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.
Authentication
Section titled “Authentication”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.
Sessions are database rows
Section titled “Sessions are database rows”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, setHttpOnly,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.
Forced password change
Section titled “Forced password change”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 Super Admin
Section titled “The Super Admin”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_USERNAMEmust 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,
IsSuperAdminshort-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.
Permissions
Section titled “Permissions”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.
Built-in roles
Section titled “Built-in roles”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.
River UI needs a write permission
Section titled “River UI needs a write permission”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.
Audit log
Section titled “Audit log”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.
What’s managed where
Section titled “What’s managed where”| 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.