Pre-1.0 hoardDB is pre-1.0. Expect breaking changes.

User management

hoardDB authenticates a user per connection and authorizes every operation against the roles that user holds. Users live in users.json beside the data directory, survive restarts, and replicate to every node in a cluster.

This page is the operator’s view. It assumes a running server and a CLI session; getting-started.md covers both.

Where user changes run

Every command on this page is a mutation, and mutations are served by the cluster master — the node that owns the reserved _system.auth key. On a single node that is the node you are talking to. In a cluster, ask which node it is:

[h]oardDB> master
cluster master for user changes: node-a at 192.0.2.4:7433

user list also prints the master and the current config version. A mutation sent to any other node is refused, and the refusal names the master and the address to run the command against:

[h]oardDB> create user probe roles [reader@*]
Error: user changes are served by the cluster master
       this node: node-b (not the master)
       master:    node-a at 192.0.2.4:7433
       run the same command against 192.0.2.4:7433, or use 'master' to find it

The address in the refusal, in master and in the user list header is the one a client connects to: the master’s driver listener (tcp.listen), not the cluster address nodes use for each other. Each node tells its peers where clients reach it. Three things decide what you see:

  • tcp.listen defaults to loopback (127.0.0.1:7433), which only the machine itself can reach. On a cluster spread over several machines, set tcp.listen on each node to 0.0.0.0:7433 or to an address your clients can reach. A node that does not is not offered as an address: the refusal says that node accepts clients on loopback only and what to set, instead of printing an address that would connect you back to the node you are on. A one-machine cluster on loopback is shown on 127.0.0.1, which is correct there.
  • The legacy cluster has no advertise setting. (tcp.advertise applies to the entry-ring cluster only, once cluster.enabled is on.) A node that listens on every interface (0.0.0.0:7433 or [::]:7433) is shown on the host of its server.listen, with its driver port. Behind NAT, or on a host with several addresses, that host may not be the one your clients use; the printed address is then wrong for you and you substitute the right host with the same port.
  • A node’s address may not have arrived. Right after a node starts, or when the master runs an older build, the node you asked has not heard the master’s address, and the refusal and master say so. Wait a few seconds and ask again. If it never arrives, tcp.listen may be unset or malformed on the master: check it there. This node’s own tcp.listen, if unset or malformed, is reported as such.

The user store on disk

users.json sits in the data directory, mode 0600, and every node in a cluster holds a full copy.

The file is encrypted with AES-256-GCM, so it cannot be edited to inject a user or revive a disabled one. Which key seals it depends on whether the node runs with encryption at rest (see “Encryption at rest” in docs/user/configuration.md):

  • Encryption at rest on (encryption.mode on or require): the file is sealed under the node master key, which the keystore protects and which is not in the data directory. A copy of the data directory does not hand out password hashes.
  • Encryption at rest off (the default): the file is sealed under the node’s auth signing key, which is in the data directory: auth.token.key on a standalone node, or derived from cluster.token on a clustered one. A copy of the whole data directory can read it.

What the two modes mean, in this version (the full account is in Configuration):

  • Encryption at rest is per node and off by default. It is decided when a node’s data directory is first started and cannot be switched afterwards.
  • on and require behave the same.
  • An encrypted node seals blob payload files too: content of 64 KiB and over is written to encrypted files, and smaller content is stored inside the node’s encrypted store.
  • A dump is plaintext whatever the mode.
  • There is no key rotation command yet. The node master can be exported and imported with hoarddb-server -export-key <file> and -import-key <file> while the server is stopped.
  • If the wrapped node master (keys/node-master.key.wrapped) is missing from a data directory that holds sealed data, the server refuses to start rather than create a new one. Restore the file from a backup, or import the exported node master.

That makes the key part of the store’s durability, not only its security:

  • Keep the key with your backups. With encryption at rest on, that is the keystore’s KEK, kept apart from the data directory; a node started without it refuses to start. With it off, it is the key file or cluster token above, and a node that has lost it refuses to start with:

    users.json is encrypted with key <kid>, but that key is not in the keyring
    — restore the original auth.token.key or lose the users
    
  • --reset-password is not a recovery path for that case. It reads the same file and fails the same way; it can only rewrite a store it can already read.

  • Decommissioning a node means deleting its users.json as well as wiping its cluster token — the copy is readable by that node and by anyone holding its key material.

A users.json written before this encryption existed is plain JSON and still loads as-is: the format is detected from the file itself, so there is no migration step and no flag to set.

Create a user

Passwords are prompted, never passed as a flag. On a terminal the CLI asks twice and does not echo:

[h]oardDB> create user alice roles [writer@app, reader@logs]
New password:
Repeat new password:
user "alice" created (config version 2)

A username is at most 255 bytes and holds no control characters (the escape byte, a newline, DEL): a terminal acts on them when it prints a name. create user refuses such a name and says so. Accounts made before this rule keep working, and can still be altered and dropped.

In a script the statements arrive on stdin, so the password comes from one of three places:

# 1. The two lines that follow the statement in the script (a piped session
#    cannot prompt, so it reads the next two lines of the same input).
printf 'create user alice roles [writer@app]\nalice-s3cret-pw\nalice-s3cret-pw\nexit\n' | hoardDB

# 2. One line after the statement, with --password-stdin.
printf 'create user alice roles [writer@app]\nalice-s3cret-pw\nexit\n' | hoardDB --password-stdin

# 3. A 0600 file, for a password that must not appear in the script.
echo 'create user alice roles [writer@app]' | hoardDB --password-file /run/secrets/alice.pw

A script that leaves out the password lines has its next statements read as the password (the CLI refuses when they do not agree, and names --password-file; it cannot tell identical lines from a password). If the script ends where the password should be, the CLI stops with an error that names --password-file; it never answers a bare EOF. --password-stdin on a terminal is refused, because the line would be typed in the clear: leave the flag off to be prompted without echo.

There is no --password flag on any user command: a password on a command line is visible in ps and shell history, and a pre-hashed credential is an offline-crackable artifact of a password people reuse. The server never receives the plaintext on this path — the CLI derives the Argon2id hash locally and sends only the salt, hash and parameters, which the server peppers before storing.

Roles

Roles are named for what they do, so the intuition transfers. A role is always bound to a database: writer@app grants read and write on app. Use db: "*" (writer@*) for every database.

RoleGrantsCovers
readerreaderget, exists, count, list, search, btree scans, blob read
writerreader + writerput, insert, update, delete, batch put, blob write, queue/heap mutations
db-managerreader + writer + db-managercreate/drop bucket, index changes, bucket stats, retention
user-manageruser-managercreate/alter/drop users, grants, password changes, within its own roles (below)
cluster-managercluster-managernode add/remove, rebalance, migration, gossip, nodes
backup-operatorbackup-operator (+ reader)dump/backup, including buckets it cannot read directly
restore-operatorrestore-operatorrestore/import
rooteverything, all databasesthe bootstrap administrator

Two notes on scope. A role only applies to the database it was granted on: writer@app + reader@logs can write app and cannot write logs. A user with no grant on a database is refused there. And user and cluster administration are store-wide in v1 — the database in a user-manager or cluster-manager grant is not used to narrow the action.

A user-manager works within its own roles. Unless it also holds root@*, it gives only roles it holds itself, on a database it holds them on, and it creates, alters, drops, grants to or revokes from only accounts whose roles it holds itself. A user with user-manager@* and db-manager@app can create an app reader or writer and reset that user’s password; it cannot give root, cluster-manager, backup-operator or any role on billing, and it cannot change a root account. The refusal names the grant and says to ask a root user. writer includes reader, db-manager includes both, and backup-operator includes reader.

show databases follows the grants too: it lists the databases a user holds a role on, and every database for a role on *.

Refusals, the server log and the audit log name these roles (lacks "writer", grant role writer@app to alice). Earlier development builds used other names for the same roles; those are not accepted, and a refusal that meets one says what it is called now. Granting the same role on the same database twice stores it once, and one revoke role removes it.

A users.json written by an earlier development build can hold the old names. The server refuses to start on it and names each old role and its new name. users.json is sealed, so it cannot be edited by hand, and --reset-password refuses it too. To recover, stop the server, move users.json aside, and start it again with HOARDDB_ROOT_PASSWORD set, which creates the root user afresh. That drops every other account; create them again with the new role names.

Read users back

[h]oardDB> user list
USERS (master: node-a at 192.0.2.4:7433, config version 2)
---
admin: enabled, roles [root@*], epoch 0
alice: enabled, roles [reader@logs, writer@app], epoch 0

[h]oardDB> user describe alice
user:  alice
status: enabled
roles:  [reader@logs, writer@app]
epoch:  0
created: 2026-09-19T06:45:01Z
password changed: 2026-09-19T06:45:01Z

user list and user describe never show a hash, a salt or a pepper.

Change a user

Grant and revoke are incremental:

[h]oardDB> grant role db-manager@logs to alice
granted db-manager@logs to "alice" (config version 3)
[h]oardDB> revoke role db-manager@logs from alice
revoked db-manager@logs from "alice" (config version 4)

alter user ... roles [...] replaces the whole role set, so it is refused if the user store changed since you read the version (the CLI reads the current version for you). Prefer grant/revoke when you are adding or removing one role:

[h]oardDB> alter user alice roles [reader@app, writer@logs]
user "alice" altered (config version 5)

Disable and enable:

[h]oardDB> alter user alice disable
user "alice" altered (config version 6)
[h]oardDB> alter user alice enable
user "alice" altered (config version 7)

Change a password (prompted, or from the script as under “Create a user”):

[h]oardDB> alter user alice password
New password:
Repeat new password:
user "alice" altered (config version 8)

A password change bumps the user’s epoch, which refuses every token minted before it at the next refresh. The old password stops working immediately.

Drop a user

[h]oardDB> drop user alice
user "alice" dropped

The last enabled user-manager or cluster-manager cannot be dropped, disabled, or stripped of the granting role; hoardDB refuses and names the reason. A lockout is not recoverable through the CLI.

What happens to an open connection

  • Role changes, grants and revokes apply at the user’s next AUTH. An already authenticated connection is not terminated and keeps the roles it authenticated with; a token refresh picks up the narrowed or widened set.
  • Disable, drop and a password change close that user’s existing sessions on every node: at once on the node that takes the change, and on each other node as soon as the change reaches it, normally within a second. A request on such a connection is refused with NOT_AUTHENTICATED and the connection is closed; the client must log in again (and, after a drop or disable, is refused).

Reading a refusal

A permission refusal names the user, the missing action, the resource, the roles the user actually holds, and the command that fixes it:

[h]oardDB> create database other
CLI error: create database "other": server error (5): permission denied: user "alice" lacks "db-manager" on "other"
       alice's roles: reader@app
       ask an administrator to run: grant role db-manager@other to alice

Repeated identical refusals reach the audit log once per minute with a count of the repeats after that, so a misconfigured client in a retry loop does not flood the log.

Source: docs/user/user-management.md in the repository.