QDG Knowledge Base Read-only viewer QWebHub
general

Roles, Permissions & Multi-Tenancy

Version 2 · Rewrote the Roles section for the 2026-09-06/07 changes: the 5-tier Super Admin/Admin/Developer/Support Lead/User model, is_admin-flag-based access checks, and the site_roles DB table replacing the hardcoded ROLE_GROUPS dict. Added Site Clients to the multi-tenancy/client comparison.

Roles, Permissions & Multi-Tenancy

Written for the "official developer and integration guide" requested in ClickUp task 86d40a7qe, which frames QDBAuth as "similar to Keycloak." This page documents where that comparison holds and where it doesn't — most importantly for multi-tenancy, which QDBAuth does not actually implement the way Keycloak does.

Superseded 2026-09-06/07: the role model described below replaced an earlier flat admin/developer/user list plus a single hardcoded TD custom group. See Changelog for the exact delivery dates.

Roles

Every staff account has exactly one role string (users.role) plus an is_admin flag. There is still no separate fine-grained permission system (no per-action permission grants) — role plus is_admin plus is_site_admin plus approved_sites together are the permission model, and consuming apps are expected to interpret the role string returned to them for their own authorization decisions.

The 5-tier dashboard-access model

The shared default role list (falls back for any site with no roles of its own — see "Per-site roles" below) is: Super Admin, Admin, Developer, Support Lead, User. What each controls, in QDBAuth's own dashboard (apps/auth/dashboard.py):

Role Access
Super Admin Every dashboard page, every site — dashboard.require_admin.
Admin Users / Create-user / Site Config / Site Clients, scoped to the account's own approved_sites — dashboard.require_dashboard_admin. Never Manage Sites, Groups, or Logs (Super-Admin-only).
Developer, Support Lead, User No admin screens at all. Login redirects here to a self-service My Account page (/dashboard/account/) — change own password, reset own authenticator, change/remove own profile picture.

How "Super Admin" is actually checked — not by parsing the role string. dashboard._is_super_admin(request) reads a dash_is_admin flag snapshotted into the session at login from the account's real users.is_admin column. This is deliberate: it means an account created before this 5-tier model existed (role literally "admin", is_admin=1) keeps full Super Admin access with zero data migration — the flag was already 1 regardless of which role vocabulary was in use when the account was created.

How "Admin" (scoped) is checked: dashboard._is_scoped_admin(request) — True if the role string uppercases to "ADMIN" or the legacy is_site_admin flag is set (the older, still-supported username-login account type from the 2026-07 delivery; both routes land on the same approved_sites-scoped access).

Per-site roles — now database-backed, not hardcoded

A site's role list lives in the site_roles table (apps/auth/site_roles.py), managed through a dashboard page at /dashboard/site-roles/ — not a hardcoded Python dict anymore (that was the 2026-07-30 ROLE_GROUPS design; see Changelog). Each role row can be flagged:

  • is_default — pre-selected when creating an account for that site.
  • is_admin_equivalent — assigning this role to a staff account sets is_admin=1 (irrelevant for site clients — see below).

site_roles.role_group_for_site(site_id) / role_group_for_sites(approved_sites) resolve which list applies: a site's own rows win if it has any, else the shared default group (site_id IS NULL rows) applies. users.role_group_for_sites is now a direct alias to the latter, so every existing caller (create_user_direct, update_user, bulk_users.create_users_from_rows, dashboard.py's Create/Edit User forms) kept working unchanged.

TD (Troyen Data) still has its own list, seeded verbatim into site_roles during the 2026-09-07 migration: Developer, Support, Dev Lead, Support Lead, Super Admin (admin-equivalent). Any other site can now get its own list the same way, through the dashboard, with no code change — e.g. MTC could be given a Manager role that only makes sense for its own clients.

Known limitation, unchanged in shape: a user granted two sites that each define a different custom role list still can't hold two independent role values — role is one column on users. The mechanism moved from a Python dict to a database table, but this specific limitation is structural (one column) and wasn't addressed by that move.

Where roles are enforced

  • QDBAuth's own dashboard: _is_super_admin/_is_scoped_admin (above), checked against session state set at login.
  • Everywhere else: the consuming app receives the role string (/api/verify-2fa/'s user object, or /api/whoami/'s user_permissions) and is responsible for interpreting it. QDBAuth does not gate access to any consuming app's own features — it only vouches for who the person is and what role string they hold. Note: the role strings a consuming app might see changed on 2026-09-06 (e.g. "ADMIN" became possible for accounts that used to only ever be "admin"/"user") — see Changelog's heads-up for that date.

Site Clients — a second, separate account type (2026-09-07)

apps/auth/site_clients.py + the site_clients table hold each site's own end-customers, distinct from the users table entirely. A client:

  • Belongs to exactly one site (not a JSON list like staff approved_sites).
  • Holds a single role from that site's site_roles list (same table staff roles come from) — is_admin_equivalent has no effect for a client, since clients never reach the QDBAuth dashboard regardless of role.
  • Logs in through the same /login/ → /verify-2fa/ pages as staff (login_view tries site_clients.authenticate() as a fallback once the users table lookup misses), always ending in an SSO redirect back to their own site — never a QDBAuth dashboard session.
  • Is managed from /dashboard/site-clients/, visible to Super Admin (every site) or a scoped Admin (their own site(s) only) — the same require_dashboard_admin gate as Users.

Multi-tenancy — the actual answer

QDBAuth does not implement multi-tenancy in the Keycloak sense. There is one global users table, shared across every consuming application — a person's email is unique across the entire ecosystem, not scoped per tenant/realm. There's no data isolation between "tenants"; every site's admin can (in principle) see every user via the same dashboard. (Site Clients, added 2026-09-07, don't change this — a client's own scope is per-site, but Super Admin still sees every site's clients from one dashboard.)

What QDBAuth actually has, that's closest to a Keycloak concept, is the "site" model (site table + approved_sites) — this maps to Keycloak's "client" concept (a registered consuming application), not to a "realm" (an isolated tenant with its own user pool). Concretely:

Keycloak concept QDBAuth equivalent Notes
Realm (tenant, isolated user pool) Not implemented All staff users are global; no tenant boundary
Client (a registered app) site table entry site_name + site_url
Client-specific role mapping site_roles table, per site_id Database-backed since 2026-09-07; any site can opt in via the dashboard, not just TD
Client's own end-users site_clients table Added 2026-09-07 — closer to a Keycloak client's own user base than anything before it, but still visible ecosystem-wide to a Super Admin, unlike a true realm boundary
Per-client credentials site_credentials.py (opt-in, new accounts only) See Overview

If genuine tenant isolation (separate user pools per customer/organization) is a real future requirement, that's a substantial architectural change, not a configuration option — flag it as its own project decision rather than assuming today's "site" model already provides it. A request to rename site → Organization throughout the codebase was raised and declined on 2026-09-07 for exactly this reason: it's a naming change, not a tenancy change, and the ~450-reference blast radius wasn't worth it for a cosmetic result. See Changelog.

Related pages

Updated by Claude on Sept. 7, 2026, 8:49 a.m.