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 throws Forbidden unless a matching Permission row exists. SuperAdmin short-circuits to can(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 PureAbility checked at the resolver.
  • The resolver enforcement decorator/guards@ApGqlAuthorize({ permission })GqlRolesGuardGqlPermissionGuardPermissionFactory.
  • 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_groupswho
moduleId ObjectId ✅ (indexed) FK → permission_moduleswhere
actionId ObjectId ✅ (indexed) FK → permission_actionswhat
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 group

The 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 guard

Where the group id comes from depends on kind (permission.factory.tsresolveGroupId):

  • kind === Staff → the group lives on the Employee record (employeeSvc.findOne({ userId }).groupId), not on the user.
  • every other kind → user.groupId directly.
  • kind === SuperAdmin → group is irrelevant; ability is manage 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_roles row for { groupId, moduleId(action.module === subject), actionId(action.action === action) }. There are no deny rows; absence is denial.
  • MANAGE is CASL's wildcard verb, used only for SuperAdmin (manage matches any action on all).
  • The ability is built around the specific (action, "all") the resolver asked for, so throwUnlessCan(action, subject) succeeds because can(action, "all") covers any subject. The subject string (ApModules value) is meaningful only as the module filter 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
}
  1. Admin opens Permissions tab, picks a group, expands a module, flips an action switch.
  2. PermissionRowContentaddOrUpdatePermission({ actionId, moduleId, groupId }).
  3. The context optimistically flips hasPermission locally, then calls the mutation.
  4. BE toggles the permission_roles row; 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_ALLdeleteMany({ groupId, moduleId? }) (whole group, or one module if moduleId given).
  • CHECK_ALL → fetch all actions (for the module, or the whole company), fetch already-granted action ids, then bulkInsertPermissions() only the missing ones (idempotent; uses a raw insertMany that skips the per-row ref/documentCode overhead).

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 in permission/decorators/permission.decorator.ts (it sets the same CHECK_PERMISSION metadata and applies GqlPermissionGuard directly), 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; @ApGqlPermission is 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:

  1. ApGuardBuilder.fetchClientModules() (zerp-admin/src/guard.tsx) calls getClientModulesAsync(token)getClientModules query → BE returns find({ client: contextSvc.companyId, ignoreCompanyId: true }).
  2. Results are cached on the ApSsrGlobal singleton (globalSsr.modules).
  3. The admin permission context's checkModuleAccess(module) (permission/context.tsx) looks up the module in ApSsrGlobal.modules; if a row exists with hasAccess === false, haveAccess/haveViewAccess return null → 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):

  1. accessGroupSvc.seed() → creates default groups ["Admin", "Salesman", "Marketing", "Customer", "Supplier"] (only if the company has none).
  2. actionSvc.seed() → ensures every ApModules module row exists, then bulk-inserts every seeded module→action pair that's missing (idempotent; 4 queries total).
  3. Finds the Admin group and calls updateAll({ 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 sets filter.groupIdfindPermissionModules({ groupId }).
  • An expandable ApTable: each row = a module; expanded content (components/rowContent.tsx) = a grid of ApSwitchInput toggles, one per action, bound to action.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):

  • ApTable of 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 gates haveAccess(module, component, action='view') / haveViewAccess(...) used throughout the app to conditionally render. findUserAccess is auto-run on session/company change and maps data into { ..., 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) — @ApGqlAuthorize and the whole guard chain live here; provides req.user (id, kind, groupId, activeBranchId). PermissionModule imports AuthModule.
  • user + hr/employeePermissionFactory.resolveGroupId and PermissionService.haveAccess resolve the group from the user (non-staff) or the employee record (staff). GqlRolesGuard.resolveEmployeeId back-fills contextSvc.employeeId.
  • companyGqlRolesGuard.checkCompanyArchiveStatus blocks all access to an ARCHIVED company (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) — carries companyId/branchId/employeeId that scope every permission query.

9. Gotchas & project-specific rules

  • No deny rows. A permission is purely the presence of a permission_roles row. To revoke, you delete the row (addOrUpdatePermission is a toggle; updateAll(UNCHECK_ALL) deletes in bulk). Soft-deleted rows are excluded from findOne (the guard sees them as revoked).
  • SuperAdmin bypasses everythingPermissionFactory and haveAccess both short-circuit on kind === SuperAdmin before any DB lookup; checkCompanyArchiveStatus also 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 uses user.groupId.
  • No group ⇒ denied. PermissionFactory issues cannot(action,"all") when resolveGroupId returns nothing.
  • subject is 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-ruledefinePermission is 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 (use findUserAccess / findUserAccessAggregated for that).
  • findUserAccessAggregated deliberately 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. haveAccess returns the component (allowed) while userAccessLoaded === false, and checkModuleAccess allows a module that's absent from ApSsrGlobal.modules. So a slow/missing client-modules fetch shows UI rather than hiding it.
  • Optimistic toggle isn't rolled back on error. addOrUpdatePermission flips local state before the mutation and only toasts on failure — the switch can briefly disagree with the server until the next findPermissionModules.
  • 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 / findClientModules filter by client: contextSvc.companyId (a commented-out line shows it was previously contextSvc.client = request host). For a port, decide explicitly whether ClientModule.client is the company id or the hostname.
  • Two parallel "module" enums. ApModules (BE) and USER_ACCESS (admin) must be kept in lockstep; the action strings also live in action.service.ts's seed. Adding a module/action means editing the enum, the seed, and the admin USER_ACCESS constants.