Skip to content

Access Control: Users, Roles, API Tokens and Audit ​

Console accounts live in the gateway's own .zhin/gateway.db, not in the project database (ADR 0049, ADR 0059); the management gateway handles sign-in and authentication (ADR 0054). These pages keep working while the instance is stopped or crashed.

The Access control group in the sidebar has four pages: Users (/access/users), Roles (/access/roles), API tokens (/access/tokens) and Audit log (/access/audit). Reading needs iam.view, changes need iam.manage — among the built-in roles only owner holds iam.manage; admin has iam.view; without iam.manage the pages are read-only. Accounts without iam.view do not see the group. Deleting, disabling and revoking ask for confirmation, and deleting a user or role asks you to type its name.

The roles here are Console account roles: they decide who can do what in the Console. The Roles tab of the object pages manages chat roles (Owner / Administrator / Trusted), which decide who can use the bot's features in chat; the two never affect each other (see chat roles). Everyone changes their own password and issues their own API tokens in Personal settings (/account, from the user name in the top bar).

Credentials ​

CredentialWhere it comes fromUsed for
Bootstrap tokenhttp.token in the configuration (scaffolded into HTTP_TOKEN in .env)Only the setup wizard before any owner exists (Onboarding); returns 401 everywhere once the owner exists
Session tokenUsername + password sign-in (POST {base}/auth/login)The Console in the browser. 12-hour sliding expiry by default, 30 days at most
API tokenIssued by a user (below)CI, monitoring, zhin doctor --live, MCP / A2A clients; command-line tools read ZHIN_CONSOLE_TOKEN

With neither http.token nor an owner, the Host is open: it holds every permission and only accepts loopback peers without X-Forwarded-For / Forwarded (a request with an unknown peer address is not local). The gateway refuses to start open when http.host is not a loopback address (e.g. 0.0.0.0 in a container): first run zhin doctor --fix to generate HTTP_TOKEN in .env and set http.token: ${HTTP_TOKEN}, or create the owner with zhin console init. A same-host reverse proxy that adds no forwarding header makes every proxied request look local to an open Host, so always set a bootstrap token behind a reverse proxy.

Every credential is sent as Authorization: Bearer …; no cookies are used. Failed sign-ins always return 401; after 5 failures per IP and username within 15 minutes the gateway backs off exponentially (429).

Users ​

Lists every user: username, display name, roles, status and when the password last changed. Each row's manage menu has Change display name, role bindings, reset password, disable / enable and delete.

ActionRPCPermission
Listiam.users.listiam.view
Create (lowercase unique username, password of at least 10 characters)iam.users.createiam.manage
Change display name (the username is for sign-in and cannot change after creation)iam.users.updateiam.manage
Disable / enableiam.users.disableiam.manage
Deleteiam.users.deleteiam.manage
Reset passwordiam.users.reset-passwordiam.manage
Change role bindingsiam.bindings.setiam.manage
  • Changing a password, disabling or deleting a user ends all of that user's sessions immediately; disabling or deleting also invalidates the API tokens they issued.
  • Those actions and binding changes also close the event streams and WebSockets the user has open on the instance (they reconnect with the new permissions); binding changes apply to the next request immediately, because every request is authorized against the current bindings.
  • There is always at least one enabled user with a global owner binding: deleting, disabling or demoting the last owner is refused.
  • Every user can change their own password under personal settings (POST {base}/auth/password).

Bindings and scopes ​

A binding = role + scope:

ScopeKeyCovers
Global—Everything
AdapterAdapter instance alias, e.g. qqThat adapter and all its accounts
Account<alias>/<endpointId>, e.g. qq/bot-1Only that account

Only endpoint.view, endpoint.operate, endpoint.manage, feature.configure and feature.toggle are narrowed by scope; every other permission counts only from global bindings. Example — "an operator for Telegram only": bind operator with the adapter scope tg-main.

Roles ​

Built-in roles cannot be changed or deleted, but can be copied:

RolePermissions
ownerEverything (including ops.terminal and iam.manage)
adminEverything except ops.terminal, ops.database.schema and iam.manage
operatorstatus.view, endpoint.view, endpoint.operate, feature.view, feature.configure, feature.toggle, agent.view, ops.schedule.read, plugin.perms.read, plugin.perms.operate
viewerEvery *.view and *.read except ops.config.read and ops.env.read (they may contain secrets)
ActionRPCPermission
List roles and the plugin permission catalogiam.roles.listiam.view
Create / update / delete custom rolesiam.roles.create / iam.roles.update / iam.roles.deleteiam.manage

Permission catalog ​

PermissionCovers
status.viewHome, system status, logs, capability browser, command permissions, the Records tab of object pages, event stream, instance status and logs
endpoint.view / endpoint.operate / endpoint.manageReading accounts and conversations / sending messages, handling requests, login assist / adding, changing, starting and stopping account connections
feature.view / feature.configure / feature.toggleReading feature configuration / writing configuration and overrides / enabling and disabling at the adapter, account and conversation levels (see Feature matrix and layered configuration)
plugin.manageInstalling, updating and uninstalling plugins and dependencies; global plugin on / off (disable_plugins, a gateway transaction); onboarding steps 3–5
agent.view / agent.configureAI and Agent reads / writes
ops.config.read / ops.config.writeRaw configuration file
ops.env.read / ops.env.writeEnvironment files: read (masked values only) / save; showing one value as written (env:reveal) needs both and is audited
ops.database.read / ops.database.writeDatabase browser (sort, filter, table structure and schema change previews) / record edits
ops.database.schemaThe SQL console (read-only queries and write SQL) and schema changes (add / rename / drop columns, change types, create tables, indexes); owner-only by default, every statement is audited with its literals redacted. See Database
ops.schedule.read / ops.schedule.writeScheduled tasks (an explicit IM delivery target also needs endpoint.operate on that account)
ops.files.read / ops.files.writeProject files (reading or writing the configuration file still needs ops.config.*; .env / .env.* are never served as project files and are managed on the environment variables page /ops/env under ops.env.*)
ops.restartStarting, stopping and restarting the instance
ops.terminalTerminal (see Operations)
iam.view / iam.manageThis group; iam.manage also covers the Roles tab of object pages (chat-roles.*, checked by scope)
mcp.access / a2a.accessBearer access to the MCP Server and A2A Server
plugin.perms.read / plugin.perms.operate / plugin.perms.manageEvery plugin permission of that level

Every Console RPC, every {base} HTTP route and every Host-authenticated WebSocket is listed in the permission maps of @zhin.js/console-protocol (CONSOLE_RPC_PERMISSIONS, CONSOLE_HTTP_PERMISSIONS, CONSOLE_WS_PERMISSIONS); anything unlisted is denied to every signed-in principal.

Plugin permissions ​

Plugin permissions are named <plugin>:<action> (onebot11:read, ambient:read, ops:approve) and belong to one of three levels — read, operate, manage — declared by the plugin in definePluginConsoleRpc({ permissions }). Undeclared levels are inferred from the suffix (:read / :view → read; :approve / :manage / :admin / :write / :config → manage; anything else → operate).

  • viewer gets the read level, operator gets read + operate, admin and owner get all.
  • Custom roles can tick level permissions or list plugin permission names directly. A name no loaded plugin uses can still be saved (the plugin may not be installed yet); the result carries warnings, and the name grants nothing until a plugin uses it.
  • The role editor shows each role's effective plugin permissions with their source (level / explicit) and the catalog of loaded plugins' permissions (reported by the instance; while it is offline the gateway keeps the last one).
  • Plugin Console RPC is authorized per method: the method's declared permissions and its page's requirements must both hold; when neither declares anything, read methods need feature.view and actions need feature.configure.

API tokens ​

ActionEndpointPermission
My tokens (Personal settings /account)GET / POST {base}/auth/tokens, POST {base}/auth/tokens/revokeAny signed-in user
All tokens (filter by user)iam.tokens.listiam.view
Issue for a user / revokeiam.tokens.create / iam.tokens.revokeiam.manage

On the API tokens page, click Issue for a user, pick the user and set a name and a validity period; issuing your own token in Personal settings works the same way. A token issued from the Console UI carries all of the user's permissions; when issuing through the API you can list only the permissions needed (a readiness probe needs only status.view, an MCP client only mcp.access), and the list shows it as narrowed to N items.

  • The plaintext is shown once, at issue time; the database stores only its SHA-256.
  • A token never exceeds its user: listed plugin permissions take effect only if the user holds them (by name or level); disabling or deleting the user invalidates the token; tokens can also be revoked individually.
  • Command-line tools read ZHIN_CONSOLE_TOKEN: ZHIN_CONSOLE_TOKEN=<token> zhin doctor --live https://bot.example.com/api --json.
  • MCP / A2A clients send the token as Authorization: Bearer; the issuing account must hold mcp.access / a2a.access globally (a grant limited to one scope does not count). The protocols' own mcp.token / a2a.token work as well; see Configuration · mcp / a2a. The bootstrap token stops working once the first owner exists and cannot be used for MCP / A2A.

Audit log ​

iam.audit.list (iam.view) pages through console_audit_log, filterable by principal, action and time.

Recorded eventDetails
Sign-in success / failure, sign-out, password changePrincipal, IP
Every iam.* writeChanges to users, roles, bindings, tokens
RPCs mapped to a write permissionWrites on the instance are reported by the instance and written by the gateway
Showing an environment variable as written (env:reveal)File, variable name, principal; never the value
Every denied requestoutcome: denied
Terminal session start / endTarget, duration, exit code and reason; never input or output
  • Principals are written as bootstrap, user:<id> or api-token:<id>; detail records only the RPC type, target and parameter key names — never parameter values or secrets; env:reveal / env:save also record the file and the variable names involved (set / deleted).
  • Entries are kept for 180 days and pruned daily.
  • The gateway does not read X-Forwarded-For: behind a reverse proxy, the recorded IP is the proxy's.

Offline rescue ​

Forgot a password or locked the owner out? Work on .zhin/gateway.db directly on the server (zhin console user …); no running gateway is needed and there is no online super-credential.

bash
zhin console user list
printf '%s\n' "$NEW_PASSWORD" | zhin console user passwd alice --password-stdin
zhin console user enable alice
printf '%s\n' "$PASSWORD" | zhin console user create rescue --role owner --password-stdin

zhin console user create without --role creates a viewer.