Authentication and users
Added in 4.4.0. Before it, this gateway had no login of its own: the EmberNET dashboard’s reverse proxy and a NetworkPolicy were the whole perimeter, and the actor recorded against a write came from a header the proxy set, spoofable by anything else on the same segment that could reach the port.
That is no longer the default. It is still available, deliberately, for a
deployment that really is isolated behind the dashboard and wants to say so, see Running without a login, but a bare
helm install or a Podman Quadlet unit now comes up requiring one.
The short version
Section titled “The short version”- Local users, bcrypt passwords, server-side sessions. No external identity provider yet, see MCP.md for the separate bearer-token perimeter that protects the agent interface, which this does not replace.
- Three roles: admin, operator, viewer. Admin can do everything, including manage users. Operator can read everything and write tags, acknowledge alarms and trigger automations. Viewer can read everything and nothing else. A user can also be given an explicit permission list instead of a role, for anything narrower.
- A session is a cookie or a bearer token, whichever a client finds
easier. The browser UI uses the cookie; a script or another service uses
Authorization: Bearer <token>. - Sessions expire.
HOTLOOP_AUTH_SESSION_TTL, 12 hours by default. There is no “remember me”, a session that outlives a work shift is a session nobody is watching.
Getting the first account
Section titled “Getting the first account”The chart generates one on first install and shows it once, in
helm install’s NOTES output:
helm install hotloop ./charts/hotloop \ --namespace hotloop --create-namespaceRunning the binary directly, generate a hash and set two variables:
hotloop hash-password# Password: ********# Confirm password: ********# $2a$10$....................................................
export HOTLOOP_AUTH_ENABLED=trueexport HOTLOOP_ADMIN_USER=adminexport HOTLOOP_ADMIN_PASSWORD_HASH='$2a$10$....................................................'hash-password also takes -password directly, for a script that already has
one:
hotloop hash-password -password 'something-long'The admin account this creates is a bootstrap, not a reset switch. Once it
exists, changing HOTLOOP_ADMIN_PASSWORD_HASH and restarting does nothing, EnsureAdminUser only creates the account if that username does not already
exist, so a value here can never overwrite a password an operator has since
changed through the UI. This matters most on a Helm upgrade: re-applying the
same values.yaml must not silently revert someone’s password.
| Role | Can do |
|---|---|
| admin | Everything: *. Including creating, disabling and deleting users. |
| operator | Read every screen. Write tags (POST /api/write) and command entities (POST /api/services/...), set helpers (helpers.write, see HELPERS.md), run and stop scripts (scripts.run, see SCRIPTS.md), apply recipes (recipes.apply, see RECIPES.md), make a rule from a blueprint and upgrade one (blueprints.apply, see BLUEPRINTS.md), see the dashboards offered to the role (dashboards.read, see DASHBOARDS.md), rearrange the board, acknowledge/shelve/unshelve alarms, trigger automations. Cannot edit device, tag, alarm, automation, script, recipe, blueprint or dashboard definitions, and cannot manage users. |
| viewer | Read every screen. Nothing else. |
A user can be given an explicit permissions list instead of a role, which
replaces the role entirely rather than adding to it, a user is either “the
operator role” or “exactly these permissions,” never both, so there is one
place to look to know what somebody can do.
Permission strings are resource.action, tags.write, alarms.ack, or a
resource wildcard, tags.*, or the global wildcard *. The full list a
built-in role can grant lives in internal/auth/auth.go, next to the roles
themselves, so it cannot drift from what the code actually checks.
Managing users
Section titled “Managing users”Admin only. In the UI it is Settings, Users: every account with its role,
whether it is on, and when it last signed in, with Add a user, Edit (role, and
switching it off), New password and Delete. Nothing is drawn for anyone without
users.manage, or when authentication is off. An account whose permissions were
listed one by one shows as custom with how many, and Edit keeps the list unless
you pick a role, which replaces it. The same things over the REST API:
A password is 8 to 72 bytes: 72 is bcrypt’s own limit, and one longer is refused with that said. A password over 64 bytes works here and cannot sign in over OPC UA, which is the OPC UA library’s limit (see OPCUA-SERVER.md).
# Listcurl -H "Authorization: Bearer $TOKEN" https://gateway/api/users
# Createcurl -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"username":"j.smith","password":"something-long","role":"operator"}' \ https://gateway/api/users
# Disable (does not delete; also revokes every session that account holds)curl -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"role":"operator","disabled":true}' \ https://gateway/api/users/j.smith
# Reset a password an operator forgotcurl -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"password":"a-new-one"}' \ https://gateway/api/users/j.smith/passwordTwo refusals are worth knowing about before they surprise anyone:
- You cannot delete your own account while logged in as it. Switch to another admin account first.
- You cannot disable or delete the last enabled admin account. Promote a second account to admin, or leave one alone, before removing the first. Without this check, a single mistake in a scripted user sync is a lockout with no admin session left to undo it from.
Any logged-in user can change their own password without either restriction:
curl -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"currentPassword":"old","newPassword":"new"}' \ https://gateway/api/auth/passwordA password change revokes every session that account holds, including the one that made the request. The client is expected to log in again.
Device credentials are write-only
Section titled “Device credentials are write-only”Added in 4.15.1. Before it, GET /api/devices and GET /api/devices/{id} returned
a device’s config and address exactly as stored, so any logged-in user,
viewers included, could read the MQTT broker password, the HTTP and MTConnect
credentials, and anything else saved in a device. 4.15.0 hid the OPC UA password
only. From 4.15.1 a device’s credentials cannot be read back through the API by
any role, admin included. To change one you send a new value.
What counts as a credential
Section titled “What counts as a credential”| Protocol | Hidden in the configuration | Also hidden |
|---|---|---|
| MQTT | password |
user information and secret query values in the address (mqtt://user:pass@broker) |
| HTTP | password; every value in headers except a few plain ones (Accept, Accept-Encoding, Accept-Language, Cache-Control, Content-Type, User-Agent); body and writeBody when they mention a credential (password, token, apiKey, and the like); a secret-looking query value in writeUrl |
user information and secret query values in the address and in writeUrl |
| MTConnect | password; every value in headers, with the same exceptions |
user information and secret query values in the address |
| OPC UA | password |
user information in the address is hidden too, and the driver ignores it, since an OPC UA endpoint URL carries no credentials |
| Modbus, S7comm, EtherNet/IP | none of their options is a credential | user information in the address is hidden and ignored by the driver, for the same reason |
A path to a key file (keyFile) is not key material and is shown. Neither is a
flag such as allowInsecureCredentials.
The lists above are checked in code. A test reads every driver’s options struct
and fails when an option is on neither the list of credentials nor the list of
options known not to be one (plainConfigKeys), when a map of free-form entries
or a free-text field is not on the credential list, and when either list names
something no driver reads. So a new option cannot ship without somebody deciding
which it is.
As a net under the lists, any key at any depth of a device’s configuration is
hidden by name, in any protocol, when it contains password, passwd,
passphrase, pwd, secret, token, apikey, authorization, credential,
private, bearer, cookie, basicauth, signature, jwt, oauth,
sessionid, or connectionstring, or ends in key, or has as a whole word one of
pass, pw, pwd, passwd, auth, cred, creds, sig, signature, sas,
jwt, otp, pin, or psk. Words are split at punctuation and at
lowercase-to-uppercase boundaries, so db_pass, smtp_pw, apiAuth, and
sasToken count and bypass, compass, passive, authority, and pinned do
not. Case and punctuation are ignored, and so are spellings that encoding/json
would still read as the same key (a long s for s, the Kelvin sign for k). The
short-word rule is blunt on purpose: a key nobody has reviewed, such as authMode,
is hidden until it is added to plainConfigKeys.
A value that is hidden by name, and everything beneath a key that is, is replaced
whatever its type (a number is hidden as well as a string), except a boolean,
which cannot be a credential. A string that carries a credential in its text is
hidden whole wherever it is: a command line with --password hunter2, a
password=... pair or a connection string, Authorization: Bearer .... A device
with no credential set shows none, so “no password” and “a password I cannot see”
can be told apart. A configuration that cannot be read as JSON is shown as {}
rather than as it is.
A hidden value shows as [redacted]. In an address it replaces the password and
keeps the user name (http://svc:[redacted]@meter/api), or the whole user
information when there is no colon, and the value of each secret-looking
parameter of the query and of the fragment (?fmt=json&token=[redacted],
#access_token=[redacted]). Parameters are separated by & or ;, or by their
percent-encoded forms %26 and %3B, and each is judged alone, so
?fmt=json stays readable. A parameter named code counts as a secret in an
address, for the Azure Functions key in ?code=, and nowhere else. A query that
cannot be read cleanly (an invalid percent-escape, a control character) is hidden
whole instead of guessed at.
Saving a device
Section titled “Saving a device”POST /api/devices creates a device when the id is new and replaces it when the
id exists. There is no separate PUT. For every credential in the address and the
configuration:
| What the request sends | Result |
|---|---|
| A new device with a real value | The value is stored as given. |
A new device with [redacted] |
Refused with a 400. The placeholder is never stored as a credential. |
An existing device, [redacted] |
The stored value is kept. |
| An existing device, the key left out | The stored value is kept. A client that does not know about secrets cannot clear one by accident. |
| An existing device, a new string or number | It replaces the stored value. |
An existing device, an empty string or null |
It clears the stored value. This is the one way to remove a credential. |
[redacted], with nothing stored to keep |
Refused with a 400. |
The placeholder is never stored, wherever it appears in a configuration or an address: it is replaced by the stored value at the same place, or refused when there is none.
Two details. Inside headers (a map whose entries the person chose) the map is the
unit: an entry left out is removed, {} clears them all, and leaving the whole
headers key out keeps them. A key is matched without regard to case, as the
drivers match it. And everything that is not a credential is replaced as it always
was: a device saved with no config at all keeps its credentials and nothing else.
The web UI’s device editor shows a device’s configuration as the API returns it,
placeholders included, and saves what it shows. Editing a device without touching a
credential therefore keeps it. To change a secret in the UI, replace the
[redacted] in the configuration (or in the address) with the new value. Nothing
is prefilled with the old one, so a person changing a device must know the
credential to change it.
Moving a device does not carry its secrets along
Section titled “Moving a device does not carry its secrets along”Kept credentials are sent to whatever the device points at. If they were kept when a
device was moved, anyone allowed to save a device could read a stored credential by
pointing it at a listener of their own and letting the gateway present it. So when a
save changes the host or port in the address, or in writeUrl, and would keep a
stored credential (by placeholder or by leaving it out), it is refused with a 400
that names the credentials. Enter them again, or send an empty string to clear
them. Changing the path, the scheme, or anything else about the device does not
count as a move, and a device with no stored credential can be moved freely.
Where a credential does not appear
Section titled “Where a credential does not appear”For a device of every protocol, with a credential in every place it can be, the
tests search for it in: every device endpoint (list, get, create and update
responses) for a viewer, an operator and an admin; every other read endpoint;
/api/diagnostics and /api/status; /api/sites and the outbound MCP servers it
lists; the live event stream; the system log and the write audit; error responses;
every MCP tool and resource; and the process log at debug level, from the runtime
and from the drivers. A driver’s error text and the runtime’s events and logs are
scrubbed of a device’s credentials, by value and by shape, because an error can
repeat an address or a server’s reply. A credential of one or two characters is
replaced only where it stands as a word of its own, since replacing it everywhere
would ruin the message.
Where credentials are still in the clear
Section titled “Where credentials are still in the clear”Write-only is about the API. The gateway needs the real values to connect, and these places hold them as they are stored:
- The database. Device credentials are in the
configandaddresscolumns. WithHOTLOOP_SECRET_KEY_FILEset, the keyed ones inconfig(everypassword, header values) are encrypted with a key kept outside the database; anything written into an address or a URL is not. See BACKUP.md. - Backups.
devices.copyin a backup holds them, encrypted or not exactly as the database does. See BACKUP.md. - Edge relay device files. The relay reads a JSON file that carries the credentials of its devices. See EDGE-RELAY.md.
- Fleet config push. A device file pushed to a relay is published to an MQTT topic, retained, as it is. See FLEET.md.
Protect the database, the backup volume, the relay’s device file and the broker accordingly. Redacting the API does not change any of them.
If untrusted users could reach the API
Section titled “If untrusted users could reach the API”Before 4.15.1, any logged-in user could read stored device credentials through the API. An operator whose gateway was reachable by users who should not have had them (a viewer account handed to a contractor, say) should rotate the credentials of the devices it holds: the MQTT broker, the HTTP and MTConnect endpoints, and the OPC UA accounts. There has been no public release since 4.3.1, so the exposure is limited to deployments of unreleased builds.
What the audit trail records
Section titled “What the audit trail records”Every write already went through write_audit with an actor column, see
the write gate in the main README. That column now comes from the session,
not a header:
> write_tag tag=press-01/Zone1_Temp value=99 reason="testing"records j.smith, the username on the session that made the call, not
whatever X-Forwarded-User happened to say. Logins, logouts, password
changes and every user-management action are logged to the system log
(GET /api/events, category auth) for the same reason the write audit
exists: a change six months from now should be explainable, not just visible.
Running without a login
Section titled “Running without a login”Set HOTLOOP_AUTH_INSECURE=true (or the chart’s auth.insecure: true) to run
exactly as every version before 4.4.0 did: no session required, the actor
recorded against a write comes from X-EmberNET-User or similar proxy
headers, and falls back to "operator" if none is set.
This is a real, defensible choice for a deployment where the EmberNET
dashboard’s own NetworkPolicy is the actual perimeter and nothing else can
reach the pod. It is no longer the default because that condition is easy to
believe is true and hard to verify from inside the pod itself, the whole
point of 4.4.0 is that a mistake in a NetworkPolicy, or a deployment that
turns out not to be behind the dashboard after all, should not also mean
actor(r) returns something an attacker chose.
config.Validate refuses to start with auth neither enabled nor
acknowledged this way, specifically so “no auth” is always a decision someone
made on purpose, written down in the deployment’s own configuration, rather
than a default nobody chose.
What this does not cover yet
Section titled “What this does not cover yet”- No SSO / OpenID Connect. Local accounts only. An identity-provider integration is a reasonable next step and is not built.
- No per-tenant or per-site roles. A user’s permissions are the same
everywhere this gateway runs, which is fine for one plant and would need
more thought for the multi-site rollup in
Sites. - No account lockout after repeated failed logins. The timing-safe
comparison in
handleLogin(seedummyPasswordHashininternal/api/auth.go) stops username enumeration by response time; it does not rate-limit guessing. A NetworkPolicy or an upstream proxy is still worth having in front of the login endpoint.