Documentation
RBAC
@kynetra/fx-rbac provides role-based access control decoupled from any specific SaaS model. Define roles as permission sets, create an RBAC instance, and guard routes with requirePermission or requireRole.
Defining roles#
defineRoles(map) takes an object whose keys are role names and values are arrays of permission strings. Permissions follow an optional namespace:action convention, though any string works.
import { defineRoles, createRbac } from '@kynetra/fx-rbac' const roles = defineRoles({ admin: ['*'], // wildcard: all permissions manager: ['issues:*', 'projects:*'], // namespace wildcard member: ['issues:read', 'issues:create', 'projects:read'], viewer: ['issues:read', 'projects:read'], billing: ['billing:*'],}) export const rbac = createRbac(roles)Wildcard permissions
*— super-wildcard; matches every permission. Assign toadmin.ns:*— namespace wildcard; matches all permissions in the given namespace (e.g.issues:*matchesissues:read,issues:create,issues:delete, etc.).
Wildcard expansion is performed by permissionMatches(granted, required) andexpandPermissions(principal, roles?) which are also exported for custom use.
Creating an RBAC instance#
createRbac(roles) returns an object with three members: the can checker and two route guard factories.
const rbac = createRbac(roles)// rbac.can(principal, permission) -> boolean// rbac.requirePermission(permission) -> FXMiddleware// rbac.requireRole(role) -> FXMiddlewareRoute guards#
rbac.requirePermission(perm) and rbac.requireRole(role) return middleware. Place them inline on a route or in a group middleware stack. Both read the current Principal via getPrincipal(ctx) and return 403 FX_FORBIDDEN if the check fails.
import { rbac } from '../rbac' // Single permission guardapp.get('/issues', rbac.requirePermission('issues:read'), async (ctx) => { return ctx.json({ issues: await listIssues() })}) // Role guardapp.delete('/issues/:id', rbac.requireRole('admin'), async (ctx) => { await deleteIssue(ctx.params.id) return ctx.json({ deleted: true })}) // Stack multiple guards — all must passapp.post('/issues/:id/escalate', rbac.requirePermission('issues:read'), rbac.requirePermission('issues:escalate'), async (ctx) => { return ctx.json({ escalated: true }) })Standalone functions#
The standalone exports let you use RBAC logic outside of route middleware — in business logic, event handlers, or background jobs.
import { can, requirePermission, requireRole, permissionMatches, expandPermissions,} from '@kynetra/fx-rbac'import { defineRoles } from '@kynetra/fx-rbac' const roles = defineRoles({ admin: ['*'], member: ['issues:read'] }) // can(principal, permission, roles?) -> booleanconst allowed = can(principal, 'issues:read', roles) // permissionMatches(granted, required) -> booleanpermissionMatches('issues:*', 'issues:delete') // truepermissionMatches('billing:*', 'issues:read') // false // expandPermissions(principal, roles?) -> string[]const perms = expandPermissions(principal, roles)// ['issues:read', 'projects:read', ...]Standalone guard options
| Name | Type | Description |
|---|---|---|
| requirePermission(perm, roles?) | FXMiddleware | Returns middleware that enforces a permission check. Accepts optional roles map to use instead of the instance default. |
| requireRole(role) | FXMiddleware | Returns middleware that checks whether the principal holds a specific role string. |
| can(principal, perm, roles?) | boolean | Synchronous permission check. Returns true if the principal holds the permission via any of its roles. |
| permissionMatches(granted, required) | boolean | Low-level wildcard matcher. Tests whether a single granted permission string covers a required one. |
| expandPermissions(principal, roles?) | string[] | Returns all resolved permission strings for a principal, expanding wildcards. |
FX_FORBIDDEN#
When a guard fails, the framework responds with a structured JSON error body matching the standard FX error format:
// HTTP 403{ "error": { "code": "FX_FORBIDDEN", "message": "Forbidden" }}Integrating with the SaaS kernel#
When using the SaaS kernel, roles are stored in the roles and memberships repositories. You can read them at request time and pass them to can() or populate principal.roles in the toPrincipal mapping of jwtAuth.
import { jwtAuth } from '@kynetra/fx-auth'import { kernel } from './kernel' // Enrich the principal with live roles from the databaseconst strategy = jwtAuth({ secret: env.JWT_SECRET, toPrincipal: async (payload) => { const membership = await kernel.memberships.list({ userId: payload.sub as string, organizationId: payload.tenantId as string, }) return { id: payload.sub as string, type: 'user' as const, roles: membership[0]?.role ? [membership[0].role] : [], scopes: [], tenantId: payload.tenantId as string | undefined, } },})Note
@kynetra/fx-rbac in any application — it has no dependency on @kynetra/fx-saas.Group-level RBAC#
Apply a guard to an entire route group using app.group() with g.use():
app.group('/admin', (g) => { g.use(rbac.requireRole('admin')) // all routes under /admin require admin role g.get('/users', async (ctx) => { return ctx.json({ users: await kernel.users.list() }) }) g.delete('/users/:id', async (ctx) => { await kernel.users.delete(ctx.params.id) return ctx.json({ deleted: true }) })})