Permissions & Access Control — RBAC, CASL & client/master gates
The whole authorization model reduces to one row: a
Permission(a.k.a.permission_role) document grants one(group, module, action)triple. A user belongs to one access group; that group's set of permission rows is their ability set. Every guarded resolver declares the(module, action)it needs; at request time a CASL ability is built from the user's group and the resolver throwsForbiddenunless a matchingPermissionrow exists.SuperAdminshort-circuits tocan(manage, all).
Source: BE src/modules/permission (+ src/modules/master-access) · Admin src/modules/permission (+ src/modules/master-access) · enforcement entry src/modules/auth/decorators/gql-auth.decorator.ts
Related platform docs: auth.md (JWT/OTP/session that populates req.user), multi-tenancy.md (the companyId/branchId every row is scoped by), audit-trail.md (@AuditMeta on every mutation here). Domain entry point: ../domains/user-access/_overview.md.
1. Purpose & scope
This module is the authorization spine for the whole ERP. It owns:
- Permission modules (
ApModules) — the ~75 protectable feature areas (sales-invoice,inventory,journal-entries, …). - Permission actions — the verbs allowed inside each module (
view,create,approve,void,edit-posted, …). Modules + actions are seeded per company. - Access groups (roles) — named buckets a user is assigned to (
Admin,Salesman, …). - Permission rows (
permission_roles) — the grant table: one row = "this group may do this action in this module". - CASL ability building — turning a user → group → permission rows into a
PureAbilitychecked at the resolver. - The resolver enforcement decorator/guards —
@ApGqlAuthorize({ permission })→GqlRolesGuard→GqlPermissionGuard→PermissionFactory. - Client modules — a per-tenant on/off switch for whole feature areas, independent of RBAC.
- Master access — a single static "platform key" gate used by the admin to unlock platform-level setup screens.
It explicitly does not own: authentication / JWT issuance (see auth.md), the subscription feature-flag layer (@RequireFeature / hasFeature, a separate gate that runs before RBAC — see the manufacturing flow in §6.4), or business-rule validation inside services.
Three independent gates stack on a request, in this order:
1. Authentication — is there a valid JWT? (auth module, GqlAuthGuard)
2. Client module gate — is this feature area switched on for the tenant? (ClientModule + SsrGlobal)
Subscription feature gate — does the plan include this feature? (subscription module, separate)
3. RBAC / CASL — does the user's group grant (module, action)? (THIS doc)
2. Data model
Five collections, all extending BaseSchema (so all carry _id, ref, companyId, branchId, createdAt/By, updatedAt/By) and all soft-deleted via mongoose-delete (deletedAt). Everything is tenant-scoped by companyId — modules, actions, groups and grants are seeded and queried per company.
2.1 permission_modules — the protectable feature areas
permission/module/module.schema.ts. One row per ApModules value, per company.
| field | type | required | description |
|---|---|---|---|
module |
string | ✅ | machine key, e.g. sales-invoice (matches an ApModules value) |
name |
string | – | display name (seeded equal to module) |
companyId |
ObjectId | (BaseSchema) | tenant scope |
2.2 permission_actions — the verbs inside a module
permission/action/action.schema.ts. One row per allowed action per module per company.
| field | type | required | description |
|---|---|---|---|
moduleId |
ObjectId | ✅ | FK → permission_modules |
action |
string | ✅ | machine key, e.g. approve, edit-posted |
name |
string | – | display name (seeded equal to action) |
module |
string (transient) | – | not persisted; populated by $lookupModule |
hasPermission |
boolean (transient, DTO) | – | computed per-group at query time (see §3) |
2.3 permission_groups — access groups (roles)
permission/group/group.schema.ts, GraphQL type AccessGroup.
| field | type | required | description |
|---|---|---|---|
group |
string | ✅ | role name, unique per company (e.g. Admin) |
branchId |
ObjectId | – | optional branch scope |
usersCount |
number (resolve-field) | – | live count of users in this group (group.resolver.ts) |
2.4 permission_roles — the grant table (the heart)
permission/permission.schema.ts. This is the actual permission. One row = "group X may perform action Y (in module Z)". Presence of a row = granted; absence = denied.
| field | type | required | description |
|---|---|---|---|
groupId |
ObjectId | ✅ (indexed) | FK → permission_groups — who |
moduleId |
ObjectId | ✅ (indexed) | FK → permission_modules — where |
actionId |
ObjectId | ✅ (indexed) | FK → permission_actions — what |
branchId |
ObjectId | – | optional branch scope |
action |
string | PermissionAction (transient) | – | not persisted; joined via $lookupActions |
module |
string (transient) | – | not persisted; joined via $lookupModules |
Indexes (hot paths, permission.schema.ts):
PermissionSchema.index({ groupId: 1, actionId: 1, companyId: 1 }); // single-permission lookup
PermissionSchema.index({ groupId: 1, companyId: 1, actionId: 1 }); // bulk fetch all grants for a groupThe repo joins the action/module documents in via aggregation so callers can query by the string action/module rather than by id:
// permission.schema.ts
export const $lookupActions = [{ $lookup: { from: "permission_actions", localField: "actionId", foreignField: "_id", as: "action" }}, { $unwind: {path:"$action", preserveNullAndEmptyArrays:true} }];
export const $lookupModules = [{ $lookup: { from: "permission_modules", localField: "moduleId", foreignField: "_id", as: "module" }}, ...];
export const $commonLookup = [...$lookupActions, ...$lookupModules];PermissionRepository.findOne runs a pre-lookup $match on the direct fields (groupId, companyId, …) before the expensive $lookup, then a post-lookup $match for the joined action.action / module.module string filters. This is what lets PermissionService.haveAccess / PermissionFactory query { groupId, module: "sales-invoice", action: "approve" }.
2.5 permission_client_modules — per-tenant feature on/off
permission/client/client.schema.ts, GraphQL type ClientModule. Independent of groups; toggles whole modules for a tenant (client).
| field | type | required | description |
|---|---|---|---|
module |
string | ✅ | feature key |
client |
string | ✅ | tenant identifier (in practice the company id — see §6.5) |
hasAccess |
boolean | ✅ | on/off for that tenant |
2.6 master-access — no collection (static key)
master-access/master-access.constants.ts defines a single hardcoded constant; there is no DB collection:
export const MASTER_ACCESS_KEY = "ZYN-MASTER-2026-SECURE-ACCESS-KEY";MasterAccessService.validateMasterId(id) returns id === MASTER_ACCESS_KEY. That's the entire model.
2.7 User → group linkage (defined in the user/HR modules)
user/user.schema.ts:
export enum UserKindTypes { Admin, SuperAdmin, Company, StoreAdmin, Staff, Customer, Supplier }
export enum UserRoleTypes { Admin, SuperAdmin, Customer, StoreAdmin, Staff, Employee, SalesMan, Supplier }
// UserEntity:
groupId: Types.ObjectId; // FK → permission_groups (the user's access group)
activeBranchId / branchIds: ...; // branch context, used by branchIdRequired guardWhere the group id comes from depends on kind (permission.factory.ts → resolveGroupId):
kind === Staff→ the group lives on the Employee record (employeeSvc.findOne({ userId }).groupId), not on the user.- every other kind →
user.groupIddirectly. kind === SuperAdmin→ group is irrelevant; ability ismanage all.
3. The real enums (verbatim)
3.1 RoleActions — the canonical action verbs
permission/permission.enum.ts. These are the generic verbs; note the per-module seed (§3.3) adds many more granular action strings beyond these.
export enum RoleActions {
CREATE = "create",
UPDATE = "update",
READ = "read",
MANAGE = "manage", // CASL wildcard verb — SuperAdmin gets manage/all
DELETE = "delete",
EDIT_POSTED = "edit-posted", // accounting: amend a POSTED document
POST_LOCKED_PERIOD = "post-to-locked-period"
}3.2 ApModules — every protectable module key (verbatim)
permission/permission.enum.ts — the authoritative module list (the admin mirrors these in src/constants/UserAccess.ts):
export enum ApModules {
DASHBOARD = "dashboard", SHORTCUTS = "shortcuts", CUSTOMERS = "customers",
SALES_QUOTATIONS = "sales-quotations", SALES_ORDER = "sales-order", SALES_INVOICE = "sales-invoice",
VENDORS = "vendors", PURCHASE_REQUISITION = "purchase-requisition", PURCHASE_ORDER = "purchase-order",
PURCHASE_INVOICE = "purchase-invoice", INVENTORY = "inventory", CASHBOOK = "cashbook",
GL_ACCOUNTS = "gl-accounts", ACCOUNT_CATEGORY = "account-category", PAYMENT_ENTRIES = "payment-entries",
JOURNAL_ENTRIES = "journal-entries", NOTE_ENTRIES = "note-entries", ASSETS = "assets",
WORKFLOW = "workflow", EXCHANGES = "exchanges", COMPANY_FINANCIALS = "company-financials",
INVENTORY_REPORT = "inventory-report", AR_REPORT = "ar-report", AP_REPORT = "ap-report",
ASSET_REPORT = "asset-report", USER_MAINTENANCE = "user-maintenance", STORE_MAINTENANCE = "store-maintenance",
TAX_MAINTENANCE = "tax-maintenance", COMPANY = "company", SETTINGS = "settings", HR_SETTINGS = "hr-settings",
WORK_CENTER = "work-center", ROUTING = "routing", MANUFACTURING_BOM = "manufacturing-bom",
PRODUCTION_ORDER = "production-order", MRP = "mrp", QUALITY_CONTROL = "quality-control",
MANUFACTURING_DASHBOARD = "manufacturing-dashboard", MANUFACTURING_REPORT = "manufacturing-report",
PROJECT_MANAGEMENT = "project-management", PROJECT_TASKS = "project-tasks",
PROJECT_TIME_ENTRIES = "project-time-entries", PROJECT_MILESTONES = "project-milestones",
PROJECT_MEMBERS = "project-members", PROJECT_EXPENSES = "project-expenses", PROJECT_REPORTS = "project-reports",
BOM = "bom", TRADE_ENTRIES = "trade-entries", CONTRA_ENTRIES = "contra-entries", BUDGET = "budget",
LEAVES = "leaves", LEAVE_GROUPS = "leave-groups", HR_CALENDAR = "hr-calendar", HR_APPROVALS = "hr-approvals",
HR_APPROVAL_POLICIES = "hr-approval-policies", CLAIMS = "claims", ADVANCES = "advances", LOANS = "loans",
ATTENDANCES = "attendances", HR_TIMESHEETS = "hr-timesheets", HR_TIMETABLES = "hr-timetables",
HR_SHIFTS = "hr-shifts", HR_ATTENDANCE_GROUPS = "hr-attendance-groups", JOB_POSTINGS = "job-postings",
JOB_APPLICANTS = "job-applicants", INTERVIEW_SCHEDULES = "interview-schedules", JOB_OFFERS = "job-offers",
TRAINING = "training", ZOOM = "zoom-meetings"
}3.3 The seeded module → actions matrix (the real action vocabulary)
PermissionActionService.seed() (permission/action/action.service.ts) defines the authoritative per-module action list. The actions are not limited to RoleActions; each module has its own granular verbs. Selected, verbatim from the seed:
| Module | Seeded actions |
|---|---|
dashboard |
view, view-statistics, view-sales-by-employee, view-sales-analysis, view-top-customers, view-top-selling-items, view-top-invoices, view-top-selling-categories, view-top-profitable-items, view-top-profitable-categories |
customers |
view, create, view-customer-details, update, delete, import-customers, export-customers |
sales-quotations |
view, create, view-details, update, delete, submit, post, approve, reject, cancel, convert-to-order, duplicate, print, export |
sales-order |
…same + close, convert-to-invoice, receive-payment |
sales-invoice |
view, create, view-details, update, delete, approve, void, cancel, add-payment, add-item, return-item, print, import, export |
purchase-requisition / purchase-order / purchase-invoice |
mirror the sales counterparts (receive-goods on PO) |
inventory |
view + Items (view-items, create-item, view-item-details, update-item, delete-item, import-items, export-items), Item Groups/Categories/Types CRUD, Stock Transfers CRUD, Stock Adjustments CRUD, plus cross-app create, adjust, transfer, approve |
cashbook |
view, view-entries, create-entry, view-details, update, delete, edit-posted, post-to-locked-period, reconcile, import-entries, export-entries |
journal-entries |
view, create, view-details, update, delete, edit-posted, post-to-locked-period, approve, void, reverse, import-journals, export-journals |
note-entries |
view, create-debit-note, create-credit-note, view-details, update, delete, edit-posted, post-to-locked-period, approve, void, import-notes, export-notes |
trade-entries / contra-entries |
view, create, view-details, update, delete, edit-posted, post-to-locked-period, approve, void, print, export |
payment-entries |
view, create, view-details, update, delete, void, print, export |
gl-accounts |
view, create, view-details, update, delete, import-accounts, export-accounts |
assets |
view, create, view-asset-details, update, delete, depreciate-asset, dispose-asset, sell-asset, revalue-asset, view-disposed-asset, import-assets, export-assets |
budget |
view, create, view-details, update, delete, approve, reject |
workflow |
view, create-workflow, view-workflow-details, update, delete, create-stage, view-approval, create-approval, view-approval-details, view-my-task, view-workflow-task-details, approve-task, reject-task |
company-financials |
view, view-profit-loss, view-balance-sheet, view-cashflow, view-trial-balance, view-general-ledger, export-reports |
inventory-report / ar-report / ap-report / asset-report |
per-report view-* actions + export-reports |
user-maintenance |
view + Users (create-user, view-user-details, view-user-access, update-user, delete-user, reset-user-password, import-users), Employees CRUD, Departments CRUD, Access Groups (view-access-groups, create-access-group, update-access-group, delete-access-group, assign-permissions) |
company |
view, create-company, view-company-details, update-company, delete-company, switch-company, update-accounting-period, manage-fiscal-year, manage-modules |
settings |
view, update-general, update-notifications, update-integrations, manage-api-keys |
Manufacturing (work-center, routing, manufacturing-bom, production-order, mrp, quality-control, manufacturing-dashboard, manufacturing-report) |
activation/lifecycle verbs — e.g. production-order: start-production, pause-production, resume-production, complete-production, cancel-production, issue-materials, receive-output, quality-check |
Projects (project-management, project-tasks, project-time-entries, project-milestones, project-members, project-expenses, project-reports) |
CRUD + domain verbs (close-project, assign-manager, log-time, approve-time, mark-completed, …) |
HR (leaves, claims, advances, loans) |
view, create, cancel |
HR (hr-approvals) |
view, action |
HR (hr-timesheets) |
view, create, update, delete, submit, approve |
Recruitment (job-postings) |
view, create, update, delete, open, close, mark_filled |
Recruitment (job-applicants) |
view, create, read, move_to_interview, make_offer, reject |
Recruitment (interview-schedules) |
view, create, read, complete, cancel, mark_no_show |
Recruitment (job-offers) |
view, create, read, send, accept, reject |
training |
view, write, delete |
zoom-meetings |
view, read, create, update, delete |
The full list lives in
action.service.ts— treat that file as the source of truth for the exact action set of every module.
3.4 CheckActionTypes (bulk grant control)
permission/permission.interface.ts:
export enum CheckActionTypes { CHECK_ALL = "CHECK_ALL", UNCHECK_ALL = "UNCHECK_ALL" }4. How a user gets abilities (the resolution path)
Request (JWT) ──▶ req.user ─────────────────────────────────────────────────┐
│
@ApGqlAuthorize({ permission:{action,subject} }) sets metadata CHECK_PERMISSION
│
GqlRolesGuard.canActivate │
├─ authGuard.canActivate (auth: validates JWT → req.user) │
├─ branchIdCheck (if branchIdRequired & no activeBranchId → throw)
├─ permissionCheck ─────────────▶ GqlPermissionGuard.checkPermission(rule, user)
│ └▶ PermissionFactory.definePermission({user, subject, action})
│ 1. SuperAdmin? → can(MANAGE, "all") ✅ allow everything
│ 2. resolveGroupId(user):
│ Staff → employee.groupId ; else → user.groupId
│ 3. no groupId → cannot(action,"all") ❌
│ 4. permissionSvc.findOne({ module:subject, action, groupId })
│ row found → can(action,"all") ✅
│ no row → cannot(action,"all")❌
│ 5. build() → CASL PureAbility
│ └▶ ForbiddenError.from(ability).throwUnlessCan(action, subject)
│ throws ForbiddenException if denied
├─ updateUserContext (stamps ignoreCompanyQuery / includeBranchQuery on context)
├─ checkCompanyArchiveStatus (company ARCHIVED → throw, unless SuperAdmin/authNotRequired)
└─ resolveEmployeeId (best-effort: set contextSvc.employeeId from employees collection)
GqlClientGuard runs alongside GqlRolesGuard (both listed in @ApGqlAuthorize) but only stamps contextSvc.client = req.headers.host and the x-audit flag — it does not enforce client modules itself (that gate is enforced admin-side, §6.5).
5. CASL ability building (the factory)
permission/permission.factory.ts builds a fresh PureAbility per request, per (subject, action) — it is not a cached, fully-materialised ability. The "subject" is "all" for every grant; granularity comes entirely from matching the right Permission row, not from CASL subject types.
export type ApPermission = PureAbility<[RoleActions | string, Subjects | string]>;
async definePermission({ user, subject, action }) {
const { can, cannot, build } = new AbilityBuilder(PureAbility);
// 1. SuperAdmin → unconditional
if (user?.kind === UserKindTypes.SuperAdmin) {
can(RoleActions.MANAGE, "all");
return build({ detectSubjectType: i => i.constructor });
}
// 2. find the user's group (Staff → employee.groupId, else user.groupId)
const groupId = await this.resolveGroupId(user);
if (!groupId) {
cannot(action, "all").because("You are not permitted to perform this action");
return build(...);
}
// 3. the ONE lookup that decides everything
const permission = await this.permissionSvc.findOne({ module: subject, action, groupId });
if (permission?.action) can(action, "all");
else cannot(action, "all").because("You are not permitted to perform this action");
return build({ detectSubjectType: i => i.constructor });
}Key implications for a rebuild:
- Grant = existence of a
permission_rolesrow for{ groupId, moduleId(action.module === subject), actionId(action.action === action) }. There are no deny rows; absence is denial. MANAGEis CASL's wildcard verb, used only for SuperAdmin (managematches any action onall).- The ability is built around the specific
(action, "all")the resolver asked for, sothrowUnlessCan(action, subject)succeeds becausecan(action, "all")covers any subject. Thesubjectstring (ApModulesvalue) is meaningful only as themodulefilter in the DB lookup, not as a CASL subject. Subjects = InferSubjects<typeof User> | "all"— declared but effectively unused beyond"all".
6. API surface & flows
6.1 GraphQL operations (from src/schema.gql)
| Operation | Type | Input | Returns | Resolver / auth |
|---|---|---|---|---|
findPermission |
Query | QueryPermissionInput { groupId, moduleId, actionId } |
[Permission] |
permission.resolver.ts, @ApGqlAuthorize({ ignoreCompanyQuery:false }) |
haveAccess |
Query | HaveAccessInput { userId?, module!, action! } |
Boolean |
resolves userId from current user if omitted |
addOrUpdatePermission |
Mutation | CreatePermissionInput { groupId!, moduleId!, actionId! } |
Boolean |
toggles one grant (save()), @AuditMeta |
updateAllPermission |
Mutation | UpdateAllInput { groupId!, action: CheckActionTypes!, moduleId? } |
Boolean |
bulk check/uncheck (updateAll()) |
findAccessGroups |
Query | AccessGroupQueryInput { group? } |
[AccessGroup] |
group.resolver.ts |
createAccessGroup |
Mutation | CreateAccessGroupInput { group! } |
AccessGroup |
@AuditMeta |
updateAccessGroup |
Mutation | _id, UpdateAccessGroupInput |
Boolean |
@ApGqlAuthorize({ permission:{ action:"update", subject:"group" }}) |
deleteAccessGroup |
Mutation | _id |
Boolean |
@ApGqlAuthorize({ permission:{ action:"delete", subject:"group" }}) |
findPermissionModules |
Query | PermissionModuleQueryInput { groupId!, … } |
PermissionModuleResult { groupId, data:[PermissionModule] } |
per-group module+action grid with hasPermission flags |
findUserAccess |
Query | – | PermissionModuleResult |
the current user's effective module/action grid (drives the admin haveAccess() UI gate) |
findPermissionActions |
Query | PermissionActionQueryInput { moduleId?, action?, name? } |
[PermissionAction] |
|
createPermissionAction / updatePermissionAction |
Mutation | Create/UpdatePermissionActionInput |
PermissionAction / Boolean |
|
createPermissionModule / updatePermissionModule |
Mutation | Create/UpdatePermissionModuleInput |
PermissionModule / Boolean |
|
getClientModules |
Query | – | [ClientModule] |
@ApGqlAuthorize({ authNotRequired:true }) — used by admin guard |
clientModulePage |
Query | ClientModulePageInput |
ClientModulePageResult |
|
findOneClientModule |
Query | ClientModuleQueryInput { keyword?, client? } |
ClientModule |
|
createClientModule / updateClientModule / deleteClientModule |
Mutation | Create/Update…Input / _id |
ClientModule / Boolean |
@AuditMeta |
validateMasterAccess |
Query | ValidateMasterAccessInput { masterId! } |
MasterAccessValidationResult { isValid } |
@ApGqlAuthorize({ authNotRequired:true, ignoreCompanyQuery:true }) |
Verbatim key types (src/schema.gql):
type Permission { _id role:UserRoleTypes! moduleId:String! actionId:String! branchId:String! module:PermissionModule action:PermissionAction ... }
type PermissionModule { _id module:String! actions:[PermissionAction!] name:String ... }
type PermissionAction { _id moduleId:String! action:String name:String hasPermission:Boolean module:PermissionModule ... }
type AccessGroup { _id! group:String branchId:String! usersCount:Float! module:PermissionModule! ... }
type ClientModule { _id module:String! client:String! hasAccess:Boolean! ... }
input CreatePermissionInput { groupId:String! moduleId:String! actionId:String! }
input UpdateAllInput { groupId:String! action:CheckActionTypes! moduleId:String }
input HaveAccessInput { userId:String module:String! action:String! }6.2 Granting a single permission — addOrUpdatePermission (toggle)
PermissionService.save() is a toggle, not an upsert:
async save(model) {
const exist = await this.permissionRepo.findOne({ actionId, moduleId, groupId });
if (exist) await this.permissionRepo.delete(exist._id); // already granted → revoke
else await this.permissionRepo.create(model); // not granted → grant
}- Admin opens Permissions tab, picks a group, expands a module, flips an action switch.
PermissionRowContent→addOrUpdatePermission({ actionId, moduleId, groupId }).- The context optimistically flips
hasPermissionlocally, then calls the mutation. - BE toggles the
permission_rolesrow; on error the admin shows a toast (no rollback of the optimistic flip — a known UX gotcha, §9).
6.3 Bulk grant — updateAllPermission (Check/Uncheck All)
PermissionService.updateAll():
UNCHECK_ALL→deleteMany({ groupId, moduleId? })(whole group, or one module ifmoduleIdgiven).CHECK_ALL→ fetch all actions (for the module, or the whole company), fetch already-granted action ids, thenbulkInsertPermissions()only the missing ones (idempotent; uses a rawinsertManythat skips the per-rowref/documentCodeoverhead).
6.4 Enforcing on a domain resolver (the live pattern)
The actual enforcement pattern across zerp-be is the inline permission option on @ApGqlAuthorize, e.g. hr/training/training.resolver.ts:
@ApGqlAuthorize() // class-level: auth + client guard, no permission
@Resolver(() => Training)
export class TrainingResolver extends ApBaseResolver<Training> {
@ApGqlAuthorize({ permission: { action: "write", subject: "training" } }) // method-level
@Mutation(() => Training, { name: "createTraining" })
create(...) { ... }
}At runtime @ApGqlAuthorize({ permission }) sets the CHECK_PERMISSION metadata, which GqlRolesGuard.permissionCheck reads and passes to GqlPermissionGuard.checkPermission (§4). So a user may call createTraining only if their group has a permission_roles row for module training + action write.
Note on
@ApGqlPermission/@ApGqlFeature: the standalone@ApGqlPermission(...)decorator exists inpermission/decorators/permission.decorator.ts(it sets the sameCHECK_PERMISSIONmetadata and appliesGqlPermissionGuarddirectly), and the manufacturing access-control plan (docs/superpowers/plans/2026-04-09-manufacturing-access-control.md) describes layering a separate subscription feature gate (@ApGqlFeature('MANUFACTURING')) before RBAC. In the current code the inline@ApGqlAuthorize({ permission })form is the one wired up across modules;@ApGqlPermissionis defined but not imported elsewhere. Treat the subscription feature flag as a separate gate from this RBAC model.
6.5 Client-module gate (per-tenant feature on/off)
Enforced on the admin side, not in the BE permission guard:
ApGuardBuilder.fetchClientModules()(zerp-admin/src/guard.tsx) callsgetClientModulesAsync(token)→getClientModulesquery → BE returnsfind({ client: contextSvc.companyId, ignoreCompanyId: true }).- Results are cached on the
ApSsrGlobalsingleton (globalSsr.modules). - The admin permission context's
checkModuleAccess(module)(permission/context.tsx) looks up the module inApSsrGlobal.modules; if a row exists withhasAccess === false,haveAccess/haveViewAccessreturnnull→ the UI element is hidden. Absent row ⇒ allowed (fail-open).
6.6 Master-access gate (platform setup unlock)
zerp-admin setup pages (/setup/plans, /setup/features, /setup/subscriptions, /setup/plan-mapping, /setup/plan-settings, select-company) call validateMasterAccessAsync(masterId, token) (master-access/query.ts) → validateMasterAccess query → BE compares against the static MASTER_ACCESS_KEY. true unlocks the platform-level screens. Pure string equality; no roles, no DB.
6.7 Seeding (bootstrapping a tenant's RBAC)
PermissionService.seed() (called per company at setup):
accessGroupSvc.seed()→ creates default groups["Admin", "Salesman", "Marketing", "Customer", "Supplier"](only if the company has none).actionSvc.seed()→ ensures everyApModulesmodule row exists, then bulk-inserts every seeded module→action pair that's missing (idempotent; 4 queries total).- Finds the
Admingroup and callsupdateAll({ action: CHECK_ALL, groupId })→ Admin gets every permission by default.
7. Admin UI
Page: the permission management lives under the admin Permissions screen (zerp-admin/src/modules/permission/layout.tsx) with two tabs: Permission (page.tsx) and Groups (group/page.tsx). User listing is a separate SuperAdmin-only page src/pages/admin-users.tsx (employee table; redirects non-SuperAdmin to /).
Permission tab (modules/permission/page.tsx):
- Group dropdown (from
useAccessGroupState().accessGroups); selecting a group setsfilter.groupId→findPermissionModules({ groupId }). - An expandable
ApTable: each row = a module; expanded content (components/rowContent.tsx) = a grid ofApSwitchInputtoggles, one per action, bound toaction.hasPermission. - Per-module "Check/Uncheck All" switch and global Check All / Uncheck All buttons →
updateAllPermision({ groupId, action: CHECK_ALL|UNCHECK_ALL, moduleId? }).
Groups tab (modules/permission/group/page.tsx):
ApTableof groups with edit/delete; New Group modal (group/components/create.tsx) is a Formik form,Yup.object({ group: required })→createAccessGroup/updateAccessGroup.
Context methods (the only consumers of the gql layer, per the zync-nextjs standard):
PermissionContextProvider(context.tsx):findPermissionModules,addOrUpdatePermission(optimistic toggle),updateAllPermision,findUserAccess, and the client-side gateshaveAccess(module, component, action='view')/haveViewAccess(...)used throughout the app to conditionally render.findUserAccessis auto-run on session/company change and mapsdatainto{ ..., children: actions }for the gate lookup.AccessGroupContextProvider(group/context.tsx):findAccessGroups,createAccessGroup,updateAccessGroup,deleteAccessGroup— each refetches the list after a mutation.
GraphQL ops (gql/query.ts): FIND_PERMISSION_MODULES, findUserAccess, ADD_OR_UPDATE_PERMISSION, UPDATE_ALL_PERMISSIONS, HAVE_ACCESS, GET_CLIENT_MODULES; group ops in group/gql/query.ts. Module/action shapes come from gql/fragment.ts (PermissionModuleFragment → nested PermissionActionFragment carrying hasPermission).
USER_ACCESS constants (zerp-admin/src/constants/UserAccess.ts, ~595 lines): a typed mirror of every ApModules module + its action strings (USER_ACCESS.SALES_INVOICE.ACTIONS.APPROVE), used by nav config (components/navbar/config.tsx) and SSR route guards so module/action strings are never hand-typed in the UI.
8. Dependencies & integrations
- auth (auth.md) —
@ApGqlAuthorizeand the whole guard chain live here; providesreq.user(id,kind,groupId,activeBranchId).PermissionModuleimportsAuthModule. - user + hr/employee —
PermissionFactory.resolveGroupIdandPermissionService.haveAccessresolve the group from the user (non-staff) or the employee record (staff).GqlRolesGuard.resolveEmployeeIdback-fillscontextSvc.employeeId. - company —
GqlRolesGuard.checkCompanyArchiveStatusblocks all access to anARCHIVEDcompany (except SuperAdmin /authNotRequired). See multi-tenancy.md. - subscription — a separate feature-flag gate (
@RequireFeature/hasFeature) that runs before RBAC for plan-gated modules (manufacturing etc.). Not part of this module. - audit-trail (audit-trail.md) — every permission/group/module/action/client mutation carries
@AuditMeta({ module, collection, snapshots }). - context (
ApContextService, AsyncLocalStorage) — carriescompanyId/branchId/employeeIdthat scope every permission query.
9. Gotchas & project-specific rules
- No deny rows. A permission is purely the presence of a
permission_rolesrow. To revoke, you delete the row (addOrUpdatePermissionis a toggle;updateAll(UNCHECK_ALL)deletes in bulk). Soft-deleted rows are excluded fromfindOne(the guard sees them as revoked). - SuperAdmin bypasses everything —
PermissionFactoryandhaveAccessboth short-circuit onkind === SuperAdminbefore any DB lookup;checkCompanyArchiveStatusalso exempts SuperAdmin. - Group source differs by user kind. Staff users carry their group on the Employee record, not the user — a Staff user with no employee record (or no
employee.groupId) resolves to no group → denied (a warning is logged). Everyone else usesuser.groupId. - No group ⇒ denied.
PermissionFactoryissuescannot(action,"all")whenresolveGroupIdreturns nothing. subjectis the module string, not a class. Despite CASL's subject machinery, granularity is 100% in the DB row match (module === subject && action === action); the CASL grant is always(action, "all").- CASL ability is per-request, per-rule —
definePermissionis called once per guarded handler with that handler's single(subject, action). It is not a fully-materialised user ability; don't assume you can introspect a user's full permission set from it (usefindUserAccess/findUserAccessAggregatedfor that). findUserAccessAggregateddeliberately avoids deep$lookup— it runs three lean queries (modules, actions, granted action ids) and joins in memory to dodge the 16 MB BSON limit and keep it to 2–3 DB calls (permission.repository.ts).- Admin gates fail-open during load.
haveAccessreturns the component (allowed) whileuserAccessLoaded === false, andcheckModuleAccessallows a module that's absent fromApSsrGlobal.modules. So a slow/missing client-modules fetch shows UI rather than hiding it. - Optimistic toggle isn't rolled back on error.
addOrUpdatePermissionflips local state before the mutation and only toasts on failure — the switch can briefly disagree with the server until the nextfindPermissionModules. - Master access key is hardcoded (
ZYN-MASTER-2026-SECURE-ACCESS-KEY) and compared by plain equality — it is a static platform unlock, not a per-user credential. Rotating it is a code change. - Client identifier ambiguity.
getClientModules/findClientModulesfilter byclient: contextSvc.companyId(a commented-out line shows it was previouslycontextSvc.client= request host). For a port, decide explicitly whetherClientModule.clientis the company id or the hostname. - Two parallel "module" enums.
ApModules(BE) andUSER_ACCESS(admin) must be kept in lockstep; the action strings also live inaction.service.ts's seed. Adding a module/action means editing the enum, the seed, and the adminUSER_ACCESSconstants.