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, deployment LDAP, updates/release repo, public_base_url, proxy/timeouts, drift scheduler, retention, deployment name, branding (favicon) — 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) |
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.)
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()).
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 → Deployment → 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.
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 & Delivery, Directory. API: /api/deployment/*, /api/system/* |
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/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 via the inviting admin's active tenant. Deep-link
URLs always use the deployment public_base_url.