Skip to main content

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.
RoleDescription
OwnerFull 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.
AdminDeactivates 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.
OperatorDay-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.
ViewerRead-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.

ActionMinimum roleScope
Issue / renew / reissue certificateoperatorproject
Import / re-import certificate (bring-your-own)adminproject
Revoke certificateadminproject
Download private keyoperator — there is no separate key-export permissionproject
Delete certificateadminproject
Distribute: link a distribution to a cert · execute · dry-run · roll back · unlinkoperator (approval-gateable — off by default)project
Configure issuance: ACME accounts · MSCA connections · DNS credentials · zonesadminproject
Configure distribution — targets · target-groups · PathSets/ActionSets · project-variables · module configsoperator (approval-gated by default)project
Configure distribution — module credentialsadmin (they hold secrets)project
Configure distribution — secret storesadmin (they hold the connection to your secret manager)project
Health-check a target / module / credentialadminproject
Certificate policy / approval policyadminproject
Notification channels & rulesadminproject
Create users · assign roles · force password reset · LDAP · OTP policyownerorg
Create / delete projectsownerorg
Rename / update a projectadminproject
Deactivate / delete a user · licence · syslog · general settingsadminorg

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 infrastructuremodule 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 itlink 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).
ActionMinimum roleApproval gate (default)
Create / edit / delete a targetoperatorconfig-change (ON)
Create / edit / delete a target-group (+ add/remove members)operatorconfig-change (ON)
Create / edit / delete a PathSet / ActionSetoperatorconfig-change (ON)
Create / edit / delete a project-variableoperatorconfig-change (ON)
Create / edit / delete a module configoperatorconfig-change (ON)
Create / edit / delete a module credential (holds secrets)admin
Health-check a target / module / credentialadmin
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 credentialadmin— (that pairing is what the config-change approval reviews)
Link a distribution to a certificateoperatordistribution (OFF)
Update / unlink a distributionoperatordistribution (OFF)
Execute / dry-run a distributionoperatordistribution (OFF)
Roll back a distribution (manual, version picker)operatordistribution (OFF)
Approve a changed SSH host key on a targetoperator— (never gated; audited with both fingerprints)
View targets / modules / credentials / distributionsviewer

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:

FieldPurpose
emailUnique per organisation. Used for login, notifications, audit correlation.
display_nameShown in UI lists and audit logs.
org_roleowner / admin / operator / viewer.
project_rolesMap of project_id → role; per-project overrides.
auth_sourcelocal (password + optional TOTP) or ldap.
totp_enabledWhether the user has enrolled in TOTP. The OTP policy is org-wide — there is no per-user override.
is_activeBoolean. 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.

Recovery codes are shown once

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:

  1. POST /api/v1/auth/login with {username, password} → an access_token (Bearer, 15 min TTL) plus a refresh_token (7 days). MFA users complete POST /api/v1/auth/otp/verify first.
  2. Send Authorization: Bearer <access_token> on each request. Header/bearer auth bypasses CSRF — a browser can't attach an Authorization header cross-site, so it is CSRF-immune.
  3. When the access token expires, POST /api/v1/auth/refresh with {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-submit X-CSRF-Token header, 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-Token on 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.

Reuse detection is the firewall

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.

See also