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)
The hierarchy
Section titled “The hierarchy”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_idin the service layer.
Isolation model
Section titled “Isolation model”How it works
Section titled “How it works”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.
What isolation does not cover
Section titled “What isolation does not cover”- 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_adminvisibility. Asuper_admincan 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.
Role ladder (7 tiers)
Section titled “Role ladder (7 tiers)”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 |
admin | 80 | Full capability within their own org; cannot cross org boundaries |
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 |
guest | 0 | Highly restricted; set by specific grants |
Privilege escalation prevention
Section titled “Privilege escalation prevention”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 and notification provider access
Section titled “Audit and notification provider access”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 |
Per-user site grants
Section titled “Per-user site grants”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.
Grant management endpoints
Section titled “Grant management endpoints”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 |
How grants interact with org-level roles
Section titled “How grants interact with org-level roles”super_admin,admin, andorg_adminroles always bypass the site-grant filter - they see all sites.site_admin,operator,viewer, andguestare 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.
Managing organisations
Section titled “Managing organisations”Listing and creating organisations
Section titled “Listing and creating organisations”| 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 |
Creating an organisation (example)
Section titled “Creating an organisation (example)”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.
Module enablement per org
Section titled “Module enablement per org”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 - BETA, off by default |
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) |
Org quotas
Section titled “Org quotas”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:
# In your .env fileENFORCE_ORG_QUOTAS=trueQuota tiers
Section titled “Quota tiers”| 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.
How quotas are enforced
Section titled “How quotas are enforced”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 forsuper_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 - issuper_adminonly.- Plugin install (
/api/v1/marketplace) -super_adminonly. - Infrastructure health version detail (
GET /api/v1/enterprise/health/infrastructure) reveals framework versions only tosuper_adminor users withsystem:read; all others get a redacted view.
Security events
Section titled “Security events”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 and the org boundary
Section titled “API keys and the org boundary”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.
MSP operating patterns
Section titled “MSP operating patterns”Pattern 1: one org per customer
Section titled “Pattern 1: one org per customer”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 LabYour super_admin account sees all three orgs. Customer admins see only their own org.
Recommended procedure for onboarding a new customer:
- Log in as
super_admin. POST /api/v1/organizations/with the customer name and tier.POST /api/v1/users/to create the user withorganization_idset to the new org’s ID androleset toorg_admin.- Within that org, create the initial site(s) and attach controllers.
- If MSP staff will manage this customer, add them as
operatororsite_adminand apply site grants.
Pattern 2: single org with site grants
Section titled “Pattern 2: single org with 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.
Relevant environment variables
Section titled “Relevant environment variables”| 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. |
Next steps
Section titled “Next steps”- 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)