Skip to main content

Identity and SSO

The mental model behind how dtedge knows who you are, which orgs you belong to, and what you can do in each. Read this before setting up SSO; the configuration pages assume you know the shape.

For setup walkthroughs see Configure global SSO and Configure per-org SSO.

Three ways to authenticate​

MethodWhen you'd use itStored where
Local password + 2FADefault for the first super-admin, dev clusters, break-glassbcrypt hash in dtedge DB
OIDC SSOEnterprise installs where the IdP is the identity authoritydtedge links the IdP identity to a user row via (provider, subject)
API keyNon-interactive callers (CI, scripts, custom dashboards)sha256 hash in dtedge DB

The three resolve to the same user record under the hood — SSO doesn't create a parallel user table; it links into the existing one. An SSO user can also have an API key. A local user can SSO-link later.

Two SSO surfaces​

dtedge supports two distinct SSO configurations, not mutually exclusive:

Per-org SSO​

Each organization brings its own IdP that authenticates its own members. Configured by the org admin at Organizations → <org> → SSO. Each org's IdP is independent — Acme using Auth0, BorTech using Keycloak, Foo Co. using Azure AD all coexist.

Per-org SSO works on slug-first login: the user picks "Sign in with SSO" on the login page and types their org slug (acme). The dtedge backend looks up which IdP that slug maps to, redirects the user, and verifies the returned token against that org's configured client.

Global SSO​

One IdP for the whole install. Configured by a super-admin at Admin → Settings → Global SSO. Used for:

  • Single-tenant deployments where one customer owns everything and wants one IdP across every org.
  • Super-admin break-glass when per-org SSO is broken.
  • Cross-org membership driven by IdP groups (see global org-role assignments).

A global SSO user logs in by picking "Sign in with SSO" on the login page without typing a slug — global has no realm.

Which one fires?​

User clicks "Sign in with SSO"
│
▼
Did they type an org slug?
│
├── No → Global SSO (if configured)
│
└── Yes → Does that org have per-org SSO?
├── Yes → Per-org SSO
└── No → Fall through to global (if configured)
OR error "no SSO for this realm"

Per-org SSO takes precedence for the slug it covers. Global SSO is the fallback.

Identity linking​

When you SSO in for the first time, dtedge looks for a (provider, subject) pair in the user_identities table:

  • Match: link found → resolve the user, you're in.
  • No match by (provider, subject), but email matches an existing user: dtedge creates the identity link to that user. Your existing local-account roles + memberships stay.
  • No match by either: dtedge creates a fresh user — but only if auto-provision is on for this SSO config. Otherwise you see a "not provisioned" page; an admin needs to invite you first.

The match key is (provider, subject) not email because:

  • IdP sub claims are stable across email changes
  • The same user might exist at different IdPs with the same email (we want different linked users, not collision)

Group → role reconciliation​

When the IdP ships a groups claim and the SSO config has a group→role mapping (per-org SSO) or org-role assignments (global SSO), dtedge reconciles roles on every login:

User's groups from IdP : [engineers, finance-viewers]
Mapping rows : engineers → editor
finance-viewers → viewer
sales → viewer

What user should have : editor, viewer (in org X for per-org;
in org X and finance for global)
What user has in Casbin : editor, sales-viewer (stale!)

Diff:
add : viewer
revoke : sales-viewer (mapping says they're not in `sales` any more)
keep : editor (still in `engineers`, still maps)

Two important guarantees:

  1. Only roles in the mapping universe are touched. If you manually granted "admin" outside the mapping, it stays. The reconcile only removes roles that were in the mapping universe but aren't in should-have.

  2. Absent groups claim is treated as "no signal". If the IdP silently stops shipping the groups claim (broken Action, misconfigured scope), reconcile is skipped entirely. Without this safety net every user would lose every mapping-driven role on every login until the operator noticed. The HasGroupsClaim flag in the backend's verify step is what enforces this.

Super-admin isolation​

The super-admin flag is never touched by SSO group mapping. It's set manually via Admin → Users → Toggle super-admin. A super-admin who falls out of every IdP group keeps the platform-wide flag.

This is intentional: the highest-privilege flag in the system shouldn't be assignable from outside the system. An IdP misconfig can't accidentally grant super-admin; an IdP compromise can't escalate.

Require SSO​

Both SSO surfaces have a Require SSO toggle:

  • Per-org Require SSO: members of this org can't use local password. If they only belong to this org, their /login with email+password returns SSO_REQUIRED.
  • Global Require SSO: install-wide. Every non-super-admin user must SSO in. Super-admins keep local as break-glass — forcing them to SSO would let an IdP outage take the whole platform admin path down.

The login gate looks at:

Is the user a super-admin? → local always works
Does global require_sso = true? → local refused
Do all the user's orgs require SSO? → local refused
Otherwise → local works

Session lifetime​

Once you log in (local or SSO), you get a JWT with iat (issued at). The JWT lives until either:

  • exp is reached (default 24h, configurable)
  • A session invalidation event stamps users.sessions_invalidated_at later than your token's iat

Session-invalidation events:

EventTriggered by
You change your password/auth/password
Admin resets your passwordAdmin → Users → Set password
Admin disables your accountAdmin → Users → Toggle active off
You're toggled to/from super-adminAdmin → Users → Toggle super-admin
Admin resets your 2FAAdmin → Users → Reset 2FA
You disable your own 2FASettings → 2FA → Disable
You log out/auth/logout
Your IdP back-channel logout firesIdP → POST /auth/sso/backchannel-logout

After any of these, your tokens are rejected on the next request.

See also​