Tenant settings - global defaults, per-tenant overrides¶
How settings split between the deployment (one install, all tenants) and a tenant (its own slice), and how per-tenant LDAP login works.
The model¶
Two settings stores:
| Store | Scope | Holds |
|---|---|---|
DeploymentSettings (core/models.py, singleton pk=1) |
whole install | SMTP defaults (Settings → Email), deployment LDAP (Settings → Directory), updates/release repo, public_base_url, proxy/timeouts (Settings → Security → Outbound delivery), drift scheduler, retention, deployment name, branding (favicon, login_logo), faceplate knobs and port_count_virtual (Settings → Component details → Port counting) - plus the defaults for every overridable group |
TenantSettings (core/models.py, OneToOne per tenant) |
one tenant | overrides for Email/SMTP, LDAP/AD, UI policy (device-field visibility, human-IDs), Delegation (site-editor delegation), Site separation (enhanced_site_separation, allow_site_settings - its own override_separation toggle, like the floor-plan popover group), Date & time (date_format, time_style, display_timezone - its own override_datetime toggle), Topology card lines (topology_card_fields, topology_card_role_overrides - its own override_topology_card toggle; Settings → Topology) - plus tenant-only fields with no deployment default, such as default_topology_view (the saved view a bare /topology opens; SET_NULL when the view is deleted; written through /api/topology-views/default/) |
SiteSettings (core/models.py, OneToOne per site) |
one site | Email/SMTP only (v1) - site-local relay + From address, for orgs whose sites run their own IT. Gated by allow_site_settings + site-admin qualification (core/site_settings.py) |
Each group on TenantSettings carries an override_* toggle. Off (and no
row at all) = inherit the deployment default. Field names mirror
DeploymentSettings exactly, so consumers (build_email_connection, the LDAP
backend builder, the sharing gates) accept either object unchanged.
Resolution lives in core/effective_settings.py:
effective_email(tenant, site=None) / effective_sharing(tenant) /
effective_ui(tenant) / effective_separation(tenant) /
effective_datetime(tenant) return the most specific row whose toggle is on,
else DeploymentSettings.load().
(separation_enabled(tenant) is the bool shortcut the RBAC fencing reads -
see Enhanced site separation. Within one HTTP
request it is read once and kept in the per-request RBAC memo; saving either
settings row drops it, so a change made mid-request is seen by the next check.)
port_count_virtual(tenant) - whether
port utilization counts virtual
interfaces - returns the deployment value: it has no tenant override yet, but
every consumer (the device and stack cards, the roll-up, spec sheets, the
alert sweep) asks through it with the tenant in hand, so one can be added
without touching them.
Topology card lines have two more layers below the tenant. What a device
card on the topology Diagram shows under its name is an ordered list of keys
from core.deployment.TOPOLOGY_CARD_FIELDS (status, monitor,
primary_ip, secondary_ip, oob_ip, loopback, serial, asset_tag,
device_type, manufacturer, platform, role, site, location, rack,
tags, plus cf_<key> for device custom fields), at most 8. status and
monitor draw the pill in the card's corner rather than a line.
The global and per-role lists are edited under Settings → Topology
(both tiers, the tenant one inheriting until its switch is on).
effective_topology_card(tenant) returns {fields, role_overrides, source}
from the tenant row when override_topology_card is on, else the deployment
row; a tenant override replaces the global and per-role lists wholesale.
resolve_card_fields(device, eff, view_fields=None) then picks, first hit
wins:
- the device's own
Device.topology_card(the device form's Topology card section, or Card lines… on a Diagram card); - the saved view's list,
state.filters.diagram.fields(the Diagram's Display popover), sent to/api/topology/ascard_fields; - the device role's list, keyed
role:<slug>inrole_overrides; - the effective global list (
sourcetenantordeployment); - the built-in default: monitoring pill, IP, Loopback, Serial (
sourcedefault).
Null (or an absent role key) inherits the next level; [] means name
only and is allowed at every level. Writes refuse unknown keys and lists
over 8 with a field error; reads drop keys the vocabulary no longer knows,
and a list that loses every key inherits rather than turning into name only.
That holds on every endpoint that carries the lists, the generic
/api/tenant-settings/ included, so a payload read and written straight
back is never refused.
Date & time has a third, per-user layer. auth_api.user_prefs carries
date_format / time_style / timezone prefs whose default is "auto" =
inherit the tenant-effective value; datetime_prefs(user, tenant) resolves
the full cascade (user → tenant → deployment; a blank stored timezone falls
back to the server's TIME_ZONE). /api/me/ exposes the resolved values
as datetime - the SPA's single read point for date/time formatting
(frontend/src/lib/datetime.ts, useDateFormat()).
Nothing formats a date on its own. new Date(x).toLocaleString() renders
in the browser's timezone, which is a different instant from every other
screen. Reach for TimeCell, the useDateFormat() hook, or the plain
helpers; formatCustom(value, opts) takes Intl options of your own (a chart
axis wants 14:00, not a full timestamp) and still applies the effective
timezone. A test (frontend/src/lib/-raw-dates.test.ts) fails the build if a
raw call comes back.
What site email affects (v1) - only sends that are about a single
site-bound object resolve the site layer: per-object monitoring alerts and
prefix-utilization warnings (notify_event(..., site_id=…)), plus the
per-site test-email endpoint. Batched change digests, sign-in/MFA codes, and
invites stay on the tenant/deployment relay - a digest mixes sites and login
happens before any site is known. Site SMTP hosts are SSRF-guarded like
tenant ones (build_email_connection).
Who is a site admin - tenant admins always; otherwise allow_site_settings
must be on and the user either holds a sitesettings change grant scoped to
the site (grantable to users or groups - build a "Site X admins" group) or is
a site editor there. Holding only the sitesettings grant does NOT make
someone an infrastructure editor (it's excluded from editable_sites).
Deployment-only groups (updates, public URL, proxy, drift, retention) have no
tenant counterpart by design - updates patch the shared process.
Branding¶
deployment_name (blank = "Danbyte") drives the sidebar header, the browser
tab title, and the login page. The tab icon is the favicon image:
blank = the shipped default (the blue Danbyte "D", frontend/public/favicon.*),
else the uploaded file served from media. Both live on DeploymentSettings and
are set from Settings → Branding & identity → Identity (users.manage). The favicon
uploads via POST /api/deployment/favicon/ (multipart; DELETE clears it) -
only Pillow-decodable raster images are accepted, which rules out SVG so no
active content lands on the media origin. me_json returns favicon_url
(null = default) and the SPA swaps the <link rel="icon"> href at runtime
(__root.tsx), the same pattern that brands the tab title from
deployment_name.
The login-page logo works the same way: login_logo on
DeploymentSettings, uploaded via POST /api/deployment/logo/ (multipart
logo; DELETE clears; raster only, max 2 MB / 2400px), shown above the
sign-in form. Blank = the bundled Danbyte wordmark
(frontend/public/branding/logo-full*.png, one variant per theme). Branding -
name, favicon and logo - also rides the anonymous me_json answer, since
the login page shows it before anyone signs in.
Sessions¶
Two deployment-wide session controls live on DeploymentSettings and are set
from Settings → Security (users.manage):
- Idle timeout (
session_idle_timeout_minutes,0= off). A rolling inactivity timeout:core.middleware.SessionIdleTimeoutMiddlewareresets each authenticated session's expiry on every request, so a session untouched for the configured span is signed out. The value is cached for ~60s (cleared on save) to keep it off the hot path; API-token requests carry no session and are unaffected. - End all sessions (
POST /api/deployment/end-all-sessions/). Deletes everydjango_sessionrow - an emergency "log everyone out" switch after a suspected compromise or a permissions overhaul. It signs the caller out too; API tokens keep working.
Plugins & service control¶
Plugins follow the same tiering. A plugin is installed deployment-wide (a
package in PLUGINS, applied on restart), then enabled/disabled per scope:
plugins.PluginConfig holds a row per (tenant, plugin) - a NULL-tenant row is
the deployment default - and plugins.resolve.plugin_enabled() resolves the
cascade (tenant row → deployment default → the plugin's default_enabled).
Tenant admins toggle their tenant (PATCH /api/plugins/<slug>/config/ scope
tenant, can_manage_admin); the deployment default is can_manage_deployment.
Service control (restart units, apply plugin migrations) is stricter still -
superuser only, since it restarts production processes. See
Plugins.
Two admin tiers¶
| Tier | Gate | Surfaces |
|---|---|---|
| Tenant admin | can_manage_admin(user, tenant) - a users.manage/user-change grant narrowed to the tenant suffices |
Settings → This tenant: General (UI + sharing overrides), Email, Directory, Monitoring, SNMP profiles. API: /api/tenant-settings/* |
| Deployment admin | can_manage_deployment(user) - superuser, global users.manage, or a user-change grant with no tenant narrowing |
Settings → Deployment: General, Updates, Email, Directory, Identity providers (SSO). API: /api/deployment/*, /api/system/*, /api/identity-providers/ |
me_json exposes both flags (can_manage_users, can_manage_deployment);
the SPA nav (settings.tsx) renders the two sections accordingly.
Per-tenant LDAP (the interesting part)¶
Login happens before a tenant is selected, so the backend resolves an
ordered directory chain (ldap_directory_chain(username)):
user@corp.comwhose domain matches a tenant's login domains → only that tenant's directory, searched asuser. The Django username keeps the fulluser@domainform - collision-proof against bare local names. A login domain may be claimed by at most one tenant deployment-wide (enforced inTenantLDAPSettingsSerializer.validate); otherwise routing would be ambiguous and a tenant could siphon another's@domainlogins.- Otherwise: deployment directory first, then each overriding tenant
directory ordered by tenant slug. First successful bind wins; local
accounts still fall through to
ModelBackend.
Security invariants (auth_api/ldap.py)¶
- Ownership anchor:
UserProfile.ldap_source_tenantrecords which tenant's directory owns an LDAP account (NULL = deployment directory / local). - Pre-bind guard (before any directory I/O): a tenant directory may only match a username it owns or one that doesn't exist. It can never authenticate as a local user, a deployment-LDAP user, or another tenant's user - so a tenant admin pointing at a malicious directory can't impersonate anyone outside their tenant. The deployment directory keeps its historical "adopt a local account" semantics but refuses tenant-owned accounts.
- Collision policy: a new tenant-directory user whose bare username already
exists elsewhere is rejected (logged to
danbyte.ldap). Use login domains to avoid collisions entirely. - Membership: a successful tenant-directory login grants
UserProfile.tenantsmembership to that tenant only. - Group-mapping escalation guard:
LDAPGroupMappingnow carries a nullabletenantFK (NULL = deployment mapping). A tenant-scoped mapping may only target anauth.Groupwhose enabledObjectPermissions are all narrowed to exactly that tenant - enforced at mapping creation and re-checked at every group sync, so widening a group's permissions later can't be laundered through an old mapping into deployment-wide access.
API summary¶
GET/PUT /api/tenant-settings/- overrides + non-secretdeployment_defaultsfor the UI's inherit summaries (tenant admin).POST /api/tenant-settings/email/test/- test through the effective SMTP.GET /api/device-fields/- effective device-field visibility (any member).GET/PUT /api/deployment/topology-card/- deployment card lines (can_manage_deployment);GET/PUT /api/tenant-settings/topology-card/- this tenant's, withoverrideanddeployment_defaults(tenant admin);GET /api/topology-card/- the effective config (any member). Payload keys:card_fields(null on PUT resets to the built-in default),is_default,role_overrides, and the vocabularyavailable,pills,defaults,max_fields. The per-device list istopology_cardonPATCH /api/devices/<id>/(device.change); a device clone carries it.GET/PUT /api/tenant-settings/ldap/+test/,test-login/,groups/, and/api/tenant-ldap-group-mappings/(tenant admin).- Deployment endpoints unchanged in shape but now require
can_manage_deployment.
What emails use which relay¶
Alert/notification channels resolve via channel.tenant; MFA codes via the
user's current_tenant (best-effort - a user with no tenant yet gets the
deployment relay); invites and reset links via the inviting admin's active
tenant. Deep-link URLs always use the deployment public_base_url.
A tenant admin runs the tenant's relay and can read what passes through it,
so an MFA code, invite or reset link takes the tenant relay only when the
account works in that tenant alone and is no deployment admin
(login_api.mail_tenant_for). A superuser, a deployment admin, a
grant_superuser holder or anyone who also works in another tenant gets
them through the deployment relay - otherwise that tenant's admin could take
the account over from the relay's log.
That needs a deployment relay: a deployment SMTP host, or an EMAIL_BACKEND
that delivers (not console, locmem, dummy or file). Without one they fall
back to the tenant relay above, so an install that only set up tenant mail
keeps delivering them.