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.
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
/adminand 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
-
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.rolegrant. -
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. -
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 theview.block for each area separately. -
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/auditunder moduleADMIN_CRM. -
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. -
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" --applyNaming 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.