HomeDocs › Auth & Permissions

Auth & Permissions

Implementation status (shipped): Local auth (JWT + rotating refresh token), TOTP MFA, API tokens with scopes, five external identity provider types configured at runtime from the admin UI (LDAP, OIDC, SAML, RADIUS, TACACS+), failover to backup servers on LDAP / RADIUS / TACACS+, group-based RBAC enforced on every API router, and a set of builtin roles seeded at startup (see PERMISSIONS.md). Deferred: SCIM provisioning.

Overview

SpatiumDDI’s auth stack has three layers:

  1. Authentication — verifying who you are. Local password login, plus any mix of enabled external providers. All external providers funnel through a unified sync_external_user() that creates / updates the local User and replaces their group membership from the provider’s group mappings. A login with no group mapping match is rejected.
  2. Session — the login handler issues a short-lived JWT access token (15 min default) and a longer-lived rotating refresh token stored as a hashed row in user_session.
  3. Authorization — every API router uses a permission helper (require_permission / require_resource_permission / require_any_permission) to check that the caller’s groups carry a role with a matching permission entry. See PERMISSIONS.md for the grammar.

Login flow — three authentication paths, one identity sync

Local auth

TOTP MFA (issue #69)

Local-user 2FA stacks on top of the password flow. As of #408, TOTP enrolment is also open to external-identity users (LDAP / OIDC / SAML / RADIUS / TACACS+) — not for login (they still authenticate at their provider), but so an SSO superadmin can re-confirm sensitive reveals (appliance kubeconfig, pairing codes, agent bootstrap keys, SNMP community) with a TOTP code, since those reveals demand a credential the session alone doesn’t prove and an SSO account has no local password.

External identity providers

All providers are configured at runtime from /admin/auth-providers. The AuthProvider row carries:

Column Type Notes
id UUID  
name str Display name shown on the login page.
type str "ldap", "oidc", "saml", "radius", or "tacacs".
is_enabled bool Disabled providers are skipped at login.
priority int Lower = tried first (password-flow fallthrough).
auto_create_users bool Create missing users at first login.
auto_update_users bool Update email / display name on every login.
config jsonb Public config (see per-provider fields below).
secrets_encrypted bytes Fernet-encrypted secrets dict.

Secrets at rest. Everything in secrets_encrypted is wrapped with Fernet. The encryption key is derived from settings.credential_encryption_key (explicit) or SHA-256(secret_key) (fallback), so a fresh deploy that only sets SECRET_KEY still works. See backend/app/core/crypto.py.

Group mappings. Each provider owns a list of AuthGroupMapping rows (external_groupinternal_group_id). The unified sync in backend/app/core/auth/user_sync.py resolves the user’s provider-reported groups case-insensitively and rejects the login if no mapping matches.

LDAP

Driver: backend/app/core/auth/ldap.py (ldap3).

Flow per login:

  1. Bind as the service account (config.bind_dn + secrets.bind_password).
  2. Search under config.user_base_dn using config.user_filter (must contain the {username} placeholder).
  3. Bind as the returned DN + the user’s password to verify credentials.
  4. Extract groups from the memberOf attribute (or config.attr_member_of).

Key config fields:

Field Default Notes
host FQDN or IP of the primary directory.
port 636 (SSL) / 389  
backup_hosts [] List of "host" or "host:port" entries. Bracketed IPv6 ([::1]:389) allowed.
use_ssl true ldaps:// on port 636.
start_tls false Upgrade a plain connection after bind.
bind_dn Service account DN.
user_base_dn Search base for user lookups.
user_filter (&(objectClass=user)(sAMAccountName={username})) Must contain {username}.
attr_username / attr_email / attr_display_name sAMAccountName / mail / displayName  
attr_member_of memberOf  
tls_ca_cert_file Container path to a custom CA.

OIDC

Driver: backend/app/core/auth/oidc.py (authlib).

Flow:

  1. Login page calls GET /auth/{provider_id}/authorize — 302 redirects to the IdP’s authorization endpoint with a signed-JWT state+nonce cookie (oidc_flow).
  2. IdP redirects back to /auth/{provider_id}/callback with an authorization code.
  3. Backend validates the state+nonce cookie, exchanges the code, validates the ID token via authlib.jose (discovery + JWKS cached), and redirects to /auth/callback#token=…&refresh=… on the frontend.
  4. Frontend’s LoginCallbackPage consumes tokens from the URL hash and routes into the app.

Key config fields:

Field Notes
discovery_url e.g. https://accounts.google.com/.well-known/openid-configuration
client_id From the IdP.
scopes Array. Defaults to ["openid", "profile", "email"].
claim_username / claim_email / claim_display_name / claim_groups Claim names to pull from the ID token.

Secrets: client_secret.

SAML

Driver: backend/app/core/auth/saml.py (python3-saml).

Flow:

  1. Login page calls GET /auth/{provider_id}/authorize — builds an HTTP-Redirect AuthnRequest and 302s to the IdP’s SSO URL.
  2. IdP POSTs the SAMLResponse back to /auth/{provider_id}/callback (ACS endpoint).
  3. Backend consumes the assertion and redirects to /auth/callback#token=….
  4. GET /auth/{provider_id}/metadata returns SP metadata XML so admins can register SpatiumDDI at the IdP.

Key config fields:

Field Notes
idp_metadata_url Optional — backend can pull IdP details automatically.
idp_entity_id / idp_sso_url / idp_slo_url Set these when you don’t provide a metadata URL.
idp_x509_cert Base64 or PEM — used to verify the assertion.
sp_entity_id Defaults to the app URL.
attr_username / attr_email / attr_display_name / attr_groups SAML attribute names.

Secrets: sp_private_key (PEM, optional — only needed for signed requests).

RADIUS

Driver: backend/app/core/auth/radius.py (pyrad).

Flow: one UDP round-trip per login. The driver sends Access-Request with User-Name + User-Password (pyrad encrypts). Access-Accept with group info lifted from the reply attribute (Filter-Id by default) is a success; Access-Reject is bad credentials; timeout / MAC mismatch raises RADIUSServiceError so the caller can fall through to the next provider.

Key config fields:

Field Default Notes
server Primary RADIUS host.
port 1812 Auth port.
backup_servers [] List of "host" or "host:port" entries.
timeout 5 Seconds per attempt.
retries 3 Per server, before failing over.
nas_identifier "spatiumddi" Stamped into every Access-Request.
attr_groups "Filter-Id" Attribute that carries group info.
dictionary_path Optional extra RADIUS dictionary file path.

Secrets: secret (shared secret — bytes).

TACACS+

Driver: backend/app/core/auth/tacacs.py (tacacs_plus).

Flow:

  1. client.authenticate(username, password) over TCP; valid=True is a success.
  2. client.authorize(username) round-trip pulls AV pairs. priv-lvl numeric values are surfaced as priv-lvl:N so admins can map e.g. priv-lvl:15Admins in the group-mapping UI.

Key config fields:

Field Default Notes
server Primary TACACS+ host.
port 49  
backup_servers [] List of "host" or "host:port" entries.
timeout 5 Seconds.
attr_groups "priv-lvl" AV pair used for group mapping.

Secrets: secret.

Backup server failover (LDAP / RADIUS / TACACS+)

Each password provider accepts an optional list of backup hosts via config.backup_hosts (LDAP) or config.backup_servers (RADIUS / TACACS+). Entries are strings of the form "host" or "host:port"; bracketed IPv6 literals ([::1]:389) are supported. The admin UI exposes a “Backup hosts / servers” textarea (one entry per line).

LDAP failover uses ldap3.ServerPool(pool_strategy=FIRST, active=True, exhaust=True):

RADIUS + TACACS+ failover iterates primary → backups manually:

Test-connection probe

Every provider type exposes a probe under backend/app/core/auth/ that returns {ok, message, details} without raising — test_connection for LDAP / RADIUS / TACACS+, probe_discovery for OIDC, and probe_metadata for SAML. The admin UI’s “Test” button hits POST /api/v1/auth-providers/{id}/test to run the probe. For LDAP + OIDC + SAML the probe does a real service bind / discovery fetch / metadata fetch; for RADIUS + TACACS+ it sends a stub Access-Request — a Reject for the bogus credentials still proves the server is reachable and the shared secret is correct (the MAC would not validate otherwise).

Provider priority

When a password-grant login arrives at /auth/login:

  1. Local credentials are tried first. If the User exists and has a hashed password, that wins (or loses) on its own.
  2. Otherwise the handler iterates every enabled provider whose type is in PASSWORD_PROVIDER_TYPES = ("ldap", "radius", "tacacs") in (priority, name) order. Each authenticate_*() call runs in a worker thread (asyncio.to_thread) with a 20 s timeout.
  3. The first definitive answer wins. Service errors (unreachable / misconfigured) are logged and fall through to the next provider.
  4. If nothing accepted, a single 401 + a login audit row are emitted.

OIDC and SAML are redirect flows — they are never tried at /auth/login. The login page lists every enabled OIDC/SAML provider as a “Sign in with …” button that kicks off the redirect flow directly.

Permission enforcement

See PERMISSIONS.md for the full grammar + built-in roles. In short:

Audit

Every login attempt — success, failure, LDAP service error — writes an AuditLog row with action="login", result="success"|"failure", the auth source (local, ldap, oidc, saml, radius, tacacs), source IP, user agent, and for failures a new_value.reason string. Failed logins for unknown usernames still get a row (user_id=NULL, user_display_name=<attempted name>) so brute-force attempts are visible in the audit viewer.

API tokens

Long-lived bearer credentials for scripts, CI pipelines, and automation. As of issue #74, tokens carry an explicit scopes set that narrows the owning user’s permissions — a token can do at most what its scope set allows AND what the owning user has permission for. Tokens are indistinguishable from JWTs on the wire (both use Authorization: Bearer …); the auth middleware peeks at the prefix to pick the validation path.

Scopes (issue #74). APIToken.scopes is a JSONB list drawn from a closed coarse-grained vocabulary (backend/app/services/api_token_scopes.py), checked against TOKEN_SCOPE_VOCABULARY at create time so a typo is rejected with 422 rather than silently locking the operator out:

Multiple scopes compose by union (any single match passes). An empty scope set means no scope restriction at all (the token still inherits the owning user’s RBAC, which is the only gate) — equivalent to the pre-#74 behaviour. New tokens pick scopes via a chip selector in the create modal.

Scope enforcement runs before RBAC: a non-empty scope set narrows the token, and the owning user’s RBAC still applies on top, so a read-scoped token owned by an IPAM Editor can list subnets but cannot create one. A per-token resource-instance binding (resource_grants, #374) can narrow further to specific {action, resource_type, resource_id} grants, validated at create time to be a subset of what the issuing user holds.

Wire format. Raw tokens start with sddi_ followed by 40 bytes of url-safe base64 entropy (secrets.token_urlsafe(40)). Operators typically see only the first 10 characters (sddi_AbCdE) in the UI as an identifier — this is the APIToken.prefix column, sufficient to pick a token out of a list without leaking entropy to an observer.

At rest. Only the SHA-256 hash is stored (APIToken.token_hash). The raw value is returned exactly once from POST /api/v1/api-tokens and never again — losing it means creating a new token.

Validation path. app/api/deps.py:get_current_user checks for the sddi_ prefix first; if present it hashes the bearer, looks the row up by hash, enforces is_active and expires_at, then loads the owning user and bumps last_used_at. JWTs take the original path. A missing / wrong / revoked token returns the same generic 401 as an invalid JWT to avoid confirming token existence to an attacker.

Lifecycle.

TTL. The UI defaults to 90 days and flags “Never” in amber so operators have to opt into long-lived bearers. The backend accepts both expires_in_days (UX-friendly) and expires_at (ISO timestamp, for automation). Expired tokens 401 with a distinct detail message so clients can detect “refresh me” vs “reconfigure me”.

Open items

Rules & constraints

Server-side validations that reject requests with a human-readable error. Clients should surface the response detail to the operator rather than swallowing the failure. Permission-related rejections (403 forbidden) are covered separately in docs/PERMISSIONS.md.

Login & session

Password management

Auth providers