System Users
System Users
Admin path: System > System Users (view_system_users.cfm,
inc/system_user_actions.cfm, inc/ldap_add_user.cfm,
inc/ldap_add_user_remoteauth.cfm, inc/ldap_add_user_groups.cfm,
inc/ldap_modify_user.cfm, inc/ldap_modify_user_password.cfm,
inc/ldap_change_user_access_control.cfm,
inc/ldap_delete_user.cfm, inc/delete_system_user.cfm,
inc/delete_system_user_devices.cfm, inc/generate_ldap_password.cfm,
inc/check_hibp.cfm).
This page manages admin console operators — the accounts that can
sign in at /admin/. Mailbox users (Email Server) and relay recipients
(Email Relay) are not managed here even though they share the same
underlying LDAP tree; they have their own admin pages.
Each row written by this page lands in two stores: the system_users
table (Hermes DB — UI metadata, auth-type flag, applied/ldap_synced
status), and an LDAP entry under ou=users,dc=hermes,dc=local whose
group memberships in ou=groups give the user actual access. Authelia
binds against LDAP for every console login; the DB row exists so the
admin UI has something to display and edit.
What this page creates — and what it doesn't
| Creates | Doesn't create |
|---|---|
Console admin accounts (cn=admins group membership) |
Mailbox accounts (those go through Email Server > Mailboxes, populate mailboxes + cn=mailboxes) |
| LDAP entry + DB row in lockstep | Relay-recipient accounts (those go through Email Relay > Recipients, populate recipients + cn=relays) |
| Local-auth (password lives in Hermes LDAP) or RemoteAuth (password lives in upstream AD/LDAP) admins | Authelia-side rows (Authelia is stateless against LDAP — no per-user provisioning needed) |
cn=one_factor or cn=two_factor group membership at create time |
The MFA enrolment itself — the user still has to enrol TOTP/WebAuthn/Duo from the user portal's Account Settings page once they sign in |
Operational consequence. Every account this page creates is an admin. There is no "create a reader-only admin" or "create an auditor" path today. Granular role assignment is a planned extension; the current model is binary — either you're an admin (full console access) or you're not. The
access_controlcolumn gates one-factor vs. two-factor at the login gate, not at the privilege level.
How LDAP membership is structured
System users live under the same OU as every other identity in Hermes,
and the user's role is determined by which groups contain their DN
in the member attribute (see Credential Model for the full architecture).
dc=hermes,dc=local
├── ou=groups
│ ├── cn=admins <-- every System User is added here
│ ├── cn=mailboxes <-- mailbox users (not this page)
│ ├── cn=relays <-- relay recipients (not this page)
│ ├── cn=one_factor <-- access_control = one_factor
│ └── cn=two_factor <-- access_control = two_factor
└── ou=users
├── cn=admin <-- the install-time built-in admin
├── cn=jsmith <-- example local-auth System User
└── cn=corp_user <-- example remote-auth stub entry
inc/ldap_add_user_groups.cfm adds the new System User's DN to both
cn=admins and the chosen access-control group in a single LDIF
operation. The LDIF template /opt/hermes/templates/ldap_addusergroup.ldif
contains two changetype: modify blocks that both reference the same
THE_USERNAME placeholder.
Database schema — system_users
| Column | Purpose |
|---|---|
id |
PK |
username |
LDAP cn / uid. Immutable after create (the edit modal renders this field read-only). |
email |
mail LDAP attribute; also where forgotten-password notifications would go (but admin self-service reset is disabled for security — see Password Resets below) |
first_name, last_name |
givenName, sn |
password |
Argon2id hash with the {ARGON2} prefix that OpenLDAP's argon2 overlay expects. Empty string for RemoteAuth users (their password is upstream). |
access_control |
one_factor or two_factor — drives Authelia's access-control policy at login |
auth_type |
local or remote — drives the entire create/edit flow |
remoteauth_domain |
For auth_type = 'remote', the domain_name key into remoteauth_mappings. NULL for local-auth. |
system |
1 = install-time built-in admin (delete-protected). 2 = admin-created. |
applied |
1 = current state synced to LDAP. 2 = pending sync (transient during a save). |
ldap_synced |
1 = LDAP entry exists. 0 = DB row exists but LDAP entry doesn't (a half-sync state the edit handler explicitly detects and tries to repair). |
pushover_user_key, pushover_enabled |
Optional Pushover notifications for admin alerts |
Local-auth user create flow
Admin clicks Create System User
│
▼
form validation: username regex, email format,
first/last name regex, password length 8-64
│
▼ (optional)
HIBP check: SHA-1 prefix sent to api.pwnedpasswords.com
│ reject if hash suffix matches a known breach
▼
generate_ldap_password.cfm
│ docker run --rm authelia/authelia:VERSION \
│ authelia crypto hash generate argon2 \
│ --password <plaintext>
│ returns: {ARGON2}$argon2id$v=19$m=...$...$...
▼
INSERT INTO system_users (..., password='{ARGON2}...')
│
▼
ldap_add_user.cfm -- builds adduser LDIF from template,
│ docker exec hermes_ldap ldapadd
│ writes entry to ou=users with userPassword
▼
ldap_add_user_groups.cfm -- adds DN to cn=admins
│ + cn=<one_factor|two_factor>
▼
UPDATE system_users SET ldap_synced = 1
▼
session.m = 20 ("System User was created successfully")
The Authelia hash generator runs as a one-shot docker run --rm
against the same Authelia image the platform already runs — zero host
dependency, format guaranteed to match what Authelia validates at
login. The hashing happens in inc/generate_ldap_password.cfm.
RemoteAuth user create flow
When the Authentication Type dropdown is set to Remote, the form
shape changes: the password fields disappear and a RemoteAuth
Domain dropdown becomes required (populated from
remoteauth_mappings where enabled = 1). This option only appears
when (a) the install has a Pro license, (b) remoteauth_settings.enabled = 1,
and (c) at least one enabled mapping exists.
INSERT INTO system_users (..., password='', auth_type='remote',
remoteauth_domain='<key>')
│
▼
ldap_add_user_remoteauth.cfm -- writes a stub entry with NO password,
│ with seeAlso pointing at the upstream
│ DN (expanded from the mapping's
│ remote_dn_pattern) and associatedDomain
│ set to the mapping key
▼
ldap_add_user_groups.cfm -- adds DN to cn=admins
+ cn=<one_factor|two_factor>
At login, Authelia binds locally against the stub. Hermes's
slapo-remoteauth overlay sees the associatedDomain, finds the
matching upstream URI, and rebinds as the seeAlso DN. The local entry
has no userPassword to validate against — the upstream bind is the
only decision. See LDAP RemoteAuth for the
overlay mechanics.
Username uniqueness is global. The
system_users.usernamecolumn is checked for collision across both auth types. If your upstream AD already has a user nameddedwardsand Hermes already has a local-auth admin nameddedwards, the second account cannot be created with the same username. The form's error message suggestsusername@domainorusername.domainas a workaround.
Edit flow — what can and cannot change
Two fields are immutable after create and rendered read-only in the edit modal:
| Field | Why immutable |
|---|---|
| Username | It's the LDAP RDN (cn=). Renaming would require a modrdn plus updating every group's member attribute that references the old DN. The "delete and recreate" path is simpler and safer. |
| Authentication Type | Switching local-to-remote or remote-to-local would change the LDAP entry's objectClass set (loses or gains a password attribute) and break the seeAlso/associatedDomain overlay reference. Recreate the user instead. |
Everything else is editable: email, first/last name, access-control policy (one/two factor), and — for local-auth users only — the password (via the Set User Password = YES toggle which reveals the password fields). The password edit re-runs the same HIBP check and Argon2 hash flow as create.
The access-control change is non-trivial: switching one_factor to
two_factor (or vice versa) means removing the DN from the old group
and adding it to the new one. inc/ldap_change_user_access_control.cfm
handles both ops in sequence.
Half-synced repair
If a previous save crashed between the DB INSERT and the LDAP write
(ldap_synced = 0, no LDAP entry exists), the edit handler refuses
to save the row in a "NO password change" mode — there's no password
to push into LDAP. Alert code 16 surfaces the explicit instruction:
"set Set User Password to YES and enter a new password" so the
sync can complete on the next save attempt. The user's stored
password is not re-pushed because the DB column holds an Argon2
hash, not a plaintext.
Built-in admin protection — the system column
The install script seeds a single built-in admin row (the username
chosen at install time) with system = 1. The page's UI rules:
Both gates are also enforced server-side in system_user_actions.cfm's
deleteuser branch — the SQL lookup explicitly filters
system <> '1' AND id <> <session.userid> so a crafted POST cannot
bypass the hidden button.
Delete flow
Soft-delete is not the model — the row is physically removed.
1. DB lookup: refuse if system='1' or id=session.userid
2. ldap_delete_user.cfm:
docker exec hermes_ldap ldapdelete \
cn=<username>,ou=users,dc=hermes,dc=local
(this auto-removes the DN from any group's member attribute via
the OpenLDAP referential-integrity overlay)
3. delete_system_user.cfm:
DELETE FROM system_users WHERE id = <id>
4. delete_system_user_devices.cfm:
docker exec hermes_authelia authelia storage user totp delete \
<username> --config /config/configuration.yml
docker exec hermes_authelia authelia storage user webauthn delete \
<username> --config /config/configuration.yml --all
5. session.m = 1 ("System User was deleted successfully")
Duo Push devices do NOT delete here. Duo enrolment lives on Duo's cloud servers, not Authelia's database. If the deleted user was Duo-enrolled, the admin must also remove them from the Duo Admin Panel — both the delete and the 2FA-only modals say so explicitly. See Authentication Settings § Duo Security.
Delete 2FA Devices — without deleting the user
The yellow key button on each row opens a dedicated Delete 2FA Devices modal that runs only step 4 of the delete flow above. Use this when:
- A user reports they've lost their phone / hardware key
- A user is stuck in a 2FA loop after a session expiry
- A user needs to re-enrol with a new TOTP app
After running this, the user is back to a one-factor login state for the next sign-in, then can re-enrol from their Account Settings page. The page waits 5 seconds before redirecting to give Authelia time to flush the credential cache before the success banner appears.
Note on Authelia config path. The two
authelia storagecommands reference--config /config/configuration.yml. That is the in-container path, which differs from where you'd expect to find the file from the host's perspective. Authelia's working config inside the container is/config/configuration.yml, NOT/etc/authelia/. See Authentication Settings § Storage backend — MySQL, not SQLite for why the MariaDBautheliadatabase is what actually gets cleaned when these commands run.
have-i-been-pwned (HIBP) check
The Check Password Against haveibeenpwned.com toggle (YES/NO,
default YES) sends only the first 5 hex chars of the password's
SHA-1 to api.pwnedpasswords.com/range/<prefix> (k-anonymity:
the full hash is never transmitted) and rejects the password if
the remaining 35 hex chars appear in the returned breach list.
If api.pwnedpasswords.com is unreachable (no outbound 443, DNS
broken, etc.) the create fails with alert 100 — the admin must
either restore outbound connectivity or disable the check explicitly
on the form. Silently skipping a security check on network failure
would be the wrong default.
What this page does NOT do
| Concern | Lives on |
|---|---|
| Mailbox creation | Email Server > Mailboxes — separate table, separate LDAP group |
| Relay-recipient creation | Email Relay > Recipients — separate table, separate LDAP group |
| Per-user MFA enforcement (admin-policy flag) | The mailbox / relay-recipient detail pages set enforce_mfa for those user classes. System Users use access_control instead; if you set it to two_factor, Authelia challenges every login. There is no separate "encourage but don't require" middle state for admins — see Authentication Settings § MFA enforcement is decoupled from the cn=two_factor LDAP group. |
| Password reset queue (admin processes user-initiated requests) | Password Resets |
| Authelia session length, brute-force throttle, Duo / OIDC | Authentication Settings |
| Upstream AD/LDAP mapping for RemoteAuth admins | LDAP RemoteAuth — must exist + be enabled before this page's Remote dropdown appears |
| Pushover token (per-admin alert notifications) | Set on the per-admin notification configuration page; the pushover_user_key column on system_users is populated there, not here |
Failure semantics
| What breaks | What happens |
|---|---|
hermes_ldap container down |
Create + Edit fail at the LDAP step. The DB INSERT has already run, so the row exists with ldap_synced = 0. Recovery: restart LDAP, edit the user with Set User Password = YES to retry the sync (alert 16 will prompt for this on first reload). |
hermes_authelia container down |
Create + Edit + Delete still succeed at the DB + LDAP level; the user can't actually log in until Authelia is back. Delete 2FA Devices fails silently (caught and swallowed in the cftry block) — the next attempt after Authelia recovers will succeed. |
| HIBP API unreachable with HIBP check ON | Create + password-change Edit refuse to save (alert 100). The admin must either fix outbound connectivity or set HIBP to NO. |
| RemoteAuth domain dropdown empty / RemoteAuth disabled | The Remote option doesn't appear in the dropdown at all. To restore: enable a mapping on LDAP RemoteAuth and click Apply Settings. |
| Username collision | Alert 13 with the suggested username@domain or username.domain workaround. |
Files and containers touched
| Path | Owner | Role |
|---|---|---|
config/hermes/var/www/html/admin/2/view_system_users.cfm |
hermes_commandbox |
Page (table + 4 modals) |
config/hermes/var/www/html/admin/2/inc/system_user_actions.cfm |
hermes_commandbox |
Action router (create / edit / delete / deletedevices) |
config/hermes/var/www/html/admin/2/inc/generate_ldap_password.cfm |
hermes_commandbox |
docker run --rm authelia/authelia ... crypto hash generate argon2 |
config/hermes/var/www/html/admin/2/inc/ldap_add_user.cfm |
hermes_commandbox |
LDIF render + ldapadd for local-auth entries |
config/hermes/var/www/html/admin/2/inc/ldap_add_user_remoteauth.cfm |
hermes_commandbox |
Stub-entry LDIF render + ldapadd for remote-auth entries |
config/hermes/var/www/html/admin/2/inc/ldap_add_user_groups.cfm |
hermes_commandbox |
Adds DN to cn=admins + access-control group |
config/hermes/var/www/html/admin/2/inc/ldap_change_user_access_control.cfm |
hermes_commandbox |
Moves DN between cn=one_factor and cn=two_factor |
config/hermes/var/www/html/admin/2/inc/ldap_delete_user.cfm |
hermes_commandbox |
ldapdelete of the user entry |
config/hermes/var/www/html/admin/2/inc/delete_system_user_devices.cfm |
hermes_commandbox |
authelia storage user totp delete + webauthn delete --all |
config/hermes/var/www/html/admin/2/inc/check_hibp.cfm |
hermes_commandbox |
HTTPS GET to api.pwnedpasswords.com |
/opt/hermes/templates/ldap_adduser.ldif |
hermes_commandbox |
Add-user LDIF (placeholder-substituted) |
/opt/hermes/templates/ldap_adduser_remoteauth.ldif |
hermes_commandbox |
Stub-user LDIF |
/opt/hermes/templates/ldap_addusergroup.ldif |
hermes_commandbox |
Two-block LDIF for cn=admins + access-control group add |
system_users table |
hermes_db_server (hermes DB) |
Admin metadata + LDAP sync state |
cn=admins,ou=groups,dc=hermes,dc=local |
hermes_ldap |
Source of truth for who can sign in at /admin/ |
Related documentation
- Credential Model — full four-credential architecture; this page's accounts use only the web-login credential
- LDAP RemoteAuth — required prerequisite for creating remote-auth System Users; covers mappings, DN patterns, TLS settings
- Authentication Settings — Authelia's session lifetime, login regulation, MFA capability vs. enforcement model
- Password Resets — the admin queue for user-initiated reset requests; the page's note on why admin self-service reset is blocked
- Console Settings —
/admin/hostname, cert, and the IP allowlist that layers above this page's access control