Permissions
How an endpoint declares what it needs
Every protected controller method is annotated with
@RequirePermissions(Permission.X) (or several). A global PermissionsGuard
reads that decorator and checks the requesting user's JWT for a matching
permission string:
@RequirePermissions(Permission.MANAGE_USERS)
@Get()
async findAll() { ... }
If the permission is missing, the guard throws a ForbiddenException — a
403, not a 401. A logged-in user with the wrong role gets a clean
"you're allowed in, but not to do this" response, distinct from "you're not
authenticated at all."
A handful of endpoints are marked @Public() instead (auth's own
login/refresh, and the health check) — those skip the permission check
entirely, by design.
The 4 built-in system roles
Seeded once, in migration 1782134698173-CreateRolesAndAddRoleIdToUsers,
and marked isSystem: true (can't be deleted through the API):
| Role | Permissions |
|---|---|
storeKeeper | READ, WRITE, VIEW_REPORTS, VERIFY_BATCH |
storeManager | READ, WRITE, VIEW_REPORTS, APPROVE_ORDER, ISSUE_STOCK, ADJUST_INVENTORY, VERIFY_BATCH |
CentralUser | READ, VIEW_REPORTS, EXPORT, ADJUST_INVENTORY |
Administrator | everything — full access, including MANAGE_USERS, MANAGE_ROLES, MANAGE_CONFIG |
New roles can be created through the /roles API with any combination of
permission strings — the 4 above are just the starting set, not a closed
list.
One real gotcha: MANAGE_USERS and organisation level
UsersService.create() refuses to assign a role that has MANAGE_USERS
to a user whose organisation isn't the main store
(orgUnit.isMainStore). In practice: admin-level roles are central-only,
enforced in code, not just convention.
First-run bootstrap
On a completely empty database, the very first application boot
auto-creates a root Location (name/code from DEFAULT_ORG_NAME/
DEFAULT_ORG_CODE env vars, or Location01/ROOT if unset) and one
admin01 user with a random temporary password, assigned the
Administrator role. The password is logged once, at boot, and nowhere
else — if you miss it, tempPassword is still readable via GET /users
by another admin, until it's changed.