Skip to content

Multi-Tenancy & MSP Support

Django Micboard provides optional multi-tenancy support for Managed Service Providers (MSP) and multi-campus deployments through the micboard.multitenancy module.

Overview

Three deployment modes are supported:

  1. Single-Site (default) - No tenant isolation, monitoring groups only
  2. Multi-Site - Basic site-level filtering using Django’s sites framework
  3. MSP Mode - Full organization and campus hierarchy with strict isolation

Quick Start

Single-Site (Default)

No configuration needed. Works out of the box.

settings.py
MICBOARD_MULTI_SITE_MODE = False
MICBOARD_MSP_ENABLED = False

Multi-Site Mode

Enable Django’s sites framework for basic site filtering:

settings.py
INSTALLED_APPS = [
'django.contrib.sites',
'micboard',
# ... other apps
]
SITE_ID = 1
MICBOARD_MULTI_SITE_MODE = True
Terminal window
uv run --no-sync python manage.py migrate

MSP Mode (Full Multi-Tenancy)

Enable organization and campus models:

settings.py
INSTALLED_APPS = [
'django.contrib.sites',
'micboard',
'micboard.multitenancy', # Add this
# ... other apps
]
SITE_ID = 1
MICBOARD_MULTI_SITE_MODE = True
MICBOARD_MSP_ENABLED = True
MICBOARD_SITE_ISOLATION = 'organization'
MIDDLEWARE = [
# ... existing middleware
'micboard.multitenancy.middleware.TenantMiddleware', # Add after auth
]
Terminal window
uv run --no-sync python manage.py migrate

Configuration

Settings Reference

Configure the required values explicitly in the host settings module:

# Enable/disable features
MICBOARD_MULTI_SITE_MODE = False
MICBOARD_MSP_ENABLED = False
MICBOARD_SITE_ISOLATION = 'none' # 'none', 'site', 'organization', 'campus'
# Cross-org access
MICBOARD_ALLOW_CROSS_ORG_VIEW = True # Superusers see all orgs
MICBOARD_ALLOW_ORG_SWITCHING = True # Users can switch between orgs
# Optional: Subdomain routing
MICBOARD_SUBDOMAIN_ROUTING = False
MICBOARD_ROOT_DOMAIN = 'micboard.example.com'

Models

Organization

Top-level tenant entity for MSP deployments.

from micboard.multitenancy.models import Organization
# Create organization
org = Organization.objects.create(
name="University A",
slug="university-a",
site_id=1,
subscription_tier='enterprise',
max_devices=500
)

Fields:

  • name - Organization name (unique)
  • slug - URL-safe identifier
  • site - Django Site FK
  • is_active - Active status
  • subscription_tier - ‘basic’, ‘pro’, ‘enterprise’
  • max_devices - Organization-owned wireless chassis limit (null = unlimited). The canonical chassis persistence service locks the organization and enforces this limit for new inventory and ownership transfers; locationless platform inventory is outside organization quotas.
  • logo - Organization logo
  • primary_color - Brand color (hex)

Campus

Sub-organization unit for multi-campus deployments.

from micboard.multitenancy.models import Campus
# Create campus
campus = Campus.objects.create(
organization=org,
name="North Campus",
slug="north",
address="123 University Ave",
city="Boston",
state="MA",
timezone='America/New_York'
)

OrganizationMembership

User access to organizations with role-based permissions.

from micboard.multitenancy.models import OrganizationMembership
# Add user to organization
membership = OrganizationMembership.objects.create(
user=user,
organization=org,
role='admin', # 'viewer', 'operator', 'admin', 'owner'
campus=campus, # Optional: limit to specific campus
)

Roles:

  • viewer - Read-only access
  • operator - Can modify device assignments
  • admin - Full access except billing
  • owner - Full access including billing

Service Layer Integration

Request-facing queries derive their scope from the authenticated user:

from micboard.models.hardware.wireless_chassis import WirelessChassis
from micboard.models.hardware.wireless_unit import WirelessUnit
from micboard.services.monitoring.monitoring_access import MonitoringService
chassis = WirelessChassis.objects.for_user(user=request.user).filter(
status__in=("online", "degraded", "provisioning"),
)
units = WirelessUnit.objects.for_user(user=request.user).filter(
status__in=("online", "degraded", "provisioning"),
)
locations = MonitoringService.get_accessible_locations(request.user)

Explicit access scope

Do not replace an authenticated scope with optional tenant identifiers:

chassis = WirelessChassis.objects.for_user(user=request.user).filter(
status__in=("online", "degraded", "provisioning"),
)

Managers & Querysets

TenantOptimizedQuerySet

TenantOptimizedQuerySet provides consistent filtering across deployment modes, and models expose it directly through as_manager():

from micboard.models.base_managers import TenantOptimizedQuerySet
class MyModel(models.Model):
# ... fields
objects = TenantOptimizedQuerySet.as_manager()
# Usage
queryset = MyModel.objects.for_user(user=request.user)
queryset = MyModel.objects.for_site(site_id=1)

for_user resolves the caller’s deployment mode — MSP membership, multi-site, or monitoring-group scoping. It fails closed for an anonymous user, and in MSP mode for a user with no active membership. In single-site mode it narrows through monitoring groups only when the model has a location relation and the user has monitoring_groups; otherwise it returns the queryset unchanged, because single-site deployments have no tenant boundary to enforce.

for_site and for_memberships are the narrower filters it composes. for_site is a no-op outside multi-site mode; for_memberships always applies the organization and campus identifiers it is given.

Middleware

TenantMiddleware

Attaches organization context to requests:

# In any view
def my_view(request):
org = request.organization # Current organization or None
campus_id = request.campus_id # Current campus ID or None
chassis = WirelessChassis.objects.for_user(user=request.user).filter(
status__in=("online", "degraded", "provisioning"),
)

Organization detection priority:

  1. Session (user switched org)
  2. User’s primary organization membership
  3. Subdomain (if MICBOARD_SUBDOMAIN_ROUTING=True)

Switching organizations:

# In a view
def switch_org(request, org_id):
request.session['current_organization_id'] = org_id
return redirect('dashboard')

View Integration

Apply tenant filtering in your views by accessing the organization attached to the request:

from django.http import JsonResponse
from django.views import View
from micboard.models.hardware.wireless_chassis import WirelessChassis
class ReceiverListAPIView(View):
def get(self, request):
chassis = WirelessChassis.objects.for_user(user=request.user).filter(
status__in=("online", "degraded", "provisioning"),
)
# Return as JSON
return JsonResponse({
"chassis": list(chassis.values())
})

Migration Guide

From Single-Site to Multi-Site

  1. Enable multi-site mode in settings
  2. Run migrations
  3. All buildings assigned to default site (SITE_ID=1)
  4. No code changes needed
Terminal window
# settings.py: Set MICBOARD_MULTI_SITE_MODE = True
uv run --no-sync python manage.py migrate

From Multi-Site to MSP

  1. Enable MSP mode in settings
  2. Run multitenancy migrations
  3. Create default organization and campus
  4. Assign buildings to organization/campus
  5. Create user memberships
Terminal window
# settings.py: Set MICBOARD_MSP_ENABLED = True
uv run --no-sync python manage.py migrate
uv run --no-sync python manage.py shell
# In shell
from micboard.multitenancy.models import Organization, Campus
from micboard.models.locations.structure import Building
# Create default org/campus
org = Organization.objects.create(
name='Default Organization',
slug='default',
site_id=1
)
campus = Campus.objects.create(
organization=org,
name='Main Campus',
slug='main'
)
# Update buildings
Building.objects.all().update(
organization_id=org.pk,
campus_id=campus.pk,
)

Use Cases

Large Enterprise (Single Org, Multi-Campus)

settings.py
MICBOARD_MSP_ENABLED = True
MICBOARD_SITE_ISOLATION = 'campus'
MICBOARD_ALLOW_ORG_SWITCHING = False # Single org
# Users have campus-specific access
membership = OrganizationMembership.objects.create(
user=av_tech,
organization=university,
campus=north_campus, # Limited to North Campus only
role='operator'
)

MSP (Multiple Orgs)

settings.py
MICBOARD_MSP_ENABLED = True
MICBOARD_SITE_ISOLATION = 'organization'
MICBOARD_ALLOW_CROSS_ORG_VIEW = False # Strict isolation
MICBOARD_SUBDOMAIN_ROUTING = True
# Each customer is separate org
church_a = Organization.objects.create(name='Church A', slug='church-a', site_id=1)
church_b = Organization.objects.create(name='Church B', slug='church-b', site_id=1)
# Users belong to their org only
OrganizationMembership.objects.create(user=tech1, organization=church_a, role='admin')
OrganizationMembership.objects.create(user=tech2, organization=church_b, role='admin')
# Subdomain routing: church-a.micboard.example.com → Church A

Small Single-Site

settings.py
MICBOARD_MULTI_SITE_MODE = False
MICBOARD_MSP_ENABLED = False
# All features disabled, zero overhead
# Uses existing monitoring group filtering

Testing

Test tenant isolation:

from django.test import TestCase
from micboard.multitenancy.models import Organization, Campus
from micboard.models.hardware.wireless_chassis import WirelessChassis
class TenantIsolationTest(TestCase):
def test_organization_isolation(self):
org1 = Organization.objects.create(name='Org 1', slug='org1', site_id=1)
org2 = Organization.objects.create(name='Org 2', slug='org2', site_id=1)
# Create devices in each org
# ... create buildings, locations, receivers
# Verify isolation through users with memberships in each organization
org1_chassis = WirelessChassis.objects.for_user(user=org1_user)
org2_chassis = WirelessChassis.objects.for_user(user=org2_user)
self.assertEqual(org1_chassis.count(), 5)
self.assertEqual(org2_chassis.count(), 3)
# Verify no cross-contamination
self.assertNotIn(org2_chassis[0], org1_chassis)

Performance Considerations

  • Indexes: Added on organization_id, campus_id, and site_id
  • Query optimization: Filters applied at database level
  • Single-site overhead: Zero - features disabled via settings checks
  • Multi-site overhead: Minimal - single JOIN added to queries
  • MSP overhead: Moderate - additional JOINs for org/campus filtering

Security

  • Tenant isolation: Enforced at service layer and manager level
  • Superuser override: Configurable via MICBOARD_ALLOW_CROSS_ORG_VIEW
  • Session hijacking: Organization IDs validated against user memberships
  • Subdomain routing: Requires proper DNS and SSL configuration

Troubleshooting

Buildings have no organization

# Assign buildings to default org
from micboard.multitenancy.models import Organization
org = Organization.objects.first()
Building.objects.filter(organization_id__isnull=True).update(organization_id=org.pk)

User can’t see devices

Check organization membership:

from micboard.multitenancy.models import OrganizationMembership
memberships = OrganizationMembership.objects.filter(user=user, is_active=True)
print(f"User has {memberships.count()} active memberships")

Queries returning empty

Verify feature flags:

from micboard.services.settings.settings_service import settings as micboard_settings
print(f"Multi-site: {micboard_settings.multi_site_mode}")
print(f"MSP: {micboard_settings.msp_enabled}")

API Reference

See micboard.multitenancy module for complete API documentation:

  • models.py - Organization, Campus, OrganizationMembership
  • micboard.models.base_managers - TenantOptimizedQuerySet
  • middleware.py - TenantMiddleware
  • admin.py - Django admin interfaces