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:
- Single-Site (default) - No tenant isolation, monitoring groups only
- Multi-Site - Basic site-level filtering using Django’s sites framework
- MSP Mode - Full organization and campus hierarchy with strict isolation
Quick Start
Single-Site (Default)
No configuration needed. Works out of the box.
MICBOARD_MULTI_SITE_MODE = FalseMICBOARD_MSP_ENABLED = FalseMulti-Site Mode
Enable Django’s sites framework for basic site filtering:
INSTALLED_APPS = [ 'django.contrib.sites', 'micboard', # ... other apps]
SITE_ID = 1MICBOARD_MULTI_SITE_MODE = Trueuv run --no-sync python manage.py migrateMSP Mode (Full Multi-Tenancy)
Enable organization and campus models:
INSTALLED_APPS = [ 'django.contrib.sites', 'micboard', 'micboard.multitenancy', # Add this # ... other apps]
SITE_ID = 1MICBOARD_MULTI_SITE_MODE = TrueMICBOARD_MSP_ENABLED = TrueMICBOARD_SITE_ISOLATION = 'organization'
MIDDLEWARE = [ # ... existing middleware 'micboard.multitenancy.middleware.TenantMiddleware', # Add after auth]uv run --no-sync python manage.py migrateConfiguration
Settings Reference
Configure the required values explicitly in the host settings module:
# Enable/disable featuresMICBOARD_MULTI_SITE_MODE = FalseMICBOARD_MSP_ENABLED = FalseMICBOARD_SITE_ISOLATION = 'none' # 'none', 'site', 'organization', 'campus'
# Cross-org accessMICBOARD_ALLOW_CROSS_ORG_VIEW = True # Superusers see all orgsMICBOARD_ALLOW_ORG_SWITCHING = True # Users can switch between orgs
# Optional: Subdomain routingMICBOARD_SUBDOMAIN_ROUTING = FalseMICBOARD_ROOT_DOMAIN = 'micboard.example.com'Models
Organization
Top-level tenant entity for MSP deployments.
from micboard.multitenancy.models import Organization
# Create organizationorg = 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 identifiersite- Django Site FKis_active- Active statussubscription_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 logoprimary_color- Brand color (hex)
Campus
Sub-organization unit for multi-campus deployments.
from micboard.multitenancy.models import Campus
# Create campuscampus = 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 organizationmembership = OrganizationMembership.objects.create( user=user, organization=org, role='admin', # 'viewer', 'operator', 'admin', 'owner' campus=campus, # Optional: limit to specific campus)Roles:
viewer- Read-only accessoperator- Can modify device assignmentsadmin- Full access except billingowner- Full access including billing
Service Layer Integration
Request-facing queries derive their scope from the authenticated user:
from micboard.models.hardware.wireless_chassis import WirelessChassisfrom micboard.models.hardware.wireless_unit import WirelessUnitfrom 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()
# Usagequeryset = 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 viewdef 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:
- Session (user switched org)
- User’s primary organization membership
- Subdomain (if
MICBOARD_SUBDOMAIN_ROUTING=True)
Switching organizations:
# In a viewdef 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 JsonResponsefrom django.views import Viewfrom 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
- Enable multi-site mode in settings
- Run migrations
- All buildings assigned to default site (SITE_ID=1)
- No code changes needed
# settings.py: Set MICBOARD_MULTI_SITE_MODE = Trueuv run --no-sync python manage.py migrateFrom Multi-Site to MSP
- Enable MSP mode in settings
- Run multitenancy migrations
- Create default organization and campus
- Assign buildings to organization/campus
- Create user memberships
# settings.py: Set MICBOARD_MSP_ENABLED = Trueuv run --no-sync python manage.py migrateuv run --no-sync python manage.py shell# In shellfrom micboard.multitenancy.models import Organization, Campusfrom micboard.models.locations.structure import Building
# Create default org/campusorg = Organization.objects.create( name='Default Organization', slug='default', site_id=1)campus = Campus.objects.create( organization=org, name='Main Campus', slug='main')
# Update buildingsBuilding.objects.all().update( organization_id=org.pk, campus_id=campus.pk,)Use Cases
Large Enterprise (Single Org, Multi-Campus)
MICBOARD_MSP_ENABLED = TrueMICBOARD_SITE_ISOLATION = 'campus'MICBOARD_ALLOW_ORG_SWITCHING = False # Single org
# Users have campus-specific accessmembership = OrganizationMembership.objects.create( user=av_tech, organization=university, campus=north_campus, # Limited to North Campus only role='operator')MSP (Multiple Orgs)
MICBOARD_MSP_ENABLED = TrueMICBOARD_SITE_ISOLATION = 'organization'MICBOARD_ALLOW_CROSS_ORG_VIEW = False # Strict isolationMICBOARD_SUBDOMAIN_ROUTING = True
# Each customer is separate orgchurch_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 onlyOrganizationMembership.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 ASmall Single-Site
MICBOARD_MULTI_SITE_MODE = FalseMICBOARD_MSP_ENABLED = False
# All features disabled, zero overhead# Uses existing monitoring group filteringTesting
Test tenant isolation:
from django.test import TestCasefrom micboard.multitenancy.models import Organization, Campusfrom 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, andsite_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 orgfrom micboard.multitenancy.models import Organizationorg = 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 OrganizationMembershipmemberships = 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, OrganizationMembershipmicboard.models.base_managers- TenantOptimizedQuerySetmiddleware.py- TenantMiddlewareadmin.py- Django admin interfaces
