Skip to content

Users & Permissions

Zilean is multi-user. Accounts, roles and sessions all live in the database; only the single Super Admin is bootstrapped from the environment.

Login is a form POST to /login served by zilean-ui. Passwords are hashed with bcrypt at its default cost, everywhere — login, invite acceptance, admin resets and self-service changes.

An unknown email is compared against a fixed dummy hash so the bcrypt cost is paid either way, and every failure renders the same message. There is no way to tell “no such account” from “wrong password” by response content.

A session is a row, not a signed cookie:

  • The token is 32 random bytes. Only its SHA-256 hash is stored — the raw token is never persisted.
  • The cookie is river_admin_session, set HttpOnly, Secure, SameSite=Lax, Path=/.
  • TTL is 24 hours, refreshed only at login. There is no sliding renewal.
  • There is no signing secret to configure.

Sessions are revoked on logout, when a user is disabled or deleted, on an admin password reset, and by a daily sweep of expired rows. A session whose user is no longer active is rejected by the middleware regardless — disabling a user takes effect immediately, without relying on the revocation call.

A user created by an admin with a temporary password must change it on first login. Until they do, /api/* requests return:

403 {"error":"password change required"}

Exempt: GET /api/account/me, POST /api/account/change-password, and static assets — enough for the UI to show the change-password interstitial and submit it.

The one Super Admin is seeded from ADMIN_USERNAME and ADMIN_PASSWORD_HASH on first boot only.

  • If any row already has the super-admin flag, seeding returns immediately. The database is authoritative after first boot; changing the environment variables later has no effect.
  • ADMIN_USERNAME must be an email address or core refuses to start. See Installation.

Beyond that one-time bootstrap, more than one account can hold the super-admin flag: being assigned the Super Admin role sets it — whether by accepting an invite into that role, by being created directly into it, or by a later role change — since that role carries no permission rows of its own (see Built-in roles below); its grant is computed from the flag, not from role rows, so without this the account would activate with zero effective permissions. Only an existing Super Admin can assign someone the Super Admin role, or any role whose permissions exceed their own — a caller holding only invites:create or users:write can’t use either to escalate themselves or anyone else past what they already have.

Only the bootstrapped account is protected: role changes, status changes and deletion all return 409 against it, as does an admin password reset targeting it. A Super Admin granted the role later (invite, direct creation, or a role change) is a regular user in every other respect — another Super Admin can demote, disable, reset the password of, or delete that account like any other. Internally this is a second flag, set only by the one-time bootstrap seed and never by the invite/create/role-change paths that can grant the super-admin flag itself. Separately, an invite cannot be minted for an already-active user regardless of super-admin status. (That guard closes an account-takeover path available to a caller holding only invites:create.)

How the Super Admin relates to permission checks

Section titled “How the Super Admin relates to permission checks”

Two distinct behaviours:

  • On a check, IsSuperAdmin short-circuits to allowed without consulting any permission set. The role isn’t even loaded.
  • On enumeration — GET /api/account/me — every permission key is returned, so the UI’s gating works.

There are 13 permission keys. They live in Go, deliberately not in a database table, so adding one automatically grants it to the Super Admin without a migration.

See the permissions reference for the full table of what each one gates.

Enforcement is per route, returning:

403 {"error":"you do not have permission to perform this action"}

The one exception is jobs:script, which can’t be a route-level check because the job type is only known after the body is decoded. It’s enforced inside the create and update handlers instead.

A user can hold more than one role, and always holds at least one — the admin UI’s user form is a checkbox multi-select rather than a single dropdown, and the API rejects a create or update that would leave a user with zero roles. Effective permissions are the union of every assigned role’s permissions: holding Read-Only plus a custom jobs:script-only role grants both, not whichever role was assigned “last.”

Three system roles are seeded and cannot be renamed or deleted. Super Admin’s permissions also can’t be touched — its access is computed from the user’s super-admin flag, not from role rows, so there’s nothing meaningful to edit. Admin and Read-Only can have their permissions edited (by a caller holding roles:write) even though they can’t be renamed or deleted; the table below shows each one’s seeded defaults.

Role Permissions
Super Admin None stored. Its behaviour is computed from the user’s super-admin flag, not from role rows.
Admin jobs:read, jobs:write, workflows:read, workflows:write, globalvars:read, globalvars:write, metrics:read, audit:read (seeded default; editable)
Read-Only jobs:read, workflows:read, globalvars:read, metrics:read, users:read, roles:read (seeded default; editable)

Custom roles are created under Settings → Roles, with a checkbox grid built from the live permission list (GET /api/roles/permissions).

A roles:write holder can only add a permission to a role if they hold it themselves; attempting otherwise returns 403. Permissions already on a role can be left in place, renamed around or removed, so a lower-privileged role editor can still maintain a role a higher-privileged admin built. Super Admins are exempt.

The embedded River UI at /riverui/ is gated behind jobs:write, not mere session validity, because it is not read-only — it can retry, cancel and delete queued jobs. Gating it on session validity alone would let a Read-Only user mutate the queue.

Actions are recorded across jobs, workflows, groups, global variables, users, roles, invites, authentication, and settings such as SMTP, MCP tokens, notification targets, job alarms and metrics thresholds. A manual run of a job (Run now, POST /api/jobs/{id}/run, or the MCP zilean_run_job tool) is recorded as job.run on the job resource type when it succeeds; a rejected run is not recorded. Each entry holds the actor’s id and email, the action, resource type and id, before/after JSON snapshots, the request IP and a timestamp.

The actor id deliberately has no foreign key to users, so entries survive the deletion of the user who made them.

Two gaps worth knowing:

  • Failed logins are only recorded for emails that resolve to a real user. An unknown email has no actor to attach, and recording it would make the audit log an email-enumeration oracle.
  • Changing the audit retention setting is not itself audited.

Retention defaults to 180 days, seeded once from DEFAULT_AUDIT_RETENTION_DAYS into a database setting. After that it’s changed under Settings → Audit log by anyone with audit:write, accepting 1–3650 days. The daily sweep re-reads the setting each run.

Reads are keyset-paginated newest-first, filterable by actor, action, resource type and time range. The Audit Log filter lists every resource type. Page size is fixed at 50 and is not client-tunable.

Managed in the admin UI Bootstrapped from the environment
Users, roles, invites The single Super Admin’s email and password hash
Audit retention window Its initial default
Global variables —

See Managing users for the invite and temp-password flows.