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. The only role that can create users, assign or change roles, force a password reset, configure LDAP and the OTP policy, create or delete projects, transfer ownership, and delete the organisation. |
| Admin | Deactivates or deletes non-owner users, manages the licence, syslog and general settings, and holds admin rights inside projects. Cannot create users, grant roles, configure LDAP, or create/delete projects — those are owner-only. |
| Operator | Day-to-day cert operations — issue, renew, reissue, and distribute (link a distribution to a certificate, execute, dry-run, roll back). Can also edit the distribution-side config surface — Targets, TargetGroups, PathSets/ActionSets, project-variables, module configs — but by default only through the approval gate (see below). Cannot build issuance infrastructure (ACME accounts, DNS credentials) or module credentials (they hold secrets — admin-only), and cannot revoke or delete certificates, or manage users/org settings — those are admin. |
| 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 |
| Import / re-import certificate (bring-your-own) | admin | project |
| Revoke certificate | admin | project |
| Download private key | operator — there is no separate key-export permission | project |
| Delete certificate | admin | project |
| Distribute: link a distribution to a cert · execute · dry-run · roll back · unlink | operator (approval-gateable — off by default) | project |
| Configure issuance: ACME accounts · MSCA connections · DNS credentials · zones | admin | project |
| Configure distribution — targets · target-groups · PathSets/ActionSets · project-variables · module configs | operator (approval-gated by default) | project |
| Configure distribution — module credentials | admin (they hold secrets) | project |
| Configure distribution — secret stores | admin (they hold the connection to your secret manager) | project |
| Health-check a target / module / credential | admin | project |
| Certificate policy / approval policy | admin | project |
| Notification channels & rules | admin | project |
| Create users · assign roles · force password reset · LDAP · OTP policy | owner | org |
| Create / delete projects | owner | org |
| Rename / update a project | admin | project |
| Deactivate / delete a user · licence · syslog · general settings | admin | org |
Role assignment is owner-only. There is no graded "assign at or below your own level" ladder: only an owner can grant or change any role. An admin can deactivate or delete a non-owner user but cannot grant a role to anyone, including themselves.
Distribution & targets — who builds vs who wires
Operators can now edit most of the distribution-side config surface and run distributions; the approval gate — not the role — is the control point for the riskier writes. Two org-wide toggles decide whether an operator's action executes directly or must be approved first (see Approval workflow → What can be gated). Admins and owners bypass approval entirely.
- Operators EDIT the deploy config — create/update/delete targets, target-groups (+ members), library PathSets/ActionSets, project-variables, and module configs. Gated by the config-change approval toggle, which is ON by default (these writes were admin-only before, so the write access is new and approval-gated).
- Admins-only infrastructure — module credentials remain admin (they hold secrets), as do issuance objects (ACME accounts, DNS credentials, zones), and target/module/credential health checks (an operational probe against a live device, not a config mutation).
- Operators WIRE and RUN it — link a distribution to a certificate, execute / dry-run it, and roll back to a previous version (or unlink). Gated by the distribution approval toggle, which is OFF by default (opt-in).
| Action | Minimum role | Approval gate (default) |
|---|---|---|
| Create / edit / delete a target | operator | config-change (ON) |
| Create / edit / delete a target-group (+ add/remove members) | operator | config-change (ON) |
| Create / edit / delete a PathSet / ActionSet | operator | config-change (ON) |
| Create / edit / delete a project-variable | operator | config-change (ON) |
| Create / edit / delete a module config | operator | config-change (ON) |
| Create / edit / delete a module credential (holds secrets) | admin | — |
| Health-check a target / module / credential | admin | — |
| Browse a saved target's live resources (pickers, IIS placement preview) | operator | — (audited) |
| Browse with an unsaved target's connection details, or a saved target paired with a different credential | admin | — (that pairing is what the config-change approval reviews) |
| Link a distribution to a certificate | operator | distribution (OFF) |
| Update / unlink a distribution | operator | distribution (OFF) |
| Execute / dry-run a distribution | operator | distribution (OFF) |
| Roll back a distribution (manual, version picker) | operator | distribution (OFF) |
| Approve a changed SSH host key on a target | operator | — (never gated; audited with both fingerprints) |
| View targets / modules / credentials / distributions | viewer | — |
Reading is open to any project member (viewer+). Admins and owners bypass every approval gate — their edits and deploys auto-execute and are recorded in the audit trail. Shell-mode distributions still require Admin to execute (that check runs before the gate). The scheduler's automatic post-renewal distribution sweep is not gated — only the manual, operator-initiated distribution action is.
Auto-rollback is not a user action — it is a per-distribution setting an operator enables; the system triggers it on a failed/partial run. It fires only for the stateful modules; the push modules (Webhook, SMTP) are manual-rollback only.
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. |
totp_enabled | Whether the user has enrolled in TOTP. The OTP policy is org-wide — there is no per-user override. |
is_active | Boolean. There is no three-state status and no "pending/invited" state. |
Create: Settings → Users → New user (owner only). Username + email + an initial password + org role — all four required. There are no invite emails and no invite tokens: you set the password and communicate it out of band. Project roles are granted afterwards from Users → Roles.
Disable vs delete: disable blocks new logins and is reversible, but it does not revoke existing refresh tokens, and the refresh endpoint does not re-check the flag — an already-signed-in user keeps refreshing indefinitely. To actually cut off a live session today, delete the user or force a password reset; both revoke every refresh token. 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: there is no forgot-password flow on the login page. A signed-in user changes their own password from their profile (supplying the current one); an owner can force a reset from Settings → Users. A forced reset revokes every refresh token for that user.
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 (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):
- 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=Lax— the double-submitX-CSRF-Tokenheader, not the cookie attribute, is what blocks cross-site mutations. 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. 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. One caveat: a replay landing within 5 seconds of the first is treated as a concurrent-tab race and is rejected without revoking the family, so detection covers replays from roughly five seconds onward.
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 does not evict a live session: it prevents new logins only. A held refresh token keeps minting access tokens, so use delete or a forced password reset to revoke the token family, or block the user 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, admin-forced password resets and self-service password changes, OTP enrolment, disabling and recovery-code regeneration, and refresh-token reuse detection. An account auto-provisioned from LDAP is not recorded as a creation event — it appears only as a successful login. 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 progressive lockout: 10 consecutive failures → 1 minute, 20 → 5 minutes, 30 → 15 minutes, 40 → 1 hour — 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 missing CSRF cookie / missing X-CSRF-Token header / CSRF token mismatch
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.