Skip to content
GitHub

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.

  • 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.

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-namespace

Running the binary directly, generate a hash and set two variables:

Terminal window
hotloop hash-password
# Password: ********
# Confirm password: ********
# $2a$10$....................................................
export HOTLOOP_AUTH_ENABLED=true
export HOTLOOP_ADMIN_USER=admin
export HOTLOOP_ADMIN_PASSWORD_HASH='$2a$10$....................................................'

hash-password also takes -password directly, for a script that already has one:

Terminal window
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.

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).

Terminal window
# List
curl -H "Authorization: Bearer $TOKEN" https://gateway/api/users
# Create
curl -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 forgot
curl -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"password":"a-new-one"}' \
https://gateway/api/users/j.smith/password

Two 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:

Terminal window
curl -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"currentPassword":"old","newPassword":"new"}' \
https://gateway/api/auth/password

A password change revokes every session that account holds, including the one that made the request. The client is expected to log in again.

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.

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.

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.

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.

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 config and address columns. With HOTLOOP_SECRET_KEY_FILE set, the keyed ones in config (every password, 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.copy in 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.

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.

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.

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.

  • 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 (see dummyPasswordHash in internal/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.