Roles and permissions

How Bicrypto decides who may open an admin screen and who may act on it — the four roles that ship, how a permission key is built, where each grant is enforced, and why a saved change does nothing until the backend restarts.

11 min readUpdated 6 August 2026

Access control is two lists joined together. permission holds every key the platform knows about. role holds the named sets an operator can be put in. role_permission joins them.

Every account points at exactly one role through user.roleId. There is no per-user override, no second role and no inheritance between roles. Two people who need different access need two different roles.

The roles that ship

pnpm seed creates four roles and nothing else about them.

Role Who gets it Permissions at install
Super Admin the one account the seeder creates on a fresh database none needed — the checks short-circuit on the name
Admin nobody none
Support nobody none
User every new registration none

No seeder grants a single permission to any role. Moving somebody onto the Admin role gives them the sign-in and nothing else — the admin area answers with a "no permission" screen until you tick boxes on the Roles page. The name carries no authority of its own.

The same is true of a role created on demand: one made by hand on the Roles page, or the "Admin" role that demo mode (NEXT_PUBLIC_DEMO_STATUS) creates the first time somebody registers. Nothing seeds a new role's permissions, ever.

This is the single most-reported "the admin panel is broken" and it is worth recognising on sight. An account on a permission-less role:

  • signs in successfully
  • lands on /admin and is bounced to /admin?auth=false
  • sees every /api/admin/* call answer 403 in the browser console

Older builds then rendered "Access Restricted — You Need To Be Authenticated To Access This Area" with a Sign in button, which is false in every particular: the user is authenticated, and signing in again cannot help. The reasonable next move looks like deleting the account and making another, which does not help either and eventually trips the signup rate limit. Current builds say "Permission Required", name the role, and point at Admin → Roles.

If you are locked out of the admin panel entirely and have no Super Admin to hand, Recovering from a role with no permissions is the way back in.

The first account is created only when the user table is empty, using SUPERADMIN_EMAIL and SUPERADMIN_PASSWORD from .env when they are set, and superadmin@example.com / 12345678 when they are not. A marker row is written into settings afterwards, so the seeder never re-creates that account on later updates — including after you delete it.

What Super Admin actually is

Super Admin does not hold all 715 keys. Every gate in the platform compares the role's name against the literal string Super Admin and returns true. Two things follow from that.

New permission keys shipped by an update work for Super Admin the moment they land. Every other role has to be re-ticked by hand, because a key that did not exist yesterday cannot have been granted yesterday.

The role is fenced off from the admin screens. It is excluded from the Roles list, so it cannot be edited or renamed there; it is excluded from the role picker on the Users screen, so nobody can be promoted into it; it cannot be deleted; and its permission set cannot be synced through the API. Super Admin rows are also hidden from the user list for everyone below Super Admin.

Only a Super Admin may create, rename or delete a role, or change which role an account sits on — and changing an account's role makes the caller re-enter their own password first.

Reading a permission key

A key is the admin route it guards, written as verb.domain.resource. Take the path, drop /admin, turn / into . and - into _, then prefix the verb.

Admin path Key
/admin/crm/user access.user
/admin/crm/kyc/application access.kyc.application
/admin/finance/deposit/gateway access.deposit.gateway
/admin/ai/binary-engine/tiers view.ai.binary_engine.tier
/admin/hb/strategies view.hb.strategy

Multi-word segments are snake_case, never kebab: forex_trading, trading_bot, ai.binary_engine, copy_trading. The resource is singular — tier, not tiers.

The verb says what the key lets you do.

Verb Meaning Count
access opens a screen 173
view reads the data on it 161
edit saves a change, flips a status, approves a queue item 139
create adds a record 113
delete removes one 113
manage privileged lifecycle work — triggering cron, KMS, bot control, emergency stops 14
export export.user only 1
import import.user only 1

715 keys in total, in the permissionsList array of backend/seeders/20240402234643-permissions.js. The routes between them demand 524 distinct keys across 1,168 declarations, and every one of those 524 is in the seeded list — so nothing a route asks for is ungrantable. The remaining keys gate screens and menu entries rather than API calls, which is why ticking a key is not by itself proof that a page will open.

access and view travel in pairs

Almost every admin screen is a data table, and a table asks two questions before it fetches anything: does this role hold the screen's access key, and does it hold its view key. Both must pass. The buttons are separate — create, edit and delete each default to hidden when the key is absent.

Grant access.user without view.user and the operator gets the Users page, the header, the filters and an empty table. Forever. There is no error, no 403 banner and nothing in the console — the table simply never asks the server for a row. Always tick the access and view keys for a screen together.

Where a grant is checked

The same key is consulted in four places, and they do not all matter equally.

Gate What it controls What happens without the key
Next.js middleware the page URL redirected to the same URL with ?auth=false and a "no permission" state
The admin menu whether the entry is listed the link is hidden; the URL still resolves if the middleware allows it
The data table whether rows load, and which buttons appear empty table, or a missing Create/Edit/Delete button
The API route the data itself 403 Forbidden

Only the last one protects anything. The menu is presentation — a group header is shown when the role holds any of the permissions inside it, so seeing "Finance" in the nav says nothing about which of its twenty-two screens will open. One of those twenty-two, Trading Settings, declares no permission at all in frontend/config/menu.ts and has no URL-map entry either, so it is listed for every role that can see the group and opens on the access.admin fallback below. Hiding a menu entry is not a way to restrict access, and revealing one is not a way to grant it.

The URL map lives in frontend/middlewares/permissions.json and carries 245 entries. It fails closed: an admin path with no entry requires the base access.admin key.

298 admin pages exist; 53 of them are not in the URL map and therefore open for anyone holding access.admin alone — individual user profiles, the page builder, the copy-trading admin, ecommerce settings, custom EVM chains. The data on those pages is still fetched through the API and still refused without the right view.* key, so what an under-privileged operator sees is an empty shell rather than real records. Treat access.admin as "may walk the whole admin area", not as "may see the dashboard".

Granting access

  1. Sign in as a Super Admin. Nobody else can create or change a role, whatever keys they hold. The route checks the caller's role name, not their edit.role grant.

  2. Open Users, then Roles & Permissions, then User Roles. The admin panel has no sidebar — this is the top-nav mega dropdown. The screen is /admin/crm/role.

  3. Create the role, or edit an existing one. The Permissions field is a multi-select over every seeded key, sorted alphabetically. Because keys sort by verb first, tick the access. block and the view. block for each area separately.

  4. Save. The permission set is replaced wholesale by what is in the field — anything unticked is removed. The change is written to the admin audit trail at /admin/system/audit under module ADMIN_CRM.

  5. Assign the role. On /admin/crm/user, edit the account and pick the role. Only a Super Admin may do this, and only after re-entering their own password.

  6. Give it up to a minute. See below — the API re-reads roles on a timer, so a grant is live within 60 seconds without a restart.

Recovering from a role with no permissions

You need a Super Admin to grant permissions, and a Super Admin has to be able to reach the admin panel. When neither is true — the only administrator sits on an empty role, or the Super Admin account was deleted — the way back in is the command line, on the server, with database access:

cd backend

# 1. Make sure every permission a route declares actually exists as a row.
#    Inserting one grants nobody anything; it only makes it assignable.
pnpm sync:permissions -- --apply

# 2. See what the role is missing, without changing anything.
pnpm sync:permissions -- --grant "Admin"

# 3. Hand them over.
pnpm sync:permissions -- --grant "Admin" --apply

Naming a role that does not exist prints the roles that do, with their ids — useful on its own when you are trying to work out which role an account is actually on.

Something that hands a role every permission on the platform must not be reachable over HTTP. It requires shell access to the server, which is the same bar as editing the database directly — and it is the bar that makes it a safe recovery tool rather than an escalation primitive.

Super Admin is refused as a target: it bypasses permission checks by name, so granting it rows would imply the name is not the check.

The Permissions screen at /admin/crm/permission is read-only by design. It lists the keys and which roles hold them; there is no create, edit or delete, and the backend ships no route for any of those. Keys come from the seeder, not from operators.

Permission changes take up to a minute

The backend answers every gated request from an in-memory map of roles and their permissions. That map is refreshed on a 60-second timer, in every worker thread, so a grant or a revocation is in force platform-wide within a minute of the save — no restart.

The thread that handled your save reloads immediately, so the operator who made the change usually sees it at once. The other threads are what the timer is for: module state is per-thread, and reloading only the thread that took the POST would leave the remaining ones refusing at random, which looks intermittent and is worse to diagnose than a consistent delay.

The front end re-reads the role list on its own schedule — at most five minutes — so the menu and the URL gate can lag the API by a few minutes in either direction.

For up to a minute after you untick a permission, the API still accepts calls from that role. If you are revoking access in an incident, block the account or set its status to BANNED as well — that revokes every session it holds immediately.

The map used to be loaded once at boot and never again, so a grant simply did not exist as far as the API was concerned until somebody ran pm2 restart backend. The screen said "saved", the role_permission row was written, and the endpoint kept answering Forbidden. If a grant has not taken effect after a couple of minutes, restart the backend and check which build you are on.

Notable permission groups

These are the keys worth deciding deliberately rather than ticking in bulk.

Key What it unlocks
access.admin the admin dashboard, plus every admin page absent from the URL map
view.user / edit.user the customer database; edit.user also covers blocking accounts and resetting 2FA
export.user / import.user bulk extraction of the customer list, and bulk account creation from CSV
create.role / edit.role / delete.role listed for completeness — the routes refuse anyone who is not a Super Admin regardless
edit.wallet adjusting a customer balance up or down, and approving or rejecting a withdrawal from the wallet screen
edit.withdraw deciding the withdrawal queue — the key that moves money out of the platform
edit.deposit deciding the deposit queue
edit.transaction editing a settled transaction record
edit.kyc.application approving and rejecting identity verification
edit.settings every platform setting, including mail, 2FA policy and fees
access.database database backup, the backup list, and restore
create.license update checks and downloading a release over the running install
manage.cron firing a scheduled job by hand
manage.ecosystem.kms the master-wallet key management service
edit.extension enabling and disabling extensions
access.admin.audit reading the operator audit trail

A separate Super-Admin fence sits behind edit.settings: the 39 keys in backend/src/utils/protected-settings.ts reject a save from any role but Super Admin, even one holding edit.settings. They are the two withdrawal-approval switches and the five withdrawal 2FA keys, the two P2P 2FA keys, the five TransFi ramp, custody and IBAN switches, the two transfer-fee keys and the transfer spread, the two demo-mode keys, the forex A-book routing switch, the trading-bot fee pair and its two kill switches, KYC feature enforcement, the nine AI Support keys, and the five DEX/Swap switches. Everything else on the Settings screen saves normally.

DEMO_STATUS and NEXT_PUBLIC_DEMO_STATUS are on the protected list, but no reader in the backend or frontend consults a settings row for either one — demo mode is read from process.env.NEXT_PUBLIC_DEMO_STATUS and only from there, by utils/constants.ts, handler/Middleware.ts, utils/demoMask.ts and both registration routes. Writing them through the settings API creates a row that changes nothing. They are kept on the list on purpose, so that whoever wires demo mode to a settings row later does not inherit an unguarded key. Turn demo mode off in .env and restart, not on the Settings screen.

Extension admin sub-menus declare no permissions of their own. Their screens are gated by the URL map and by the API routes behind them, so an extension page that nobody mapped is reachable by anyone holding access.admin.

What updates do to your grants

pnpm seed — which pnpm updator runs on every update — is additive. The permissions seeder inserts only names that are missing; the roles seeder inserts only roles that are missing. Neither touches role_permission, so custom roles survive an update untouched.

Two repair seeders re-point grants when a key is renamed. They read the existing role_permission rows, insert the equivalent rows for the new key and only then remove the old one, so a role keeps the access it had rather than silently losing a screen. They are idempotent and safe to re-run.

If the first seeding attempt fails, the installer's retry path runs TRUNCATE TABLE permission; TRUNCATE TABLE role_permission; before trying again. The permission list is rebuilt by the seeder — the grants are not. Every custom role comes back empty, and only Super Admin can still reach anything. Re-tick the roles afterwards, and restart the backend.

API keys are a separate system

An API key carries scopes, not permission keys. The scopes are trade, futures, deposit, withdraw and transfer, plus three gateway.* scopes for the payment gateway extension, and they map to route prefixes rather than to the verb.domain keys above. Nothing on the Roles screen affects them.

The API Management screen at /admin/api/key creates a key against a chosen userId, with whatever scopes the form was given. A key acts as its owner, so an operator holding create.api.key can mint working credentials for any account on the platform, including scopes that move money. Grant it only to roles you would trust with the accounts themselves.