Skip to content

Authentication and permissions

Source: g2labs-in/billuu-docs@9f1dcd7 · content/architecture/auth-and-permissions.md · updated 2026-09-16

What this page covers

This page follows one request from the login form to the permission check that guards an API route. It names the file that enforces every decision.

Frontend paths are relative to brewlabs-billu-frontend. Backend paths are relative to brewlabs-billu-backend.

Three ideas carry the whole system:

  • A signed JWT identifies the user, the tenant and the outlet.
  • A role is a coarse label. A permission is the thing that is actually checked.
  • The server decides. The browser only mirrors the decision to hide controls.

Login

POST /api/auth/login lives in src/auth/auth.routes.js. Three middlewares run before the controller.

  1. checkAccountLockout rejects a locked account with HTTP 423.
  2. loginLimiter rate limits the attempt.
  3. validate('login') checks the body against the rules in src/middlewares/authValidation.js.

authController.login in src/auth/auth.controller.js reads identifier, password, latitude and longitude from the body. identifier accepts an email address or a phone number. authService.login in src/services/authService.js strips a phone number to its last ten digits and also tries the +91 and 91 prefixes.

The lookup runs inside databaseExecutor.executeForAuth. It searches User first and SuperAdminUser second, so one transaction covers both tables.

A login fails in four ways.

ConditionStatusMessage
No user row matches401Invalid email or password
isActive is false403Account is deactivated
status is PENDING403Account pending approval
The bcrypt compare fails401Invalid email or password

The hash comes from password_hash for a super admin and from password for everyone else. A successful login writes lastLogin and then calls enrichUserContext.

Every attempt writes an audit row through logAuthEvent in src/security/auditLogger.js, with the action LOGIN_SUCCESS, LOGIN_FAILURE or LOGIN_BLOCKED.

What enrichment adds

authService.enrichUserContext builds the context the frontend needs.

  • organization, read from Organization by organization_id, falling back to business_id.
  • outlets. The service tries outlet_ids on the user row, then rows in outlet_users, then every outlet in the business when the role is OWNER, ADMIN or SUPER_ADMIN and nothing is assigned yet.
  • permissions. SUPER_ADMIN, OWNER and ADMIN get ['*']. Every other role gets its ROLE_PERMISSIONS list merged with the permissions stored against its roleId.
  • schema, built as tenant_<organizationId>.

The same function sets panelType to ADMIN for a super admin and to TENANT for everyone else.

The login response

authController.login sets two cookies and returns a JSON body.

CookieMax ageFlags
accessToken1 hourhttpOnly, secure and sameSite=strict in production, lax otherwise
refreshToken7 daysthe same flags

The body is { success: true, data: { ... } } and carries user, organization, outlets, permissions, canSwitchOutlet and tokens. canSwitchOutlet is true only for OWNER, ADMIN and SUPER_ADMIN.

GET /api/auth/me returns the same user shape plus panelType and status.

Token format

src/core/auth/tokenService.js signs both tokens with HS256.

PropertyAccess tokenRefresh token
Secretconfig.accessTokenSecretconfig.refreshTokenSecret, falling back to config.accessTokenSecret
Lifetime24 hours7 days
Issuerbrewwlabs-posbrewwlabs-pos
Audiencebrewwlabs-pos-usersbrewwlabs-pos-users
Distinguishing claimnonetype: 'refresh'

authService.generateAccessToken builds the payload. It carries id, email, name, role, businessId, business_id, outletId, outlet_id, homeOrganizationId, organizationId, schema, panelType and tokenVersion. Both camelCase and snake_case keys are present on purpose.

Read the lifetimes carefully, because two numbers disagree in the code. The exp claim is 24 hours, while the cookie max age in authController.login is 1 hour and the response reports expiresIn: 3600. The cookie is the shorter of the two, so the browser stops sending the token long before the token itself expires. A JWT read out of the response body stays valid for the full 24 hours.

UnifiedAuthMiddleware.verifyToken in src/core/auth/unifiedAuthMiddleware.js checks the issuer, the audience and a 30 second clock tolerance. It reads the token from req.cookies.accessToken first, then from an Authorization: Bearer header.

Refresh, logout and revocation

RouteBehaviour
POST /api/auth/refreshReads only the refreshToken cookie and sets a new accessToken cookie.
POST /api/auth/logoutCalls tokenService.logoutUser, which blacklists the current jti through src/utils/inMemoryTokenBlacklist.js.
POST /api/auth/invalidate-tokensRequires the USER_UPDATE permission and increments token_version, which invalidates every token carrying the old value.

getValidatedUser in src/core/auth/unifiedAuthMiddleware.js caches the user row after the first authenticated request. The row lives for 5 minutes in a process level Map and for 86400 seconds in Redis.

Where the browser keeps the token

src/auth/AuthContext.jsx writes accessToken and refreshToken to localStorage after login. Redux holds the access token in state.user.token, declared in src/redux/slices/userSlice.js.

src/https/axiosWrapper.js is the axios instance every feature uses. It reads state.user.token, falls back to localStorage.getItem("accessToken"), and adds the Authorization: Bearer header to every request. When a response comes back 401, its response interceptor refreshes the token and writes the new value to localStorage and to Redux.

src/auth/authService.js creates one small second instance, refreshInstance, for the refresh call itself. Its comment says the separate instance exists to avoid an interceptor loop.

The panel type header

Two panel values exist, ADMIN and TENANT. The frontend sends the literal header x-panel-type: TENANT from src/https/axiosWrapper.js, and src/config/corsConfig.js allows that header name in its CORS list.

The backend does not read the header. Every decision uses req.auth.panelType, which comes from the signed token. The column behind it is panel_type on public.users, declared in src/control_plane_models/userModel.js with a default of 'TENANT'.

src/core/middleware/tokenVerification.js exports the two guards that act on the claim.

GuardRule
adminOnlyMiddlewareRejects unless role is SUPER_ADMIN and panelType is ADMIN.
tenantOnlyMiddlewareRejects role SUPER_ADMIN first, then requires panelType to be TENANT.

tenantContextMiddleware in src/core/tenant/tenantMiddleware.js applies the same rule. It lets a super admin through with no tenant context at all, and rejects any other caller whose panelType is not TENANT.

The effect is that a super admin cannot use the tenant app and a tenant user cannot use the admin panel, whatever the client sends.

Outlet scoping

An outlet is the unit of data isolation inside a tenant. The active outlet travels in the x-outlet-id header.

The frontend is the first gate. src/https/axiosWrapper.js resolves the outlet from Redux state.outlet.currentOutletId, then state.outlet.currentOutlet.id, then localStorage.ACTIVE_OUTLET_ID. If the request is not exempt and no outlet is set, the interceptor rejects the request with MISSING_OUTLET_CONTEXT and never calls the API.

The exempt routes are /api/auth/, /api/onboarding/, /api/public/, /health and /api/super-admin/. The same interceptor adds x-organization-id when an organization id is known.

The backend resolves the header in two stages.

UnifiedTenantMiddleware.resolveOrganizationContext in src/core/tenant/unifiedTenantMiddleware.js reads req.headers['x-outlet-id'], falls back to req.body.outletId, and produces req.context. That object carries organizationId, businessId, outletId, outlet, schemaName and subscription.

resolveOutletContext in src/core/rbac/rbacMiddleware.js then decides whether the caller may use that outlet.

  • MULTI_OUTLET_ROLES, which is SUPER_ADMIN, OWNER and ADMIN, may name any active outlet that belongs to the business.
  • Every other role must have an active outlet_users row for the header outlet, and that outlet must be active and in the same business. Without the row the request fails with 403.

Three more middlewares in src/core/rbac/rbacMiddleware.js tighten outlet handling.

MiddlewareEffect
enforceOutletAccessCompares an outlet id from params, body or query against the resolved outlet for single outlet roles.
blockOutletSwitchingRefuses a switch from any role outside MULTI_OUTLET_ROLES.
validateOutletConsistencyReturns 400 when an outlet id in the body, params or query differs from the x-outlet-id header.

Query level scoping uses src/utils/entityScopeRegistry.js. It exports ENTITY_SCOPES with the values outlet, business and organization, plus the helpers isOutletScoped, isBusinessScoped and isOrganizationScoped. getScope throws ENTITY_SCOPE_MISSING for a model that is not registered, so a new model cannot silently skip outlet filtering.

That registry is a different mechanism from the scope column on permission_overrides, which uses the values GLOBAL, ORGANIZATION, OUTLET and USER. Do not merge the two.

The role model

Roles are string constants in src/constants/permissions.js.

SUPER_ADMIN, OWNER, ADMIN, MANAGER, CAPTAIN, CASHIER, KITCHEN_STAFF, WAITER, STAFF.

normalizeRole from the same file maps legacy spellings onto those constants. BUSINESSOWNER, BUSINESSADMIN, OWNER and ADMIN all become OWNER. KITCHENSTAFF becomes KITCHEN_STAFF. SUPERADMIN and SUPER_ADMIN both become SUPER_ADMIN. Anything else is uppercased.

Roles carry a level and a management list in ROLE_HIERARCHY.

RoleLevelCan manage
SUPER_ADMIN100OWNER, ADMIN, MANAGER, CAPTAIN, CASHIER, KITCHEN_STAFF, WAITER
OWNER90ADMIN, MANAGER, CAPTAIN, CASHIER, KITCHEN_STAFF, WAITER
ADMIN80MANAGER, CAPTAIN, CASHIER, KITCHEN_STAFF, WAITER
MANAGER70CAPTAIN, CASHIER, KITCHEN_STAFF, WAITER
CAPTAIN60CASHIER, KITCHEN_STAFF, WAITER
CASHIER50none
WAITER45none
KITCHEN_STAFF40none

STAFF has no entry in ROLE_HIERARCHY, so getRoleLevel('STAFF') returns 0.

src/core/auth/unifiedAuthMiddleware.js and src/core/rbac/rbacMiddleware.js each declare their own ROLES object. Both of those add SUB_OUTLET and PARTNER, which the constants file does not define, and neither has ROLE_PERMISSIONS entries for them. Treat src/constants/permissions.js as the source of truth and check a guard’s own list before you rely on it.

The permission model

Permissions are string keys in src/constants/permissions.js, for example VIEW_ORDERS, CREATE_ORDER and VIEW_KDS. Five of its exports matter here.

ExportPurpose
PERMISSIONSThe permission keys themselves.
PERMISSION_CATEGORIESGroups such as orders, inventory and reports.
ROLE_PERMISSIONSThe default permission list for each role.
MENU_PERMISSIONSMaps a menu key to its permission, label, route and actions.
ACTION_PERMISSIONSMaps an action key such as order.create to a permission.

ROLE_PERMISSIONS[ROLES.SUPER_ADMIN] is Object.values(PERMISSIONS). Every other role has a written list.

Two layers sit on top of those maps.

The role layer combines ROLE_PERMISSIONS with the tenant tables roles, permissions and role_permissions. authService.enrichUserContext merges the two sets when a user has a roleId.

The menu layer holds the per user override. It lives in the control plane table user_menu_permissions, where a permissions JSONB column holds an array, and it is resolved by src/modules/permissions/menuPermissionService.js.

GET /api/me/menu-permissions is registered in src/routes/permissionRoutes.js and served by menuPermissionController.getMyPermissions in src/controllers/menuPermissionController.js. getUserMenuPermissions has three paths.

  • OWNER and SUPER_ADMIN get every menu with every action and no database read.
  • STAFF gets the intersection of its own permissions and the permissions of the admin who manages it, computed by getStaffEffectivePermissions and calculateIntersection. A staff member can never hold a permission its admin lacks.
  • Every other role uses the stored permissions, and falls back to getDefaultPermissions(role) when none are stored.

The controller caches the result twice, in a process level Map for 5 minutes and in Redis for 300 seconds, both keyed by user_menu_perms:<userId>.

Role check compared with permission check

A role check asks which label the caller holds. A permission check asks whether one specific capability is present. The two answer different questions, and a role check is the weaker of the two.

src/core/rbac/rbacMiddleware.js provides both.

HelperQuestion it answersWhat it reads
requireRole(roles)Is the caller one of these roles?req.auth.role
requireMinimumRole(level)Is the caller at or above this level?ROLE_HIERARCHY[role].level
requireRoleHierarchy(field)May the caller manage the role named in the request?ROLE_HIERARCHY[role].canManage
requirePermission(permission)Does the caller hold this permission?the resolved permission set

requirePermission resolves the caller’s effective set through getCachedPermissions in src/utils/permissionService.js, which delegates to PermissionResolver.resolvePermissions and caches the answer in Redis for 300 seconds under permissions:<userId>:<organizationId>:<outletId>.

requirePermission short circuits in three ways. SUPER_ADMIN is granted Object.values(PERMISSIONS). A normalized OWNER or ADMIN is granted ['*']. Any caller whose set contains '*' passes. Otherwise the test is set membership, and requireAll defaults to true, so a list of permissions means all of them. requireAnyPermission flips that to any of them, and requireAllPermissions keeps all of them.

The same file also exports canAccessOutlet(req, outletId) for use inside a controller.

The rule about hardcoding roles

The workspace root AGENTS.md states the rule under Frontend Critical Rules.

Never hardcode role checks (e.g., if (role === 'ADMIN')). Use the permission system.

docs/FRONTEND_GUIDE.md repeats it. The reason is practical. A role check freezes today’s role layout into the code, and every new role or per user override then needs a code change instead of a permission change.

Both files name useCan(menuKey, action) and PermissionButton as the approved UI guards. Those two names do not exist in the frontend. A search of src for useCan and PermissionButton returns no matches. The real API is usePermission() from src/context/PermissionContext.jsx and the ProtectedRoute component described below. Follow the rule, and use the names that exist.

Backend enforcement

Routes attach guards in the route file. src/routes/tenantRoutes.js is the main tenant router.

router.get('/kitchen/orders', requirePermission('VIEW_KDS'), kitchenController.getKitchenOrders);
router.put('/kitchen/orders/:id/status', requirePermission('UPDATE_KDS_STATUS'), kitchenController.updateKitchenOrderStatus);

src/auth/auth.routes.js builds a shared chain named protectedAuthChain from isVerifiedUser and tenantContextMiddleware, then adds a permission where one is needed.

router.post('/change-password', ...protectedAuthChain, requirePermission('USER_UPDATE'), validate('passwordChange'), AuthErrorHandler.asyncHandler(authController.changePassword));

src/core/rbac/rbacMiddleware.js also exports ready made chains for the common cases.

ChainGuards
tenantAccessauthenticate, resolveOutletContext, withOutletIsolation
protectedRoute(permission)the same, plus a permission check
protectedByRole(roles)the same, plus a role check
outletAdminOnlyOWNER, ADMIN, MANAGER
multiOutletAdminOnlyOWNER, ADMIN, plus blockOutletSwitching
staffAccessthe outlet staff roles plus MANAGER

A failing guard returns 403 and names the missing permission in the message. getUserEffectivePermissions stores the resolved list on req.userPermissions so a controller can reuse it.

Frontend enforcement

src/context/PermissionContext.jsx is the only place that holds permission state in the UI. PermissionProvider fetches /api/me/menu-permissions and exposes the result through usePermission.

MemberMeaning
can(menuKey, action)May the user run this action on this menu?
isMenuAllowed(menuKey)Is the menu visible?
hasPermission(permission)Is this permission string present?
hasAnyPermission(list)Is any of the list present?
hasAllPermissions(list)Is all of the list present?
loadingTrue until the first fetch settles.
refreshPermissions()Fetches again and rewrites the cache.

The provider writes the menu map and the derived permission strings to localStorage under OFFLINE_PERMISSIONS, and reads that cache on the next load so the sidebar can render before the network call returns.

Three owner shortcuts live inside the provider. can, isMenuAllowed and hasPermission all return true when the normalized role is OWNER, ADMIN or SUPER_ADMIN, and the fetch injects the admin menu when an owner lacks it. Those shortcuts are client side only. The server still decides what it is asked about.

Route level enforcement is src/components/shared/ProtectedRoute.jsx. It calls usePermission() and renders FullScreenLoader while loading is true. After that it allows the route when isMenuAllowed(menuKey) is true or can(menuKey, action) is true, and redirects to / when neither holds.

src/App.jsx wraps the admin routes in that component, keyed by menu.

<Route element={<ProtectedRoute menuKey="orders" />}>
<Route element={<ProtectedRoute menuKey="reports" action="accounting" />}>
<Route element={<ProtectedRoute menuKey="customers" action="view_history" />}>

src/config/menuActionsConfig.js defines ALL_MENUS, the list of menu keys and their valid actions, along with getMenuByKey, getMenuByRoute and isValidAction. The backend keeps a matching list in its own src/config/menuActionsConfig.js, and menuPermissionService builds the API response from that copy.

Two things to carry away. Frontend checks are a usability layer, not a security boundary. The permission that matters is the one requirePermission reads on the server, and the menu map in the browser is only a copy of the server’s answer.

Sources

  • AGENTS.md at the workspace root
  • docs/FRONTEND_GUIDE.md at the workspace root
  • brewlabs-billu-backend/src/auth/auth.controller.js
  • brewlabs-billu-backend/src/auth/auth.routes.js
  • brewlabs-billu-backend/src/config/corsConfig.js
  • brewlabs-billu-backend/src/config/menuActionsConfig.js
  • brewlabs-billu-backend/src/constants/permissions.js
  • brewlabs-billu-backend/src/control_plane_models/userModel.js
  • brewlabs-billu-backend/src/controllers/menuPermissionController.js
  • brewlabs-billu-backend/src/core/auth/tokenService.js
  • brewlabs-billu-backend/src/core/auth/unifiedAuthMiddleware.js
  • brewlabs-billu-backend/src/core/middleware/tokenVerification.js
  • brewlabs-billu-backend/src/core/rbac/rbacMiddleware.js
  • brewlabs-billu-backend/src/core/tenant/tenantMiddleware.js
  • brewlabs-billu-backend/src/core/tenant/unifiedTenantMiddleware.js
  • brewlabs-billu-backend/src/middlewares/authValidation.js
  • brewlabs-billu-backend/src/migrations/legacy/20250415_permission_overrides_and_hierarchy.sql
  • brewlabs-billu-backend/src/migrations/tenant/002_rbac_tables.js
  • brewlabs-billu-backend/src/modules/permissions/menuPermissionService.js
  • brewlabs-billu-backend/src/routes/permissionRoutes.js
  • brewlabs-billu-backend/src/routes/tenantRoutes.js
  • brewlabs-billu-backend/src/services/authService.js
  • brewlabs-billu-backend/src/utils/constants.js
  • brewlabs-billu-backend/src/utils/entityScopeRegistry.js
  • brewlabs-billu-backend/src/utils/permissionService.js
  • brewlabs-billu-frontend/src/App.jsx
  • brewlabs-billu-frontend/src/auth/AuthContext.jsx
  • brewlabs-billu-frontend/src/components/shared/ProtectedRoute.jsx
  • brewlabs-billu-frontend/src/config/menuActionsConfig.js
  • brewlabs-billu-frontend/src/context/PermissionContext.jsx
  • brewlabs-billu-frontend/src/https/axiosWrapper.js
  • brewlabs-billu-frontend/src/redux/slices/userSlice.js