Auth & RBAC
Four roles, two scopes, two identities — local password + TOTP, and LDAP/AD federation. Browser sessions are JWT in httpOnly cookies with double-submit CSRF and refresh-token reuse detection.
Roles & scope
owner ⊃ admin ⊃ operator ⊃ viewer
Roles are hierarchical — a higher role implies every capability of lower roles. Scopes are orthogonal:
- Org role applies to every project in the organisation by default.
- Project role overrides on a per-project basis. Most-permissive wins — a user who is org viewer + project operator is operator on that project, viewer elsewhere.
- Org-level actions (user management, LDAP, license) ignore project roles — only the org role counts.
| Role | Description |
|---|---|
| Owner | Full control. Only role that can transfer ownership, delete the organisation, or manage the license. |
| Admin | Manages users, projects, settings (LDAP, syslog, general policy). Cannot delete the org. |
| Operator | Day-to-day cert operations — issue, renew, revoke, distribute, configure ACME / DNS / modules. Cannot manage users or org settings. |
| Viewer | Read-only. Dashboards, cert listings, job status, audit logs. No mutations. |
Action → minimum role
The lowest role that can perform each action. Project-scoped actions check the user's project role; org-scoped actions ignore project overrides.
| Action | Minimum role | Scope |
|---|---|---|
| Issue / renew / reissue certificate | operator | project |
| Revoke certificate | operator (admin if policy gates it) | project |
| Download private key | operator (policy-gated) | project |
| Delete certificate | admin | project |
| Manage ACME / MSCA / DNS / module config | operator | project |
| Certificate policy / approval policy | admin | project |
| Notification channels & rules | admin | project |
| Create / manage users & LDAP | admin | org |
| Create / delete projects · syslog · org-level settings | admin | org |
| Transfer ownership · delete org · license | owner | org |
Escalation guard: a user can assign a role at or below their own. An operator cannot promote anyone to admin; an admin cannot promote anyone to owner.
Local users
Each user record carries:
| Field | Purpose |
|---|---|
email | Unique per organisation. Used for login, notifications, audit correlation. |
display_name | Shown in UI lists and audit logs. |
org_role | owner / admin / operator / viewer. |
project_roles | Map of project_id → role; per-project overrides. |
auth_source | local (password + optional TOTP) or ldap. |
otp_required | Per-user override of the org-level OTP policy. |
status | active / disabled / pending (invited, not yet accepted). |
Create: Settings → Users → New user. Email + display name + org role + (optional) project roles. Either email an invite token (user sets password on first login, valid 72 h) or set a temporary password.
Disable vs delete: prefer disable. Disable revokes every refresh token, blocks new logins, and is reversible. Delete is hard — audit records keep the user's id + display name at time of action for forensics, but you lose the user record itself.
Password reset: self-service via the login page or admin-forced via Settings → Users → menu → Force password reset. The admin never sees the new password.
Locked out (no admin left)? If the only owner forgot their password and can't reset it from the UI, run the offline recovery command on the server with access to the database (it rewrites the account's bcrypt hash — it does not touch MongoDB's own password).
On a standalone install, run it through the cap wrapper, which loads the database credentials the bare binary can't see from an interactive shell (details in the CLI reference):
cap user reset-password --username <user> --password '<new-password>'
Forgotten the username too? List the accounts first:
cap user list
Anywhere else (Docker, Helm, dev), run the binary with the database credentials in the environment (CERTAUTOPILOT_DATABASE_*) or in the config file:
certautopilot user reset-password --username <user> --password '<new-password>' \
--config /etc/certautopilot/config.yaml
Add --clear-totp to the reset to also disable two-factor for the account (e.g. a lost authenticator device), and --org-id <id> if the same username exists in more than one org. The new password must meet the panel's password policy. LDAP accounts are rejected — reset those in your directory. Log in with the new password, then change it from your profile.
LDAP / AD integration
Federate against an LDAP v3 directory or Active Directory (Enterprise feature, owner-configured). Passwords for federated users never land in CertAutoPilot's database.
The backend uses search-then-bind: it binds as a service account, searches for the
user with the configured filter (%s = the login name), then rebinds as the found user
to verify the password.
Setup: Settings → LDAP. Server URL (LDAPS preferred), service-account bind DN +
password, search base and user filter, and the default role for auto-provisioned users
(viewer by default). Use the Test Connection and filter test tools before enabling.
Auto-provisioning: the first successful login creates the user record with
auth_source: ldap and the configured default role. Role changes after that are
managed inside CertAutoPilot — there is no directory group→role mapping.
AD specifics: prefer (userPrincipalName=%s) or (sAMAccountName=%s) as the user filter.
Full field reference, TLS notes, and troubleshooting: LDAP / Active Directory.
Two-factor authentication (TOTP)
RFC 6238 time-based codes. Seeds are envelope-encrypted; validation accepts ±1 30-second window for clock skew. Works with both local and LDAP users — the directory never sees the OTP code.
Org policy (two modes, owner-set; must requires an Enterprise license):
- Optional — users may enroll if they want.
- Must — every user is forced to enroll at next login (with a grace period).
Enrollment: scan a QR code with any RFC 6238 authenticator app (Google Authenticator, Authy, 1Password, Bitwarden), enter the current code to confirm sync, then save the 8 one-shot recovery codes shown — they are the backstop for a lost device and the backend only stores hashes.
If you didn't save them, regenerate from Profile → Regenerate recovery codes (requires a current TOTP code) — all previous codes are invalidated. There is no admin-side OTP reset.
Login failure returns generic "invalid credentials" — no distinction between wrong password and wrong code. Repeated failures trigger a progressive account lockout.
Policy details, session limits, and disable flows: OTP policy.
Programmatic access
There is no separate API-key credential. Scripts, CI/CD, and Terraform authenticate as a regular user and carry a JWT bearer token:
POST /api/v1/auth/loginwith{username, password}→ anaccess_token(Bearer, 15 min TTL) plus arefresh_token(7 days). MFA users completePOST /api/v1/auth/otp/verifyfirst.- Send
Authorization: Bearer <access_token>on each request. Header/bearer auth bypasses CSRF — a browser can't attach anAuthorizationheader cross-site, so it is CSRF-immune. - When the access token expires,
POST /api/v1/auth/refreshwith{refresh_token}mints a fresh pair.
A programmatic caller has exactly the identity, org role, and project roles of its user — there is no distinct key role or key scope. To limit blast radius, create a dedicated user with a minimal role (e.g. an operator scoped to one project) for automation.
Full walkthrough, wire shapes, and cookie details: Programmatic access.
Sessions, JWT & CSRF
Browser logins use JWT in httpOnly cookies. There are three tokens in play:
- Access token — JWT signed HS256.
httpOnly,SameSite=Strict. Default TTL 15 min (jwt.access_token_ttl). - Refresh token — opaque 32-byte random, SHA-256-hashed in the DB.
httpOnly, scoped to/api/v1/auth/refresh. Default TTL 7 days. - CSRF token — random 32 bytes in a JS-readable cookie, required in
X-CSRF-Tokenon every mutation (double-submit). GETs are exempt.
Refresh flow: the frontend's HTTP client intercepts a 401, calls POST /auth/refresh, gets a new pair, retries the original request. Refresh tokens rotate — every successful refresh mints a new refresh and marks the old one used.
If an attacker steals your refresh token and races you, one of you refreshes first — that marks the token used. When the other tries, the server sees "already used" and revokes every refresh token in the user's family. A single legitimate refresh evicts the attacker.
CSRF compares header vs cookie on every mutation; mismatch → 403. Cross-origin requests can't read cookies, so only same-origin JS can produce matching pairs.
Admin disable invalidates every refresh token for the user immediately; the user's browser fails on its next request after the access token expires (≤ 15 min). For instant eviction, revoke at the load balancer. There is currently no per-session list / per-device revoke surface in the UI — disable-the-user is the wholesale action.
Tuning:
jwt:
access_token_ttl: 15m
refresh_token_ttl: 168h # 7 days
Don't push access TTL above an hour — it defeats the quick-revocation pattern. Long refresh TTL is fine because rotation + reuse-detection bound the damage; pin shorter (e.g. 24 h) if compliance requires it.
Audit trail
Every auth-relevant action is captured in the audit log: login (success / failure), logout, role changes, password resets (initiated and completed), OTP enrollments and resets, refresh-reuse detection, LDAP auto-provisions. Failed-login bursts trigger a notification rule if you've configured one (Notifications).
Troubleshooting
"Forbidden" on an action you expected to work
Check your role for that project (not just org). The page header shows Project role: <role>. The action may require admin, but you're operator.
"Account locked"
Too many failed logins. The backend applies a default lockout (10 failures in 15 minutes → 30-minute cool-down) — values are server-side defaults and not currently exposed in the UI. Wait out the window or restart the user's session via Settings → Users → menu.
LDAP service bind fails with "invalid credentials"
Service DN or password is wrong, or the account is disabled in the directory. Test bind in the form isolates the issue.
"Code invalid" right after enrollment
Clock skew. The authenticator device must be on NTP; so must the backend host. Most phones are fine; desktop authenticators sometimes drift.
Bearer token 401
An access token lives 15 minutes — if it's older than that, refresh it (POST /api/v1/auth/refresh) and retry. Otherwise check the header itself: Authorization: Bearer <access_token> (note the space), and watch for copy-paste whitespace or a stray newline on the token.
403 "CSRF validation failed" from the UI
Missing or stale X-CSRF-Token. The frontend HTTP client sets it automatically; if you're calling the API directly from a script, prefer a Bearer access token (Authorization header, no CSRF) over cookie auth.
"I was suddenly logged out everywhere"
Refresh-reuse detection fired. Either a real compromise (check your other devices and the audit log) or a browser replaying an old refresh after sleep. Log in again; the event is audit-logged.