@feedmepos/hrm-permission
v1.0.9
Published
Permission types, enums, and access checking for FeedMe services.
Readme
@feedmepos/hrm-permission
Permission types, enums, and access checking for FeedMe services.
Installation
pnpm add @feedmepos/hrm-permissionUsage
Permission Enums
All subjects are in PermissionSubjectBusinessNamespace, exposed via Permission.Subject.Business.
import {
Permission,
PermissionAction,
PermissionSubjectBusinessNamespace,
} from '@feedmepos/hrm-permission';
// Actions
PermissionAction.manage; // full control
PermissionAction.read;
PermissionAction.create;
PermissionAction.update;
PermissionAction.delete;
// Subjects — use either form, they are the same value
Permission.Subject.Business.hrm_teamMember; // 'business::hrm::teamMember'
PermissionSubjectBusinessNamespace.hrm_teamMember; // same
// Common subjects by module
Permission.Subject.Business.menu_item; // 'business::menu::item'
Permission.Subject.Business.menu_catalog; // 'business::menu::catalog'
Permission.Subject.Business.menu_menuManagement; // 'business::menu::menuManagement'
// ... (see full menu list in the collapsible below)
Permission.Subject.Business.restaurant; // 'business::restaurant'
Permission.Subject.Business.crm_promotion; // 'business::crm::promotion'
Permission.Subject.Business.crm_voucher; // 'business::crm::voucher'
Permission.Subject.Business.crm_membership; // 'business::crm::membership'
Permission.Subject.Business.payment_payoutAccount; // 'business::payment::payoutAccount'
Permission.Subject.Business.payment_paymentOnboarding; // 'business::payment::paymentOnboarding'
Permission.Subject.Business.payment_transactions; // 'business::payment::transactions'
Permission.Subject.Business.payment_settlements; // 'business::payment::settlements'
Permission.Subject.Business.inventory_stockBalance; // 'business::inventory::stockBalance'
Permission.Subject.Business.inventory_ingredient; // 'business::inventory::ingredient'
Permission.Subject.Business.inventory_recipe; // 'business::inventory::recipe'
// ... (see full list in the collapsible below)
Permission.Subject.Business.hrm_employee; // 'business::hrm::employee'
Permission.Subject.Business.hrm_teamMember; // 'business::hrm::teamMember'
Permission.Subject.Business.hrm_auditLog; // 'business::hrm::auditLog'
Permission.Subject.Business.report_createReport; // 'business::report::createReport'
Permission.Subject.Business.report_accessOverview; // 'business::report::accessOverview'// General
Permission.Subject.Business.profile;
Permission.Subject.Business.restaurant;
// Menu
Permission.Subject.Business.menu_item; // 'business::menu::item'
Permission.Subject.Business.menu_catalog; // 'business::menu::catalog'
Permission.Subject.Business.menu_category; // 'business::menu::category'
Permission.Subject.Business.menu_subCategory; // 'business::menu::subCategory'
Permission.Subject.Business.menu_group; // 'business::menu::group'
Permission.Subject.Business.menu_takeaway; // 'business::menu::takeaway'
Permission.Subject.Business.menu_scheduler; // 'business::menu::scheduler'
Permission.Subject.Business.menu_variant; // 'business::menu::variant'
Permission.Subject.Business.menu_cookingGuide; // 'business::menu::cookingGuide'
Permission.Subject.Business.menu_printRoute; // 'business::menu::printRoute'
Permission.Subject.Business.menu_servingSequence; // 'business::menu::servingSequence'
Permission.Subject.Business.menu_unit; // 'business::menu::unit'
Permission.Subject.Business.menu_ingredient; // 'business::menu::ingredient'
Permission.Subject.Business.menu_recipe; // 'business::menu::recipe'
Permission.Subject.Business.menu_settings; // 'business::menu::settings'
Permission.Subject.Business.menu_publish; // 'business::menu::publish'
Permission.Subject.Business.menu_menuManagement; // 'business::menu::menuManagement'
Permission.Subject.Business.menu_importExport; // 'business::menu::importExport'
// CRM
Permission.Subject.Business.crm_promotion; // 'business::crm::promotion'
Permission.Subject.Business.crm_voucher; // 'business::crm::voucher'
Permission.Subject.Business.crm_membership; // 'business::crm::membership'
Permission.Subject.Business.crm_analytic; // 'business::crm::analytic'
Permission.Subject.Business.crm_tier; // 'business::crm::tier'
Permission.Subject.Business.crm_title; // 'business::crm::title'
Permission.Subject.Business.crm_broadcast; // 'business::crm::broadcast'
Permission.Subject.Business.crm_point; // 'business::crm::point'
Permission.Subject.Business.crm_credit; // 'business::crm::credit'
Permission.Subject.Business.crm_experience; // 'business::crm::experience'
Permission.Subject.Business.crm_game; // 'business::crm::game'
Permission.Subject.Business.crm_mission; // 'business::crm::mission'
Permission.Subject.Business.crm_loyaltyMember; // 'business::crm::loyaltyMember'
Permission.Subject.Business.crm_loyaltySegment; // 'business::crm::loyaltySegment'
Permission.Subject.Business.crm_loyaltyCard; // 'business::crm::loyaltyCard'
Permission.Subject.Business.crm_referral; // 'business::crm::referral'
Permission.Subject.Business.crm_store; // 'business::crm::store'
Permission.Subject.Business.crm_transaction; // 'business::crm::transaction'
Permission.Subject.Business.crm_setting; // 'business::crm::setting'
Permission.Subject.Business.crm_bin; // 'business::crm::bin'
// Payment
Permission.Subject.Business.payment_payoutAccount; // 'business::payment::payoutAccount'
Permission.Subject.Business.payment_paymentOnboarding; // 'business::payment::paymentOnboarding'
Permission.Subject.Business.payment_transactions; // 'business::payment::transactions'
Permission.Subject.Business.payment_settlements; // 'business::payment::settlements'
// Inventory
Permission.Subject.Business.inventory_stock; // 'business::inventory::stock'
Permission.Subject.Business.inventory_stockBalance; // 'business::inventory::stockBalance'
Permission.Subject.Business.inventory_stockAdjustment; // 'business::inventory::stockAdjustment'
Permission.Subject.Business.inventory_unitCostHistory; // 'business::inventory::unitCostHistory'
Permission.Subject.Business.inventory_wastageTemplate; // 'business::inventory::wastageTemplate'
Permission.Subject.Business.inventory_closingHistory; // 'business::inventory::closingHistory'
Permission.Subject.Business.inventory_closingTemplate; // 'business::inventory::closingTemplate'
Permission.Subject.Business.inventory_closingDraft; // 'business::inventory::closingDraft'
Permission.Subject.Business.inventory_ingredient; // 'business::inventory::ingredient'
Permission.Subject.Business.inventory_ingredientGroup; // 'business::inventory::ingredientGroup'
Permission.Subject.Business.inventory_recipe; // 'business::inventory::recipe'
Permission.Subject.Business.inventory_unit; // 'business::inventory::unit'
Permission.Subject.Business.inventory_purchaseTransfer; // 'business::inventory::purchaseTransfer'
Permission.Subject.Business.inventory_orderDraftApproval; // 'business::inventory::orderDraftApproval'
Permission.Subject.Business.inventory_transferOut; // 'business::inventory::transferOut'
Permission.Subject.Business.inventory_surcharge; // 'business::inventory::surcharge'
Permission.Subject.Business.inventory_orderTemplate; // 'business::inventory::orderTemplate'
Permission.Subject.Business.inventory_supplier; // 'business::inventory::supplier'
Permission.Subject.Business.inventory_warehouse; // 'business::inventory::warehouse'
Permission.Subject.Business.inventory_publish; // 'business::inventory::publish'
Permission.Subject.Business.inventory_import; // 'business::inventory::import'
Permission.Subject.Business.inventory_integration; // 'business::inventory::integration'
// HRM
Permission.Subject.Business.hrm_employee; // 'business::hrm::employee'
Permission.Subject.Business.hrm_teamMember; // 'business::hrm::teamMember'
Permission.Subject.Business.hrm_auditLog; // 'business::hrm::auditLog'
// Report
Permission.Subject.Business.report_createReport; // 'business::report::createReport'
Permission.Subject.Business.report_accessOverview; // 'business::report::accessOverview'
Permission.Subject.Business.report_accessInsight; // 'business::report::accessInsight'
Permission.Subject.Business.report_accessSetting; // 'business::report::accessSetting'
Permission.Subject.Business.report_accessIntegration; // 'business::report::accessIntegration'
Permission.Subject.Business.report_reports_allDefaultReports; // 'business::report::allDefaultReports'
Permission.Subject.Business.report_reports_allCustomReports; // 'business::report::allCustomReports'checkAccess
import { checkAccess } from '@feedmepos/hrm-permission';
import type { RawRule } from '@casl/ability';
// userPermissions comes from your permission service / store
const userPermissions: RawRule[] = /* ... */;Basic check (AND — all must pass)
const result = checkAccess(
[
{ action: PermissionAction.manage, subject: Permission.Subject.Business.hrm_teamMember },
{ action: PermissionAction.read, subject: Permission.Subject.Business.hrm_employee },
],
userPermissions
);
if (result.granted) {
// allowed
} else {
console.log('blocked by', result.decisivePermission);
}coverSubject — OR fallback
Grants access when the user can perform the action on either subject or coverSubject. Useful for "group covers individual" patterns (e.g. a catch-all report permission covering a specific dynamic report subject).
const result = checkAccess(
[
{
action: PermissionAction.read,
subject: 'business::report::myDynamicReport',
coverSubject: Permission.Subject.Business.report_reports_allDefaultReports,
},
],
userPermissions
);Condition-aware check (single permission + subject instance)
Use this when a rule carries a $in / $eq condition (e.g. restaurant-scoped permissions). Only accepts a single permission because conditions are subject-specific.
const result = checkAccess(
{ action: PermissionAction.manage, subject: Permission.Subject.Business.restaurant },
userPermissions,
{ restaurantId: 'abc' } // evaluated against rule conditions, e.g. { restaurantId: { $in: ['abc', 'def'] } }
);
// Typical use: filter a list to items the user can access
const accessible = restaurants.filter(
(r) =>
checkAccess(
{ action: PermissionAction.manage, subject: Permission.Subject.Business.restaurant },
userPermissions,
{ restaurantId: r.id }
).granted
);Return value
interface AccessCheckResult {
granted: boolean;
/** The permission requirement that was decisive. */
decisivePermission: CaslPermission;
/** The matched CASL rule, or null if nothing matched. Parse `.reason` as JSON for audit source info. */
decisiveRule: RawRule | null;
}Adding a New Permission
Tip: You can ask Copilot to do this for you using the
add-new-permissionskill. Example prompts:@workspace /add-new-permission add hrm_payroll under the HRM category with manage action@workspace /add-new-permission add crm_loyaltyCard under CRM category, manage action, behind feature flag crm_loyalty_card@workspace /add-new-permission add report_exportCsv under Report category with manage action
Adding a portal permission (one admins can toggle in the UI) requires two file changes.
Step 1 — Add the subject enum value
File: packages/permission/src/common/types.ts
export enum PermissionSubjectBusinessNamespace {
// ... existing entries ...
// HRM module — follow the module_feature naming pattern
hrm_payroll = 'business::hrm::payroll',
}Naming rules:
- Prefix with the module:
crm_,inventory_,hrm_,report_ - camelCase feature name
- Value pattern:
business::<module>::<feature>
Step 2 — Add it to FullPortalPermissions
File: packages/permission/src/common/full-portal-permissions.ts
export const FullPortalPermissions = {
// ... existing entries ...
payroll: {
label: 'Payroll Management', // shown in the UI permission editor and audit logs
subject: PermissionSubjectBusinessNamespace.hrm_payroll,
actions: [PermissionAction.manage],
category: PermissionCategory.hrm, // General | Inventory | HRM | CRM | Report
// showByFeatureFlag: 'payroll-beta', // optional: hides checkbox until flag is on
},
};Available categories: PermissionCategory.general, .inventory, .hrm, .crm, .payment, .report, .reports, .customReports, .menu
That's it. The permission will now appear as a checkbox in the HRM portal permission editor under the correct category section.
Step 3 — Rebuild the package
cd packages/permission && pnpm buildSome features silently require access to other resources to work correctly (e.g. "Team Member Management" needs to read the POS role list to populate a dropdown). Rather than requiring admins to grant both, you define a system permission set that auto-injects the dependency whenever the parent permission is granted.
When to use this
- Your feature silently requires read access to another resource → use
permissions[] - You split an existing subject into multiple new granular subjects and existing users should silently get them → use
permissionSets[](backward-compat chaining) - If neither applies, skip this — most portal permissions do not need a system set
Subject namespaces in permissions[]
permissions[] accepts both resource subjects (hrm::posRole) and business/module subjects
(business::payment::payoutAccount). Both are injected as leaf-level CASL rules and are never
shown in the portal UI. Use PermissionSubjectBusinessNamespace directly when the subject already
exists there — no separate resource enum needed. Create a resource enum only for subjects with no
corresponding portal entry.
permissions[] — inject a read-only dependency
// permission-manifest/hrm/hrm-system-sets.ts
import { HrmResource } from './types';
[`set_${PermissionSubjectBusinessNamespace.hrm_teamMember}`]: {
key: 'sys:team_member_access', // stable — never change after deploy, stored in audit logs
name: 'Team Member Access', // shown in audit log breadcrumbs
permissions: [
{
label: 'Pos Role', // subject name only, reused as audit log display label
subject: HrmResource.hrm_posRole,
actions: [PermissionAction.read],
},
],
},Recommended rule: prefer
read-only actions inpermissions[]— avoidmanagewhere possible. Injectingmanagecauses two problems: (1) UI bleed — the editor filters out system-injected entries by checking for non-manageactions, so amanageentry renders as an auto-checked checkbox; (2) over-grant —manageis a CASL wildcard covering all actions, silently granting destructive write access the admin never explicitly authorised. Ifmanageaccess is needed as a side-effect, preferpermissionSets[]chaining instead. Some existing system sets (e.g.hrm-system-sets.ts) injectmanageviapermissions[]for legacy reasons — new sets should avoid this pattern.
permissionSets[] — chain to another portal permission (backward-compat)
Use when an existing parent permission should silently unlock new granular sibling subjects. Each chained subject gets a synthetic manage rule and its own system set is recursively expanded.
[`set_${PermissionSubjectBusinessNamespace.hrm_payroll}`]: {
key: 'sys:payroll_access',
name: 'Payroll Access',
permissionSets: [
PermissionSubjectBusinessNamespace.hrm_payroll_approval, // users with payroll:manage get this too
],
},File organisation
When a domain has many system sets, extract them:
permission-manifest/
hrm/
types.ts ← HrmResource enum
hrm-system-sets.ts
index.ts
inventory/
types.ts ← InventoryResource enum
inventory-system-sets.ts
index.tsSpread them into SYSTEM_PERMISSION_SETS in system-permission-sets.ts:
export const SYSTEM_PERMISSION_SETS = {
...INVENTORY_SYSTEM_SETS,
...HRM_SYSTEM_SETS,
};Updated checklist (with system set)
| Step | File | Portal permission | Resource dependency |
| ------------------------------- | ------------------------------- | :---------------: | :-----------------: |
| Add enum value | types.ts | ✅ | ✅ |
| Add to FullPortalPermissions | full-portal-permissions.ts | ✅ | ❌ |
| Add to SYSTEM_PERMISSION_SETS | permission-manifest/<domain>/ | ❌ | ✅ |
| Add @Action to controller | your controller | ✅ | ✅ |
| Rebuild package | — | ✅ | ✅ |
import '@feedmepos/hrm-permission/style.css';PermissionWrapper
Renders children only when route permissions are satisfied.
<template>
<PermissionWrapper>
<div>Protected content</div>
</PermissionWrapper>
</template>
<script setup lang="ts">
import { PermissionWrapper } from '@feedmepos/hrm-permission/components';
</script>withPermission HOC
Wraps a component so it renders only when the route's validationManifest permissions are satisfied. Typically combined with a lazy-loaded component:
import { withPermission } from '@feedmepos/hrm-permission/components';
import { withLoading } from '@/components/loading';
import { Permission } from '@feedmepos/hrm-permission';
import type { RouteMeta } from 'vue-router';
// Wrap lazy-loaded views
const hrMain = withPermission(withLoading(() => import('@/views/hr/Main.vue')));
const teamMain = withPermission(withLoading(() => import('@/views/team/Main.vue')));
// Helper to build the required route meta
const canManage = (subject: string): RouteMeta =>
({
validationManifest: {
requiredCaslPermissions: [{ action: Permission.Action.manage, subject }],
},
}) as unknown as RouteMeta;
// Routes
const routes = [
{
path: '/',
component: hrMain,
meta: canManage(Permission.Subject.Business.hrm_employee),
children: [
{
path: 'employee',
component: withLoading(() => import('@/views/hr/employee/EmployeeList.vue')),
},
{ path: 'role', component: withLoading(() => import('@/views/hr/role/RoleList.vue')) },
],
},
{
path: '/team',
component: teamMain,
meta: canManage(Permission.Subject.Business.hrm_teamMember),
},
];The validationManifest on the route meta is read by withPermission to check the user's CASL ability before rendering. If the check fails, the component is not mounted.
Standalone
import { PermissionService } from '@feedmepos/hrm-permission/nestjs';
const service = new PermissionService({
mongoUrl: process.env.MONGODB_URL,
dbName: process.env.MONGODB_NAME || 'companyDB',
});
await service.initialize();
const ability = await service.constructAbility({
userId: 'user123',
level: 1, // Permission.Level.business
role: 'admin',
businessId: 'biz123',
});
await service.close();NestJS module
// app.module.ts
import { PermissionModule } from '@feedmepos/hrm-permission/nestjs';
@Module({
imports: [
PermissionModule.forRoot({
mongoUrl: process.env.MONGODB_URL,
dbName: process.env.MONGODB_NAME,
}),
],
})
export class AppModule {}
// your.service.ts
import { PermissionService } from '@feedmepos/hrm-permission/nestjs';
@Injectable()
export class MyService {
constructor(private readonly permissionService: PermissionService) {}
}Environment variables
| Variable | Required | Description |
| -------------- | -------- | ------------------------------------ |
| MONGODB_URL | ✅ | MongoDB connection string |
| MONGODB_NAME | — | Database name (default: companyDB) |
Audit logging is handled automatically by hrm-backend — every ActionGuard permission check writes a ClickHouse entry via gRPC. Consumer services do not call any audit-log function directly.
If you need to build a log entry manually (e.g. inside hrm-backend itself), use buildPermissionLog:
import { buildPermissionLog, type AuditContext } from '@feedmepos/hrm-permission/audit-log';
import { checkAccess } from '@feedmepos/hrm-permission/utils';
const result = checkAccess(
[{ action: PermissionAction.read, subject: Permission.Subject.Business.hrm_employee }],
userPermissions
);
const logEntry = buildPermissionLog(result, {
userId: 'user123',
requestPath: '/api/employees',
requestMethod: 'GET',
requestBody: '{"key":"value"}', // serialized string — plain JSON or "gzip:<base64>" for large bodies
businessId: 'biz456',
country: 'MY',
} satisfies AuditContext);AuditContext.requestBody is a string — either plain JSON (JSON.stringify(body)) for bodies ≤ 1 MB, or "gzip:<base64>" for larger bodies (produced by serializeRequestBody in @feedmepos/hrm-actionguard). Legacy records stored in ClickHouse may have requestBody as a Record<string, unknown> object — AuditLogMetadata.requestBody is typed as string | Record<string, unknown> to handle both.
Flat → namespaced subjects
PermissionSubjectBusiness is @deprecated. Use PermissionSubjectBusinessNamespace instead. Legacy subjects are automatically remapped at runtime.
| Old (deprecated) | New |
| --------------------------- | -------------------------------------- |
| business::promotion | business::crm::promotion |
| business::voucher | business::crm::voucher |
| business::membership | business::crm::membership |
| business::stock | business::inventory::stock |
| business::ingredient | business::inventory::ingredient |
| business::recipe | business::inventory::recipe |
| business::unit | business::inventory::unit |
| business::supplier | business::inventory::supplier |
| business::warehouse | business::inventory::warehouse |
| business::publish | business::inventory::publish |
| business::integration | business::inventory::integration |
| business::orderDraft | business::inventory::orderDraft |
| business::wastageTemplate | business::inventory::wastageTemplate |
| business::closingTemplate | business::inventory::closingTemplate |
| business::orderTemplate | business::inventory::orderTemplate |
| business::unitCostHistory | business::inventory::unitCostHistory |
| business::permission | business::hrm::teamMember |
| business::role | business::hrm::employee::role |
hasAccess → checkAccess
// Before
const canAccess: boolean = hasAccess(requiredPermissions, userPermissions);
// After
const result = checkAccess(requiredPermissions, userPermissions);
const canAccess = result.granted;| Entry point | What's in it |
| -------------------------------------- | ------------------------------------------------------------------------------------- |
| @feedmepos/hrm-permission | Enums, types, constants, checkAccess |
| @feedmepos/hrm-permission/utils | checkAccess, validate, validateRoute, mergePermissions, mapLegacyPermission |
| @feedmepos/hrm-permission/components | PermissionWrapper, withPermission |
| @feedmepos/hrm-permission/nestjs | PermissionModule, PermissionService + re-exports common |
| @feedmepos/hrm-permission/service | PermissionService (standalone, no NestJS) |
| @feedmepos/hrm-permission/audit-log | buildPermissionLog, AuditContext type |
| @feedmepos/hrm-permission/style.css | Component styles |
Peer dependencies
@casl/ability@feedmepos/core@feedmepos/zod-repomongodbvue^3.5.0 (for components)vue-router^4.2.0 (for route validation)
pnpm install
pnpm dev # run demo app with hot reload
pnpm build # build library
pnpm type-check