@feedmepos/hrm-permission
v1.2.0
Published
Permission types, enums, and access checking for FeedMe services.
Keywords
Readme
@feedmepos/hrm-permission
Permission types, enums, and access checking for FeedMe services.
New to the permission system, or changing one? Read docs/permission-lifecycle.md first — it covers the portal vs POS lanes, where permissions are stored, how they reach each consumer, and what actually happens when you add, rename or delete one. This README is the API reference for the portal lane.
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'
Permission.Subject.Business.crm_marketingMaterial; // 'business::crm::marketingMaterial'
// 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_stockAdjustmentReason; // 'business::inventory::stockAdjustmentReason'
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'
Permission.Subject.Business.hrm_approvalCode; // 'business::hrm::approvalCode'
// Report
Permission.Subject.Business.report_createReport; // 'business::report::createReport'
Permission.Subject.Business.report_accessOsDashboard; // 'business::report::accessOsDashboard'
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'
// Media
Permission.Subject.Business.media_assets; // 'business::media::assets'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
);By default, coverSubject uses OR semantics. For report access checks where a report-specific rule should override an all-report default, pass coverSubjectMode: 'directOverride'. In that mode, coverSubject is consulted only when the user has no non-inverted direct rule for the requested subject and action.
const result = checkAccess(
{
action: PermissionAction.read,
subject: 'business::report::reports::closeup',
coverSubject: Permission.Subject.Business.report_reports_allDefaultReports,
coverSubjectMode: 'directOverride',
},
userPermissions,
reportAccessWindowBuilder.buildContext?.({ nowAtUtc, reportDate })
);buildContext is an optional convenience supplied by a domain builder. A consumer may construct
the context itself instead, as long as the final object satisfies the builder's required
contextSchema fields.
Condition-aware check (single permission + context)
Use this when a rule carries a $in / $eq condition (e.g. restaurant-scoped permissions, or an
authored condition-builder restriction like report access windows). Only accepts a single permission
because context fields are subject-specific. context is optional — see below for what happens
when it's omitted. Works identically on the frontend (against a cached permissions array, no
network call) and the backend.
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
);Omitting context (or passing undefined): whether that's tolerated or denied is derived from
the rules, not declared anywhere. If every applicable direct or cover rule that grants this permission carries a condition on
a registered condition-builder's ownedFields, a real restriction exists and can't be verified —
denied (missing_or_invalid_context). If any applicable rule is unconditioned (e.g. the builder's
unrestricted preset) or conditioned on fields no builder owns, context is irrelevant and the
type-level result stands (not_enforced). A builder's ownedFields only count toward this
derivation for actions its appliesToActions covers (absent = all actions) — a rule conditioned
solely on a builder's fields is unconditioned, and thus not_enforced, for an action that builder
does not govern. See "Advanced: condition builders" below for the full algorithm.
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;
}
// The single-permission overload actually returns this superset:
interface ContextAccessCheckResult extends AccessCheckResult {
/** How condition evaluation participated in the decision. */
conditionEvaluation: 'matched' | 'missing_or_invalid_context' | 'not_enforced';
}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, .media
The permission will now appear as a checkbox in the HRM portal permission editor under the correct category section. Two build steps remain.
Step 3 — Rebuild the package
cd packages/permission && pnpm buildStep 4 — Regenerate Go enums
cd packages/permission && pnpm build:goGo services consume subjects via the generated packages/permission/go
module (portal/pos/casl subpackages). The "Permission Go Enums Check" CI
workflow fails the PR if the regenerated output is stale, so commit the diff
in the same PR — see packages/permission/go/README.md for the full Go-side
picture.
Some 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 | — | ✅ | ✅ |
A condition builder lets a permission carry a condition that is matched against a per-request context at evaluation time — e.g. "this staff member may read a report only for the current day", or "only restaurants A/B". The admin configures the condition in the portal; the endpoint supplies the context; HRM matches them with CASL.
The two halves: condition vs context
| | What | Who produces it | When |
| ------------- | --------------------------------------------------------------------- | ---------------------------------------------- | -------------- |
| condition | stored mongo-query on the rule, e.g. { reportAgeDays: { $lte: 7 } } | admin via the portal form → toCondition | authoring time |
| context | computed scalars, e.g. { reportAgeDays: 6 } | the endpoint's @Action({ context }) resolver | every request |
HRM never computes "now" or interprets the condition — the team pre-computes any relative value
(e.g. "days ago") into a constant-comparison scalar in the context. CASL matches the context
against the stored condition: 6 <= 7 → granted.
What a domain team provides
Four things — all of it is "content"; HRM owns the frame (registry, portal host, evaluation):
1. The builder (common/condition-builder/<domain>/<name>-builder.ts) — the whole builder in
ONE file via defineConditionBuilder: context schema, the pure form ⇄ condition translation, a
declarative form config (rendered by the portal's generic renderer — no per-builder Vue file),
ownedFields, and optionally a co-located buildContext convenience factory. Consumers may use
that factory or build the same schema-valid context themselves. labelKeys follow the
conditionBuilder.<builderGroup>.<field> convention — a builder ships its own translations as
<domain>/locales/*.json (one file per locale, content nested under its own top-level key, e.g.
{ "reportAccessWindow": { ... } }) and registers them with one import + one spread in
common/condition-builder/locales.ts, which the portal consumes as a single message source
alongside its own view-owned locale files:
import { z } from 'zod';
import type { ConditionFormConfig } from '../form-config';
import { defineConditionBuilder } from '../form-config';
export const REPORT_ACCESS_WINDOW_CONTEXT_SCHEMA = z.object({
reportDate: z.string(),
reportAgeDays: z.number().int().min(0),
reportAgeMonths: z.number().int().min(0),
reportAgeYears: z.number().int().min(0),
});
export type ReportAccessWindowForm =
| { mode: 'unrestricted' }
| { mode: 'since'; sinceAtUtc: string }
| { mode: 'last'; amount: number; unit: 'days' | 'months' | 'years' };
const reportAccessWindowForm: ConditionFormConfig = {
defaults: { mode: 'unrestricted' },
fields: [
{
kind: 'radio',
key: 'mode',
labelKey: 'conditionBuilder.reportAccessWindow.mode',
options: {
static: [
{ value: 'unrestricted', labelKey: 'conditionBuilder.reportAccessWindow.unrestricted' },
{ value: 'since', labelKey: 'conditionBuilder.reportAccessWindow.since' },
{ value: 'last', labelKey: 'conditionBuilder.reportAccessWindow.last' },
],
},
},
{
kind: 'date',
key: 'sinceAtUtc',
labelKey: 'conditionBuilder.reportAccessWindow.sinceAtUtc',
visibleWhen: { field: 'mode', eq: 'since' },
},
{
kind: 'number',
key: 'amount',
labelKey: 'conditionBuilder.reportAccessWindow.amount',
min: 1,
default: 7,
visibleWhen: { field: 'mode', eq: 'last' },
},
{
kind: 'select',
key: 'unit',
labelKey: 'conditionBuilder.reportAccessWindow.unit',
default: 'days',
visibleWhen: { field: 'mode', eq: 'last' },
options: {
static: [
{ value: 'days', labelKey: 'conditionBuilder.reportAccessWindow.days' },
{ value: 'months', labelKey: 'conditionBuilder.reportAccessWindow.months' },
{ value: 'years', labelKey: 'conditionBuilder.reportAccessWindow.years' },
],
},
},
],
};
export const reportAccessWindowBuilder = defineConditionBuilder<ReportAccessWindowForm>({
builderKey: 'report-access-window', // stable id — never change after deploy
version: 1,
contextSchema: REPORT_ACCESS_WINDOW_CONTEXT_SCHEMA, // ownedFields derived from this
form: reportAccessWindowForm,
// toCondition/fromCondition are GENERATED from these templates: when the form
// matches `when`, `condition` is stored with `{ $form: '<field>' }` substituted
// by the form value — and the inverse (stored condition → form) is derived by
// matching back against the same templates, so the two directions cannot drift.
// Unknown stored shapes fall back to the form defaults — never throw.
templates: [
{ when: { mode: 'unrestricted' }, condition: {} }, // empty = no restriction
{
when: { mode: 'since' },
condition: { reportDate: { $gte: { $form: 'sinceAtUtc' } } },
},
{
when: { mode: 'last', unit: 'days' },
condition: { reportAgeDays: { $lte: { $form: 'amount' } } },
},
{
when: { mode: 'last', unit: 'months' },
condition: { reportAgeMonths: { $lte: { $form: 'amount' } } },
},
{
when: { mode: 'last', unit: 'years' },
condition: { reportAgeYears: { $lte: { $form: 'amount' } } },
},
],
});Templates cover the common case: "preset → fixed condition shape with directly substituted form
values". Only when a condition needs COMPUTED operands (arithmetic on form values, merged fields)
should a builder hand-write the toCondition(form) / fromCondition(condition) pair instead —
defineConditionBuilder accepts either style. Hand-written fromCondition MUST tolerate
null/unknown shapes (older rules) and never throw.
Optional hooks — a builder may also implement either of these on the same object passed to
defineConditionBuilder (they pass through untouched, including through the templates shorthand):
appliesToActions?(actions: string[]): boolean;
summarizeForm?(form: TForm, opts: { timeZone: string }): ConditionSummary | undefined;appliesToActions— whether this builder governs a rule carrying these actions. Absent = applies always. Gates both the authoring UI (E.g.reportAccessWindowBuilderonly offers authoring forread/managerules) and missing-context enforcement derivation (see Evaluation below) — a check for an action outside this set is never failed closed by this builder'sownedFields.summarizeForm— a{ labelKey, params }summary of a form value for display (e.g. an "inherited condition" hint next to an authoring control). The host resolveslabelKeyvia i18n;timeZoneis an IANA zone the host resolves from business context and passes in — the builder never looks it up itself.
2. Attachment (common/condition-builder/attachments.ts) — ONE central declarative map
decides which subjects render (and enforce) which builders. The builder itself is a pure
"condition entity" and knows nothing about subjects; importing the builder objects here is also
what loads them — a builder absent from this map does not exist anywhere. This is
authoring/storage only — it does not declare an enforcement mode; whether a condition is actually
enforced is derived per-check by checkAccess from the data (see Evaluation below):
// attachments.ts — add your builder to the relevant subject entries
const REPORT_COMMON = [reportAccessWindowBuilder]; // shared list — grow it, every entry grows
export const CONDITION_BUILDER_ATTACHMENTS: Record<string, ConditionBuilder[]> = {
'prefix:business::report::reports::': REPORT_COMMON,
// exact entry = the WHOLE truth for that subject (opts out of / extends the prefix):
'business::report::reports::dailySales': [...REPORT_COMMON, dailySpecialBuilder],
};Keys accept exact subjects and prefix:<subjectPrefix> entries (dynamic subjects like per-report
permissions). Resolution is most-specific-wins — exact > longest prefix — and the winning entry
fully replaces the others (entries never merge; compose shared lists with spread). A subject may
carry SEVERAL builders: array order = portal render order, and builders in one entry must own
disjoint context fields (enforced with a throw at load).
3. The form — usually NO Vue file: the declarative form config above is rendered by the
portal's generic ConditionFormRenderer (fields, visibleWhen, static/provider options) in
both permission editors automatically. Only for UI that exceeds the declarative vocabulary, set
form: { component: '<key>' } and register the component in
condition-builders/component-map.ts under that key (escape hatch).
4. The @Action context resolver on the endpoint (in the team's own service) — computes the
scalars the schema declares:
@Action({
level: Permission.Level.business,
subject: ({ params }) => `business::report::reports::${params.reportKey}`,
action: Permission.Action.read,
context: ({ query }) => ({
reportDate: new Date(query.reportDate).toISOString(),
reportAgeDays: diffDays(new Date(), new Date(query.reportDate)),
reportAgeMonths: diffMonths(new Date(), new Date(query.reportDate)),
reportAgeYears: diffYears(new Date(), new Date(query.reportDate)),
}),
})
@Get('/:reportKey')
async getReport() {}Some subjects are also evaluated locally by a non-HRM consumer instead of round-tripping through the backend — e.g. POS's Go CASL port checks report access-window access on-device. That consumer may use its own same-language context factory, pinned against a shared parity fixture so it can't drift from the required schema. See
reportAccessWindowBuilder.buildContextand its Go port atpermission/lib/conditionbuilder/report_access_window.go(parity fixtures:packages/shared/condition-builder/fixtures/report-access-window.json) for the reference pattern.
Evaluation
There is no declared enforcement mode — checkAccess derives it from the data on every check:
- Run the type-level check (ignoring conditions). If denied, stop — context can never rescue a type-level deny.
- If granted, look at every non-inverted rule that applies to this subject+action. A builder's
ownedFieldsonly count for this step if itsappliesToActionscovers the action being checked (absent = always counts) — e.g. a rule mixingcreateandreadconditioned only on the report access-window builder's fields is unconditioned, for acreatecheck, since that builder doesn't governcreate. If any applicable rule has no condition, or a condition that doesn't touch an in-scopeownedFieldskey (e.g. the rule isunrestricted, or the condition belongs to an unrelated/legacy mechanism) — context is irrelevant. Grant,not_enforced. - If every applicable rule carries a condition on an
ownedFieldskey, a real restriction is configured and needs evaluating: missing or schema-invalid context → deny (missing_or_invalid_context, fail-closed); valid context → match it against the condition (matched).
Practical effect: while every rule for a subject stays unrestricted (the builder's default),
nothing requires a context resolver — authoring and storage work with zero endpoint changes. The
moment an admin configures a real restriction, every endpoint guarding that subject needs a
context resolver or requests will be denied. So ship resolvers on all endpoints for a subject
before anyone configures anything other than unrestricted for it — not after.
Scope boundary: this is derived only from non-inverted (restricted-allow) rules. A conditioned
deny rule on an ownedFields key is not a supported pattern here (production revoke logic never
emits one — see permission-set.ts's invertBase/getEffectivePermissions, which strip the
condition or delete the subject outright for a full revoke instead of relying on CASL to negate a
condition on an inverted rule).
Condition shape: flat fields only
Sibling fields in one condition are AND-ed. Conditions must be FLAT — every key a top-level
owned field. Builders must not emit $or/$and:
- Missing-context enforcement derivation (see Evaluation above) only inspects top-level
condition keys — an owned field nested under
$orreads as unconditioned for that check, a silent fail-open. - CASL v4+ removed logical operators from the default matcher. Stored conditions are data, not
code — they outlive whatever wrote them, so a CASL upgrade would need a production data
migration to re-shape every already-saved
$orcondition, not just a code change.
OR-shaped policies, in order of preference:
- Compute the OR in
buildContextas a derived scalar field. E.g. the policy "current month always, last month until the 7th, older never": derive a single fieldgraceDaysSinceMonthEnd(current/future month →0; last month → today's day-of-month; older → a large sentinel) and condition on{ graceDaysSinceMonthEnd: { $lte: N } }— one flat, parameterized field expresses the whole rule. - Multiple same-subject rules — CASL unions positive grants across rules (guarded by
__tests__/casl-rule-union.test.ts). Today this is authorable only via separate permission sets, since the builder/portal path emits one condition per rule.
contextSchemashould declare every field a builder's condition references — declared fields are validated (fail-closed on bad values); undeclared context fields pass through to matching unvalidated (so legacy scoping keys likerestaurantId: { $in: [...] }keep evaluating).
Required test
Every builder must include a round-trip test — it's what catches a wrong fromCondition:
expect(builder.fromCondition(builder.toCondition(form))).toEqual(form);Checklist
| Step | File |
| ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- |
| Builder (schema + form config + templates/to-fromCondition + optional buildContext) | common/condition-builder/<domain>/<name>-builder.ts |
| Export from the domain barrel | common/condition-builder/<domain>/index.ts |
| Translations (one JSON per locale) + register in the aggregator | common/condition-builder/<domain>/locales/*.json, common/condition-builder/locales.ts |
| Attach (subject → builders, ONE central map) | common/condition-builder/attachments.ts |
| (only for bespoke UI) component + component-map entry | apps/hrm-portal/.../condition-builders/ |
| @Action({ context }) resolver | your controller |
| (only if a subject is also evaluated locally, e.g. POS) context factory + parity fixture | e.g. permission/lib/conditionbuilder/, packages/shared/condition-builder/fixtures/ |
| Round-trip test | __tests__/ |
| Rebuild package | cd packages/permission && pnpm build |
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.
Two-event model. A single request produces up to two records, correlated by the request's traceId:
- Access check (
AuditLogEntry) — written on every guarded request, granted or denied. CarrieseventKey,entityId, andparamsauthored on@Action(the request-intent key, in<entity>.<verb>form, e.g.inventory-item.adjust-stock). - Operation event(s) (
AuditLogOperation) — written after a mutation handler settles (POST/PUT/PATCH/DELETE), one peraudit.record()call. Carries the effect key,status(success/failed),durationMs, optionalbefore/aftersnapshots, and optionalchangeNodes(display-ready human-readable diff rows resolved at write time — the portal renders them as a table, falling back to raw JSON diff when absent).
eventKey is a stable, domain-owned key — domains may introduce keys without changing ActionGuard. See @feedmepos/hrm-actionguard for how @Action and audit.record() populate these.
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, getCoverSubjectFor, isCoverSubject |
| @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/condition-builder | Condition builders (reportAccessWindowBuilder, ...), registry, conditionBuilderI18nMessages |
| @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