Skip to content

Multi-tenancy & MSP

FreeSDN is built multi-tenant from the ground up. A single self-hosted installation can host any number of organisations (tenants), each with its own sites, devices, users, policies, and audit trail. Organisations cannot see or touch each other’s data. If you run the platform for customers - MSP-style - you use the same primitives as any other admin; there is no separate “MSP mode” to enable.

This page covers:

  • The Org → Site Group → Site → Device hierarchy
  • How isolation is enforced (and what it is not)
  • The 7-tier role ladder and privilege escalation controls
  • Per-user site grants for partial-access operators
  • Creating and managing organisations via the API
  • Org quotas (off by default; relevant for SaaS deployments)

Installation
└── Organisation (tenant boundary)
├── Site Group (optional grouping, up to depth 64, cycle-protected)
│ └── Site Group (nested)
├── Site (physical or logical location)
│ └── Device (switch, AP, camera, phone, firewall, …)
└── User (with org-wide role + optional per-site grants)

Every object lives inside exactly one organisation. An organisation owns:

  • Sites - the unit of location. Controllers attach to a site.
  • Site Groups - optional tree of groups for bulk-targeting policies, templates, and SLAs.
  • Devices - discovered and adopted through a site.
  • Users - members with an org-level role and optional per-site grants.
  • Config templates, SLA policies, alert rules, notification providers, audit logs - all filtered by organization_id in the service layer.

Every endpoint that reads or writes data calls a service method that receives the caller’s organization_id (extracted from the JWT). The service applies an explicit WHERE organization_id = :org_id filter before returning rows, and re-checks ownership before any mutation.

Request → JWT decode → user.organization_id resolved
→ service called with org_id
→ SQL WHERE organization_id = org_id
→ response (only that org's data)

Endpoints that accept a foreign UUID - a scope_id, parent_id, site_id, or assigned_to user - verify that the referenced object belongs to the caller’s org before proceeding.

  • Database-level access. A Postgres user with direct table access can read all tenants’ rows. There is no RLS. Use role-based Postgres grants and firewall the database port.
  • super_admin visibility. A super_admin can list all organisations and read platform-wide data. This is intentional (see role ladder below). Limit who holds this role.
  • Shared infrastructure. The Valkey (cache/broker), TimescaleDB (metrics), and Celery workers are shared across tenants. A tenant whose automation floods the event bus affects background task latency for all. Use the Max tier with dedicated io-worker and connection pooling for high-tenancy deployments.

Every user has an org-level role. Roles are integers; higher wins.

RoleScoreWhat it can do
super_admin100Platform-wide: list all orgs, switch org context, install plugins, validate audit chain, manage API keys for any org
admin80Full capability within their own org; cannot cross org boundaries
org_admin60Manage users and org settings; read audit logs and security events; create/update notification providers
site_admin40Full device and config control within assigned sites
operator20Day-to-day device operations; no user management
viewer10Read-only across all allowed sites
guest0Highly restricted; set by specific grants

You cannot assign a role at or above your own level. The backend enforces a strict-lower-than check: if your role score is 60 (org_admin) you can assign up to score 40 (site_admin) but not 60 or above. This is enforced at the service layer - not just the frontend - so it holds for direct API calls.

Audit log endpoints and notification provider CRUD use a role check (ORG_ADMIN or SUPER_ADMIN) rather than fine-grained permission scopes. This is because audit data has cross-tenant implications at the super_admin level.

Endpoint groupMinimum role
Read audit logs, security events, exportorg_admin (own org) or super_admin (all orgs)
Validate audit chainsuper_admin only
Create/update/delete notification providersorg_admin or super_admin
Read notification providersAny authenticated user

A user’s org-level role gives them access to all sites in their org by default. For MSP deployments where staff members each own a customer subset, you can restrict a user to specific sites using site grants.

The grant primitive lives in app/core/site_access.py. Any endpoint that accepts a site_id parameter calls assert_can_access_site(user, site_id) - a site-limited user who does not hold a grant for that site gets a 403.

All endpoints require the caller to be an org_admin or super_admin in the target org.

MethodPathPurpose
GET/api/v1/organizations/{org_id}/site-accessList all per-user site grants for the org
POST/api/v1/organizations/{org_id}/site-accessGrant a user access to a specific site
PUT/api/v1/organizations/{org_id}/site-access/bulkReplace the full site-grant set for a user
DELETE/api/v1/organizations/{org_id}/site-access/{access_id}Revoke a single site grant
  • super_admin, admin, and org_admin roles always bypass the site-grant filter - they see all sites.
  • site_admin, operator, viewer, and guest are subject to site grants if any grants have been created for that user. If no grants exist, they see all sites.
  • Once you create even one site grant for a user, they are restricted to that set.

MethodPathPurpose
GET/api/v1/organizations/List organisations (paginated); super_admin sees all, others see own
POST/api/v1/organizations/Create a new organisation
GET/api/v1/organizations/{org_id}Get org detail with stats (site count, device count, user count)
PATCH/api/v1/organizations/{org_id}Update name, settings, tier, or status
DELETE/api/v1/organizations/{org_id}Soft-delete (does not purge data immediately)
GET/api/v1/organizations/{org_id}/dashboardOrg-level summary dashboard
POST /api/v1/organizations/
Authorization: Bearer <super_admin_token>
Content-Type: application/json
{
"name": "Acme Corp",
"slug": "acme-corp",
"settings": {
"tier": "professional"
}
}

The tier value is stored in organization.settings["tier"] as a JSONB field. Valid values: free, starter, professional, enterprise, unlimited. If omitted, the org defaults to free. The tier field only affects resource limits when ENFORCE_ORG_QUOTAS is enabled (see Org quotas below).

Switching between organisations as super_admin

Section titled “Switching between organisations as super_admin”

The JWT contains the super_admin’s own organization_id. To act within another org, use API calls that explicitly target {org_id} path parameters, or issue calls with an API key scoped to that org. There is no “impersonation” session - all actions are logged under the super_admin’s user ID.


Each organisation can independently enable or disable platform modules at runtime. By default all loaded modules are available. You can restrict an org to a subset - for example, a customer who only has the network and VoIP modules licensed.

Module enablement state is tracked in the OrganizationModule database table (core.organization_modules), not in organization.settings. The dedicated API endpoints are:

MethodPathPurpose
GET/api/v1/modules/org/{org_id}List all modules and their enablement state
POST/api/v1/modules/org/{org_id}/enableEnable a module (org_admin or super_admin). Requires JSON body: {"module_id": "network", "settings": {}} - module_id is required; settings defaults to {}.
POST/api/v1/modules/org/{org_id}/disableDisable a module (org_admin or super_admin). Requires JSON body: {"module_id": "network"} - module_id is required.
PUT/api/v1/modules/org/{org_id}/{module_id}/settingsUpdate per-module settings

Module availability is enforced at the service layer; disabling a module does not unload its code from the process. The 10 loaded modules are:

Module IDDescription
networkSwitch, AP, VLAN, topology, firmware
camerasLive view, recording, forensic export
voipPhone fleet, PBX management
firewallRules, NAT, VPN, gateway orchestration
access_controlDoors, credentials, cardholders - BETA, off by default
backupConfig snapshot archive
aiMulti-provider LLM assistant - BETA
collectorSNMP-trap, syslog, NetFlow collector (Observability)
hypervisorProxmox VE: VMs, LXC, storage
storageTrueNAS health rollup (Fabric participant, no HTTP routes)

Quotas are off by default (ENFORCE_ORG_QUOTAS=false). Self-hosted installations are unlimited by design. Quotas are a construct for SaaS deployments where you bill per tier.

To enable:

Terminal window
# In your .env file
ENFORCE_ORG_QUOTAS=true
TierMax usersMax adminsMax sitesMax devicesMax API keysAudit retention
FREE3111017 days
STARTER1025100530 days
PROFESSIONAL5010205002090 days
ENTERPRISE500501005,000100365 days
UNLIMITED999,999999,999999,999999,999999,9999,999 days

The max_devices_per_site limit (10 / 50 / 100 / 500) is enforced separately from the org-wide device total.

Quota checks use SELECT … FOR UPDATE on the org row before inserting, which closes the COUNT→INSERT TOCTOU window under concurrent requests. Crossing a limit raises an HTTP 403 with a JSON body of {"detail": "Quota exceeded: <resource> limit is <N> (current: <N>). Upgrade your tier to add more."}, naming the resource and its limit. The check is a no-op when ENFORCE_ORG_QUOTAS=false.

Resources with enforced quotas: users, admins, sites, devices, controllers.

Quota enforcement call sites:

  • Device adoption (single + batch)
  • Controller creation
  • Member addition (user count)
  • Member promotion to admin (admin count)

Cross-org boundaries and the super_admin role

Section titled “Cross-org boundaries and the super_admin role”

super_admin is the only role that crosses org boundaries. Specifically:

  • GET /api/v1/organizations/ returns all orgs for super_admin; other roles see their own org only (returned as a one-item list or detail endpoint).
  • GET /api/v1/audit/validate - audit chain validation - is super_admin only.
  • Plugin install (/api/v1/marketplace) - super_admin only.
  • Infrastructure health version detail (GET /api/v1/enterprise/health/infrastructure) reveals framework versions only to super_admin or users with system:read; all others get a redacted view.

SecurityEventRecord has no site_id column - it is not site-scoped. It does carry an organization_id column (a nullable FK to core.organizations) that org-scopes security events. An org_admin viewing /api/v1/audit/security sees only their org’s events (filtered by organization_id); a super_admin passes None and sees all events across the platform.


API keys act as a hard permission ceiling even for super_admin. An API key is scoped to a specific org and permission set at creation time. Calls made with that key cannot exceed the key’s declared permissions, regardless of the underlying user’s role.

This means: if you create an API key scoped to config:read for a super_admin account, that key cannot write config or cross into another org. Use org-scoped API keys when granting programmatic access to a specific customer’s environment.


The standard approach. Create one organisation per customer, add that customer’s users as members, and restrict your own staff with site grants if needed.

Installation
├── Org: Acme Corp - tier PROFESSIONAL
│ ├── Site: NYC HQ
│ └── Site: Chicago Branch
├── Org: Globex Inc - tier STARTER
│ └── Site: Main Office
└── Org: Internal - tier UNLIMITED
└── Site: MSP Lab

Your super_admin account sees all three orgs. Customer admins see only their own org.

Recommended procedure for onboarding a new customer:

  1. Log in as super_admin.
  2. POST /api/v1/organizations/ with the customer name and tier.
  3. POST /api/v1/users/ to create the user with organization_id set to the new org’s ID and role set to org_admin.
  4. Within that org, create the initial site(s) and attach controllers.
  5. If MSP staff will manage this customer, add them as operator or site_admin and apply site grants.

If all your customers are small and you prefer one org, create one site per customer and use site grants to segment your technicians. This is simpler operationally but you lose per-customer module enablement, quota tracking, and audit separation.

Pattern 3: API key per customer integration

Section titled “Pattern 3: API key per customer integration”

Issue an org-scoped API key for each customer’s environment. Third-party tools (n8n, monitoring systems, or custom scripts) use that key and are naturally confined to that customer’s org.


VariableDefaultNotes
ENFORCE_ORG_QUOTASfalseEnable tier-based quota enforcement.
PUBLIC_BASE_URLhttp://localhost:8000Defaults to http://localhost:8000. Override with your production domain - notification action URLs and agent WebSocket URLs are built from this value.
AUDIT_HMAC_KEYFalls back to SECRET_KEYHMAC key for the audit log hash chain. Set explicitly to allow key rotation without breaking the chain.

  • Enterprise overview - full feature status matrix and Celery beat schedule
  • Audit log - query the per-org audit trail, export records, and validate the tamper-evidence chain
  • Alert rules - create per-org alert rules with fine-grained scope targeting
  • SLA monitoring - define per-org SLA policies scoped to sites, site groups, or device groups
  • Config templates - author org-wide config templates that cascade down the site hierarchy
  • Notifications - configure per-org notification providers (SMTP, Slack, Teams, webhook, SMS)