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 5-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.

Role Score What it can do
super_admin 100 Platform-wide: list all orgs, switch org context, install plugins, validate audit chain, manage API keys for any org
org_admin 60 Manage users and org settings; read audit logs and security events; create/update notification providers
site_admin 40 Full device and config control within assigned sites
operator 20 Day-to-day device operations; no user management
viewer 10 Read-only across all allowed sites

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 group Minimum role
Read audit logs, security events, export org_admin (own org) or super_admin (all orgs)
Validate audit chain super_admin only
Create/update/delete notification providers org_admin or super_admin
Read notification providers Any 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.

Method Path Purpose
GET /api/v1/organizations/{org_id}/site-access List all per-user site grants for the org
POST /api/v1/organizations/{org_id}/site-access Grant a user access to a specific site
PUT /api/v1/organizations/{org_id}/site-access/bulk Replace 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.

Method Path Purpose
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}/dashboard Org-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:

Method Path Purpose
GET /api/v1/modules/org/{org_id} List all modules and their enablement state
POST /api/v1/modules/org/{org_id}/enable Enable 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}/disable Disable 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}/settings Update 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 ID Description
network Switch, AP, VLAN, topology, firmware
cameras Live view, recording, forensic export
voip Phone fleet, PBX management
firewall Rules, NAT, VPN, gateway orchestration
access_control Doors, credentials, cardholders - Coming soon - disabled by default, cannot be enabled yet
backup Config snapshot archive
ai Multi-provider LLM assistant - BETA
collector SNMP-trap, syslog, NetFlow collector (Observability)
hypervisor Proxmox VE: VMs, LXC, storage
storage TrueNAS 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
Tier Max users Max admins Max sites Max devices Max API keys Audit retention
FREE 3 1 1 10 1 7 days
STARTER 10 2 5 100 5 30 days
PROFESSIONAL 50 10 20 500 20 90 days
ENTERPRISE 500 50 100 5,000 100 365 days
UNLIMITED 999,999 999,999 999,999 999,999 999,999 9,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.


Variable Default Notes
ENFORCE_ORG_QUOTAS false Enable tier-based quota enforcement.
PUBLIC_BASE_URL http://localhost:8000 Defaults to http://localhost:8000. Override with your production domain - notification action URLs and agent WebSocket URLs are built from this value.
AUDIT_HMAC_KEY Falls back to SECRET_KEY HMAC 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)

All product names, logos, and brands are property of their respective owners. FreeSDN is an independent project and is not affiliated with or endorsed by the vendors it integrates with. See Trademarks.