Skip to content

Multi-tenancy & Isolation

FreeSDN is a multi-tenant platform: one installation serves multiple organisations, and each organisation may further subdivide access by site. This page explains the exact mechanisms that enforce that isolation - where they live in the code, what the failure modes are, and what you as an operator need to know to keep tenant boundaries intact.


Every authenticated request carries an organisation identity (derived from the verified JWT or API key - never from a user-supplied header). Service methods, queries, and write operations are scoped to that identity in the service layer. There is no opt-in or middleware flag to “turn on” isolation; it is structural.

The model has two layers:

LayerWhat it doesWhere it runs
Org scopeRestricts queries to rows belonging to the caller’s organisationService methods and endpoints (service layer)
Per-user site grantsFurther restricts site-level resources to an explicit allow-list for that userCurrentUser.can_access_site() + assert_can_access_site()

Both layers are enforced at the application layer, in Python, before any SQL reaches the database.


The principal object attached to every request (CurrentUser, built in app/core/dependencies.py) carries user.organization_id. Service methods that read or write data filter on this value before executing.

A typical query in a service looks like:

result = await session.execute(
select(Device)
.where(Device.organization_id == current_user.organization_id)
.where(Device.site_id == site_id)
.where(Device.deleted_at.is_(None))
)

The organization_id comes from the database row that backed the JWT - it is never read from the JWT claims themselves. The JWT role claim is used for display only; authorisation always reads user.role from the database (auth.py:1023-1033).

If a principal has no organization_id (which should not happen for normal users but can occur for unauthenticated or partially-constructed requests), access is denied. The dependency chain is:

get_current_user_optional → get_current_user → get_current_active_user

get_current_user raises AuthenticationError (401) if no principal is produced. get_current_active_user raises 403 if the user is inactive. No request reaches a service method without a fully validated, organisation-bound principal.

API endpoints that operate on org-scoped resources do not accept organization_id as a query parameter or request body field for scoping decisions. The org is always taken from the authenticated principal. A require_organization_access(org_id_param) dependency (dependencies.py:1014) is used on the few endpoints that do take an org ID in the URL path - it verifies the path org matches the caller’s org (super_admin bypasses this check only).


Organisation membership alone does not determine what sites a user can see or modify. FreeSDN uses a hybrid model (FSDN-SEC-006) where site access can be further restricted per user.

  • A user with zero UserSiteAccess rows is unrestricted within their org - they can access all sites. This preserves backward compatibility for single-site deployments and simple setups.
  • A user with one or more UserSiteAccess rows becomes site-limited (is_site_limited returns True). They can only access the sites listed in their grants, regardless of their role level.

The CurrentUser principal pre-loads the accessible site list at authentication time:

dependencies.py
@property
def is_site_limited(self) -> bool:
# super_admin / org_admin (includes admin) are NEVER site-limited,
# regardless of any UserSiteAccess grants they may have.
if self.is_superuser or self.is_org_admin:
return False
return bool(self._accessible_site_ids)
def can_access_site(self, site_id: UUID) -> bool:
if self.is_superuser or self.is_org_admin:
return True # full org scope; grants are irrelevant
if self.is_site_limited:
return site_id in self._accessible_site_ids
return True # no grants configured → unrestricted

Site access levels within a grant are read, write, or admin/full. An unknown level fails secure (deny) rather than defaulting to any access.

Endpoints that operate on a specific site call assert_can_access_site (or _check_site_access in the sites service) before executing any logic. This raises 403 if the caller’s grants do not include the target site. The check also verifies that the site belongs to the caller’s organisation - preventing a user in org A from reaching a site in org B even if they somehow know its UUID.

The require_site_permissions dependency factory (dependencies.py:905) combines the site-access check with a permission check in a single Depends():

@router.get("/sites/{site_id}/devices")
async def list_devices(
site_id: UUID,
_: CurrentUser = Depends(require_site_permissions("device:read")),
current_user: CurrentUser = Depends(get_current_active_user),
):
...

Site grants are managed through the organisations API. Only super_admin and org_admin can read or modify them. admin-level users (role level 80) are not authorised to manage site grants and receive a 403 on all four site-access endpoints.

MethodPathPurpose
GET/api/v1/organizations/{org_id}/site-accessList all grants (capped at 2,000 rows)
POST/api/v1/organizations/{org_id}/site-accessGrant a user access to a site
PUT/api/v1/organizations/{org_id}/site-access/bulkReplace all grants for a user at once
DELETE/api/v1/organizations/{org_id}/site-access/{access_id}Revoke a single grant

org_admin callers are confined to their own organisation; super_admin can manage any org’s grants.


Role hierarchy and privilege escalation prevention

Section titled “Role hierarchy and privilege escalation prevention”

Five assignable roles are defined in the UserRole enum (app/models/core.py). The permission system also defines two internal levels - admin (80) and guest (0) - used by ROLE_HIERARCHY in dependencies.py for numeric comparisons and backward-compatible is_org_admin checks, but neither can be assigned to a user through the API (UserCreate.role and UserUpdate.role are typed UserRole, so Pydantic rejects those strings with HTTP 422).

RoleLevelTypical useAssignable via API
super_admin100Platform operator; sees all orgsYes
org_admin60Org management; cannot assign super_adminYes
site_admin40Site-scoped managementYes
operator20Day-to-day operationsYes
viewer10Read-onlyYes

validate_role_assignment (dependencies.py:94) enforces that a caller can only assign roles strictly lower than their own level. A caller at level 0 is denied outright. An unknown target role returns 400 rather than defaulting to any assignment. This prevents privilege escalation through role management.

Role changes trigger an immediate token_version bump, which invalidates all active sessions and API keys for the affected user - so a downgrade takes effect without waiting for token expiry.


API keys carry an explicit scopes list. When a key has one or more scopes, the principal is marked scoped=True and has_permission() evaluates only against that scope list - even a super_admin owner’s key is restricted to the declared scopes.

This means you can issue a key with ["device:read", "network:read"] to an integration and know it cannot call any write endpoint, regardless of what role the owning user holds.

Rules for creating API keys:

  • Scopes cannot exceed the creating user’s own permissions (403 if attempted).
  • * (wildcard) cannot be granted by any caller who does not already hold * permission. In practice this means only a super_admin owner can create a key scoped to *; all other callers receive a 403 if they include * in the requested scopes.
  • Keys optionally expire in 1-365 days via expires_in_days; omit the field to create a non-expiring key.
  • Maximum 50 active keys per user (enforced with SELECT ... FOR UPDATE to prevent TOCTOU race).
  • The full key value is returned once at creation and never again. Store it immediately.

Keys are sent via the X-API-Key header.


Real-time event delivery over WebSocket applies the same org and site scoping as REST:

  • Org filter fails closed in both directions: an event or connection missing an org_id is dropped. A mismatch between the receiver’s org and the event’s org is dropped.
  • Per-user site scope: site-tagged events are only delivered to connections whose user has a matching site grant. Untagged events are only delivered if they are explicitly targeted at that user (payload.user_id matches).
  • Session re-validation every 5 minutes: the server re-checks is_active, token_version, and deleted_at for every open connection. A revoked or downgraded session receives a session_revoked message and the connection is closed.
  • Subscription filtering: subscriptions to event patterns are checked against SUBSCRIPTION_PERMISSIONS (app/core/ws_rbac.py). Denied patterns are returned in a subscription_denied list rather than silently dropped, so clients can detect misconfiguration.
  • Payload sanitisation: before delivery, payloads are stripped of password, api_key, token, secret, refresh_token, and encryption_key fields.

The audit log (AuditLogRecord) carries a site_id field and is org-scoped in the service layer. The security event log (SecurityEventRecord) does not have a site_id - it is org-scoped only, and readable only by admin-and-above. This means security events are visible to org admins across all sites, which is intentional (you need to see failed login attempts regardless of which site they targeted).


super_admin is a platform-level role, not a tenant role. It can read and write across all organisations. Use it only for:

  • Initial platform setup and organisation provisioning.
  • Incident response requiring cross-tenant access.
  • Plugin and marketplace management.

super_admin cannot grant another super_admin. The role assignment validator enforces strictly-lower-than: a super_admin (level 100) can assign up to org_admin (level 60) - the highest role in the assignable UserRole set. (admin appears in the internal ROLE_HIERARCHY numeric table but is not a member of the UserRole enum, so any request body with role: "admin" is rejected by Pydantic with HTTP 422 before validate_role_assignment is reached.)


Deleted rows (users, sites, devices) are not physically removed. They carry a deleted_at timestamp, and all queries filter deleted_at IS NULL. This means:

  • A deleted user’s historical audit records remain intact.
  • A deleted site’s devices and events are still present in the database, but invisible through normal API paths.
  • Reusing a deleted entity (same slug, same credential) requires a new row - the old row is not resurrected.

Token version bumps on user deletion ensure that any active sessions for a deleted account are invalidated immediately, even before the access token expires.


Write operations to vendor devices go through AdapterStagingService. Staged changes carry an organization_id from the creating principal. The apply and discard endpoints verify:

if change.organization_id != user.organization_id:
raise HTTPException(404) # not visible across tenants

A change staged by org A is not visible, discoverable, or applicable by org B. The 404 (not 403) response prevents cross-tenant enumeration.



  • Roles & Permissions - full permission table per role, and how to assign grants.
  • Secrets & Credentials - Fernet encryption, redact_secrets, and credential storage.
  • Hardening Checklist - production deployment checklist that covers super_admin account hygiene, API key rotation, and audit log review.
  • Security Model - authentication flows, CSRF, rate limiting, and supply chain.