@feedmepos/hrm-actionguard
v1.1.0
Published
NestJS Action Guard for HRM permission system
Downloads
1,593
Readme
@feedmepos/hrm-actionguard
NestJS Guard + Decorator for declarative permission control, built on CASL.
Sends a gRPC CheckPermission call to hrm-backend for permission verification and audit logging. By default, the package connects to the internal hr-backend:5001 over insecure gRPC; set PERMISSION_CHECK_TARGET=hr-backend to route through the remote SSL endpoint instead, or PERMISSION_CHECK_TARGET=internal to explicitly opt into the default insecure path.
Installation
pnpm add @feedmepos/hrm-actionguardUsage
Module Setup
import { ActionGuardModule } from '@feedmepos/hrm-actionguard/nestjs';
@Module({
imports: [
ActionGuardModule.forRoot({
// grpcUrl defaults to:
// - PERMISSION_CHECK_GRPC_URL env, if set
// - otherwise PERMISSION_CHECK_TARGET=hr-backend: 'hr-backend-grpc.feedmeapidev.com:443'
// - otherwise PERMISSION_CHECK_TARGET=internal (or unset): 'hr-backend:5001'
//
// grpcMode defaults to:
// - PERMISSION_CHECK_GRPC_MODE env, if set ('ssl' | 'insecure')
// - otherwise PERMISSION_CHECK_TARGET=hr-backend: 'ssl'
// - otherwise PERMISSION_CHECK_TARGET=internal (or unset): 'insecure'
//
// token defaults to HRM_INTERNAL_TOKEN env → PERMISSION_CHECK_TOKEN_DEFAULT
}),
],
})
export class AppModule {}That's it. Inside the cluster (and any environment where hr-backend:5001 is reachable) the default is correct. Set PERMISSION_CHECK_TARGET=hr-backend when the caller has no internal hr-backend service and needs to use the shared dev endpoint.
Environment variables
| Variable | Purpose | Default |
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
| HRM_INTERNAL_TOKEN | Authenticates gRPC calls to hrm-backend. Auto-read when not passed explicitly. | PERMISSION_CHECK_TOKEN_DEFAULT ('Greetings_from_hrm') |
| PERMISSION_CHECK_TARGET | Selects the preset endpoint: hr-backend (remote SSL) or internal (insecure hr-backend:5001). Unset = internal. | unset |
| PERMISSION_CHECK_GRPC_URL | Manual override for the gRPC server address. Wins over the target default. | derived from PERMISSION_CHECK_TARGET |
| PERMISSION_CHECK_GRPC_MODE | Manual override for the gRPC channel credentials mode. Must be ssl or insecure. | derived from PERMISSION_CHECK_TARGET |
Target defaults:
| PERMISSION_CHECK_TARGET | URL | gRPC mode |
| ------------------------- | -------------------------------------- | ---------- |
| (unset) | hr-backend:5001 | insecure |
| internal | hr-backend:5001 | insecure |
| hr-backend | hr-backend-grpc.feedmeapidev.com:443 | ssl |
Any other non-empty value throws on startup so typos fail loudly. An empty string is treated the same as unset.
Precedence is:
ActionGuardModule.forRoot({ grpcUrl, grpcMode })PERMISSION_CHECK_GRPC_URL/PERMISSION_CHECK_GRPC_MODEPERMISSION_CHECK_TARGETdefault
For local HRM development where you run hrm-backend on your own machine, set PERMISSION_CHECK_GRPC_URL=localhost:5001. The default mode is insecure, so no PERMISSION_CHECK_GRPC_MODE override is needed — provided PERMISSION_CHECK_TARGET is unset or internal. If PERMISSION_CHECK_TARGET=hr-backend is also set, the resolved mode stays ssl, so also set PERMISSION_CHECK_GRPC_MODE=insecure.
Overriding defaults
// Synchronous override
ActionGuardModule.forRoot({
token: 'override-token',
grpcUrl: 'custom-host:5001',
grpcMode: 'insecure',
});Async Configuration
ActionGuardModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
token: config.get('HRM_INTERNAL_TOKEN'),
// Optional explicit overrides:
grpcUrl: config.get('PERMISSION_CHECK_GRPC_URL'),
grpcMode: config.get<'ssl' | 'insecure'>('PERMISSION_CHECK_GRPC_MODE'),
timeoutMs: 8000,
maxRetries: 3,
}),
});Declare Permissions at Controller / Method Level
import { ActionGuard, Action } from '@feedmepos/hrm-actionguard/nestjs';
// Controller level — all methods inherit
@Action({
level: 1, // 0=FeedMe, 1=Business, 2=Restaurant
subject: 'permission',
action: 'manage',
})
@UseGuards(AuthGuard('firebase'), ActionGuard)
@Controller('portal/businesses/:businessId/users')
export class UserController {
@Get()
async getUsers() {}
}Access Ability in Controller
After the guard passes, ability is injected into request.user.ability:
@Get()
async getData(@Req() req: Request) {
const ability = req.user?.ability[1]; // business level
const canDelete = ability?.can('delete', { subjectType: 'role' });
}API
@Action(metadata)
| Param | Type | Description |
| ----------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| level | 0 \| 1 \| 2 | Permission level: 0=FeedMe, 1=Business, 2=Restaurant |
| subject | string \| ((ctx: OperationContext) => string) | Permission subject, e.g. Permission.Subject.Business.hrm_teamMember. Pass a function for dynamic subjects resolved from request context. |
| action | string | CASL action: "manage", "read", "create", "update", "delete" |
| field? | string | Optional field-level permission |
| coverSubject? | string \| ((ctx: OperationContext) => string) | Optional OR fallback subject — access granted if user can perform action on either subject or coverSubject. Accepts a resolver function same as subject. |
| skipValidation? | boolean | Skip permission check (allow through regardless — logs a warning) |
| operationLabel? | string \| ((ctx: OperationContext) => string) | Human-readable label surfaced in audit log metadata |
| requestBody? | Record<string, unknown> \| ((ctx: OperationContext) => Record<string, unknown>) | Override for the request body forwarded to hr-backend for audit logging. Defaults to request.body. Use a function to send only the fields relevant to auditing. Errors are caught — nothing is sent on throw (fail-safe). |
Dynamic Subjects
subject and coverSubject accept a resolver function (ctx: OperationContext) => string for cases where the subject depends on request context:
// Static subject
@Action({
level: Permission.Level.business,
subject: Permission.Subject.Business.hrm_teamMember,
action: Permission.Action.read,
})
// Dynamic subject resolved from route params
@Action({
level: Permission.Level.business,
subject: ({ params }) => `business::report::reports::${params.permissionKey}`,
action: Permission.Action.read,
})
// Dynamic coverSubject as fallback
@Action({
level: Permission.Level.business,
subject: ({ params }) => `business::report::reports::${params.permissionKey}`,
coverSubject: 'business::report::accessInsight',
action: Permission.Action.read,
})The resolver receives the same OperationContext as operationLabel. See the Operation Labels section for the full context shape.
ActionGuardModule.forRoot(config?) / forRootAsync(config)
| Config | Type | Default | Description |
| --------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| token? | string | HRM_INTERNAL_TOKEN env → PERMISSION_CHECK_TOKEN_DEFAULT | HRM internal token for gRPC auth |
| grpcUrl? | string | PERMISSION_CHECK_GRPC_URL env → target default URL | gRPC server address |
| grpcMode? | 'ssl' \| 'insecure' | PERMISSION_CHECK_GRPC_MODE env → ssl when PERMISSION_CHECK_TARGET=hr-backend, insecure when internal or unset | gRPC channel credentials mode |
| timeoutMs? | number | 5000 | Per-call gRPC deadline in milliseconds |
| maxRetries? | number | 2 | Max retry attempts for transient gRPC errors (DEADLINE_EXCEEDED, etc) |
| retryDelayMs? | number | 500 | Initial retry delay in ms (doubles each attempt — exponential backoff) |
Retry Behavior
Transient gRPC errors are retried automatically with exponential backoff. The following status codes trigger retries:
DEADLINE_EXCEEDED(4)UNAVAILABLE(14)RESOURCE_EXHAUSTED(8)ABORTED(10)
Non-retryable errors (e.g. PERMISSION_DENIED, NOT_FOUND, INVALID_ARGUMENT) fail immediately.
With defaults (maxRetries: 2, retryDelayMs: 500), a failing call will attempt up to 3 times total with delays of 500ms and 1000ms between attempts.
Execution Flow
AuthGuard → ActionGuard
1. Read @Action metadata from controller/method
2. Resolve subject/coverSubject functions (if any)
3. Resolve operationLabel function (if any)
4. Resolve requestBody: use @Action override when set, else request.body
5. Serialize requestBody:
≤ 1 MB → plain JSON string
> 1 MB → gzip-compressed, base64-encoded with "gzip:" prefix
6. gRPC CheckPermission → hrm-backend (8 MB send limit)
(constructAbility + checkAccess + auditLog)
7. Retry on transient errors (with exponential backoff)
8. Reconstruct CASL Ability from gRPC response
9. Inject request.user.ability = { [level]: Ability }
10. Return granted/deniedOperation Labels
The optional operationLabel field provides a human-readable description of the operation. The resolved string is stored in metadata.operationLabel in the ClickHouse audit log.
Static Label
@Action({
level: Permission.Level.business,
subject: Permission.Subject.Business.inventory,
action: Permission.Action.update,
operationLabel: 'Update inventory stock',
})Dynamic Label
Pass a function to build the label from request context. The function receives an OperationContext:
| Field | Type | Description |
| -------- | ------------------------------------------------- | ----------------------------------------------------- |
| params | Record<string, string> | Route params, e.g. { businessId, restaurantId } |
| body | Record<string, unknown> \| undefined | Parsed request body |
| query | Record<string, string \| string[]> \| undefined | Query string params |
| path | string | Full request path, e.g. "/api/businesses/123/users" |
| method | string | HTTP method, e.g. "GET", "PUT" |
| user | { uid: string; role?: string } | Authenticated user |
@Action({
level: Permission.Level.restaurant,
subject: Permission.Subject.Restaurant.inventory_stock,
action: Permission.Action.update,
operationLabel: ({ params }) => `Update stock for outlet ${params.restaurantId}`,
})
@Action({
level: Permission.Level.business,
subject: Permission.Subject.Business.hrm_employee,
action: Permission.Action.update,
operationLabel: ({ body, params }) =>
`Update employee ${body?.name ?? params.employeeId}`,
})Note: The label function runs before the user check.
ctx.usermay beundefinedon unauthenticated requests — always guard with optional chaining.
Request Body Forwarding
By default, the full request.body is forwarded to hr-backend and stored in the ClickHouse audit log. Use requestBody in @Action to override which fields are sent.
Gzip Compression
The request body is serialized before being sent over gRPC:
- ≤ 1 MB: sent as a plain JSON string.
- > 1 MB: gzip-compressed and base64-encoded with a
"gzip:"prefix — e.g."gzip:<base64>".
The hrm-portal audit log detail view detects the prefix and decompresses transparently. Legacy audit records that stored requestBody as a raw object are also handled.
Overriding the Body
// Send a static subset
@Action({
level: Permission.Level.business,
subject: Permission.Subject.Business.hrm_employee,
action: Permission.Action.update,
requestBody: { action: 'bulk-import' },
})
// Send only the fields relevant for auditing
@Action({
level: Permission.Level.restaurant,
subject: Permission.Subject.Restaurant.inventory_stock,
action: Permission.Action.update,
requestBody: ({ body }) => ({ name: body?.name, count: body?.items?.length }),
})If the function throws, nothing is sent — this prevents raw body leakage when you intended to strip fields.
Nuxt / Nitro Integration
@feedmepos/hrm-actionguard/nuxt provides the same permission control for Nuxt backends (Nitro/H3). The gRPC call, ability reconstruction, audit logging, and env var defaults are identical to the NestJS integration — only the wiring differs.
Architecture
Frontend request
→ 01.auth.ts (sets event.context.user from Firebase JWT)
→ withActionGuard (gRPC CheckPermission → hrm-backend)
├─ 403 if denied
└─ event.context.user.ability = rebuilt CASL abilities
→ handler (runs only when granted)Setup
1. Initialize the client once at startup
Create a Nitro server plugin. It runs once when the server boots, same as ActionGuardModule.forRoot() in NestJS.
// server/plugins/actionguard.ts
import { initActionGuardClient } from '@feedmepos/hrm-actionguard/nuxt';
export default defineNitroPlugin(() => {
// Zero config — reads the same env vars as the NestJS module:
// PERMISSION_CHECK_TARGET, PERMISSION_CHECK_GRPC_URL, HRM_INTERNAL_TOKEN, etc.
initActionGuardClient();
});To override specific settings:
initActionGuardClient({
grpcUrl: 'localhost:5001',
grpcMode: 'insecure',
token: 'my-token',
timeoutMs: 8000,
});2. Protect API routes with withActionGuard
Replace defineEventHandler with withActionGuard. It requires event.context.user to already be set by a prior auth middleware.
// server/api/businesses/[businessId]/members.get.ts
import { withActionGuard } from '@feedmepos/hrm-actionguard/nuxt';
import { Permission } from '@feedmepos/hrm-permission';
export default withActionGuard(
{
level: Permission.Level.business,
action: Permission.Action.read,
subject: Permission.Subject.Business.hrm_teamMember,
operationLabel: 'List team members',
},
async (event) => {
const businessId = event.context.params.businessId;
return Services.member.list(businessId);
}
);Action Metadata
withActionGuard accepts the same Action shape as the NestJS @Action() decorator.
| Field | Type | Description |
| ----------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| level | 0 \| 1 \| 2 | Permission level: 0=FeedMe, 1=Business, 2=Restaurant |
| subject | string \| ((ctx: OperationContext) => string) | Permission subject. Pass a function for dynamic subjects resolved from request context. |
| action | string | CASL action: "manage", "read", "create", "update", "delete" |
| field? | string | Optional field-level permission |
| coverSubject? | string \| ((ctx: OperationContext) => string) | OR fallback subject — access granted if user can perform action on either subject or coverSubject |
| skipValidation? | boolean | Skip permission check (allow through — logs outcome as skipped in audit log) |
| operationLabel? | string \| ((ctx: OperationContext) => string) | Human-readable label surfaced in audit log metadata |
Dynamic subjects and labels
// server/api/businesses/[businessId]/reports/[reportKey].get.ts
import { withActionGuard } from '@feedmepos/hrm-actionguard/nuxt';
import { Permission } from '@feedmepos/hrm-permission';
export default withActionGuard(
{
level: Permission.Level.business,
action: Permission.Action.read,
// Each custom report has its own subject: business::report::reports::<reportKey>
subject: ({ params }) => `business::report::reports::${params.reportKey}`,
// Fallback: grant access if user has access to all custom reports
coverSubject: Permission.Subject.Business.report_reports_allCustomReports,
operationLabel: ({ params }) => `View custom report ${params.reportKey}`,
},
async (event) => { ... }
)Accessing Ability in the Handler
After withActionGuard passes, event.context.user.ability contains reconstructed CASL Ability objects keyed by level, identical to req.user.ability in NestJS.
import { withActionGuard } from '@feedmepos/hrm-actionguard/nuxt';
import type { ActionGuardUser } from '@feedmepos/hrm-actionguard/nuxt';
import { Permission } from '@feedmepos/hrm-permission';
export default withActionGuard(
{
level: Permission.Level.business,
action: Permission.Action.read,
subject: Permission.Subject.Business.hrm_teamMember,
operationLabel: 'List team members',
},
async (event) => {
const user = event.context.user as ActionGuardUser;
const ability = user.ability?.[Permission.Level.business];
// Fine-grained check beyond what withActionGuard already enforced
const canDelete = ability?.can(Permission.Action.delete, Permission.Subject.Business.hrm_teamMember);
// Parsed body is cached on context — no need to call readBody() again
const body = event.context.parsedBody;
...
}
);Frontend Route Guard (Vue Router)
For mf-billing or any Vue Router based frontend, add a validationManifest to route meta and check it in router.beforeEach. This is a UX guard only — the real security boundary is withActionGuard on the server.
1. Add permission meta to routes
// router/merchant.ts
{
path: '/businesses/:businessId/members',
name: 'Team Members',
meta: {
validationManifest: {
level: 1,
action: 'read',
subject: 'business::hrm::teamMember',
}
},
component: () => import('@/views/members/index.vue')
}2. Check in router.beforeEach
// router/index.ts
router.beforeEach(async (to) => {
const manifest = to.meta?.validationManifest;
if (!manifest) return;
try {
await $fetch('/api/authz/can', {
method: 'POST',
body: {
...manifest,
routeParams: to.params,
routePath: to.path,
},
});
} catch (e: any) {
if (e?.response?.status === 403) return navigateTo('/403');
}
});3. Expose a lightweight /api/authz/can endpoint on the backend
// server/api/authz/can.post.ts
import { getActionGuardClient } from '@feedmepos/hrm-actionguard/nuxt';
import type { ActionGuardUser } from '@feedmepos/hrm-actionguard/nuxt';
export default defineEventHandler(async (event) => {
const user = event.context.user as ActionGuardUser;
if (!user?.uid) throw createError({ statusCode: 401 });
const body = await readBody(event);
const result = await getActionGuardClient().checkPermission({
userId: user.uid,
role: user.role ?? '',
businessId: (body.routeParams?.businessId as string) ?? '',
restaurantId: (body.routeParams?.restaurantId as string) ?? '',
level: body.level,
action: body.action,
subject: body.subject,
requestPath: body.routePath ?? '',
requestMethod: 'GET',
country: process.env.FEEDME_COUNTRY ?? 'unknown',
operationLabel: 'Frontend route guard check',
});
if (!result.granted) throw createError({ statusCode: 403, statusMessage: 'Forbidden' });
return { granted: true };
});Environment Variables
Same env vars as the NestJS module — no extra configuration required for internal cluster deployments.
| Variable | Default | Description |
| ---------------------------- | ---------------------- | ---------------------------------------------------------------------- |
| HRM_INTERNAL_TOKEN | 'Greetings_from_hrm' | Bearer token for gRPC auth |
| PERMISSION_CHECK_TARGET | (unset = internal) | hr-backend (SSL, remote) or internal (insecure, hr-backend:5001) |
| PERMISSION_CHECK_GRPC_URL | derived from target | Manual URL override |
| PERMISSION_CHECK_GRPC_MODE | derived from target | Manual mode override: ssl | insecure |
Exports
// Types only
import type { Action } from '@feedmepos/hrm-actionguard';
// NestJS Guard, Decorator & Module
import {
ActionGuard,
Action,
ActionGuardModule,
PERMISSION_CHECK_PROTO_PATH,
PERMISSION_CHECK_PACKAGE_NAME,
} from '@feedmepos/hrm-actionguard/nestjs';
import type { ActionMetadata } from '@feedmepos/hrm-actionguard/nestjs';
// Nuxt / Nitro integration
import {
withActionGuard,
initActionGuardClient,
getActionGuardClient,
closeActionGuardClient,
} from '@feedmepos/hrm-actionguard/nuxt';
import type { ActionGuardUser, ActionGuardConfig } from '@feedmepos/hrm-actionguard/nuxt';Build Notes
This package is built with Vite (esbuild). Three critical configurations:
minify: false— esbuild minifies class names to empty strings, causing NestJS to silently skip the guard. Minification must be disabled to preserveActionGuard.name.@Inject()decorators — esbuild does not supportemitDecoratorMetadata, sodesign:paramtypesis not emitted. Constructor params must use explicit@Inject().zlibexternalized —zlibis a Node.js built-in used for gzip compression. It must be listed inbuild.rollupOptions.externalso Vite does not attempt to bundle it.
Related Packages
- @feedmepos/hrm-permission — Permission service and CASL integration
- @casl/ability — Authorization library
