Kynetra FX

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.

src/rbac.ts
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 to admin.
  • ns:* — namespace wildcard; matches all permissions in the given namespace (e.g. issues:* matches issues: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.

src/rbac.ts
const rbac = createRbac(roles)
// rbac.can(principal, permission) -> boolean
// rbac.requirePermission(permission) -> FXMiddleware
// rbac.requireRole(role) -> FXMiddleware

Route 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.

src/routes/issues.ts
import { rbac } from '../rbac'
 
// Single permission guard
app.get('/issues', rbac.requirePermission('issues:read'), async (ctx) => {
return ctx.json({ issues: await listIssues() })
})
 
// Role guard
app.delete('/issues/:id', rbac.requireRole('admin'), async (ctx) => {
await deleteIssue(ctx.params.id)
return ctx.json({ deleted: true })
})
 
// Stack multiple guards — all must pass
app.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.

src/services/issue.ts
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?) -> boolean
const allowed = can(principal, 'issues:read', roles)
 
// permissionMatches(granted, required) -> boolean
permissionMatches('issues:*', 'issues:delete') // true
permissionMatches('billing:*', 'issues:read') // false
 
// expandPermissions(principal, roles?) -> string[]
const perms = expandPermissions(principal, roles)
// ['issues:read', 'projects:read', ...]

Standalone guard options

NameTypeDescription
requirePermission(perm, roles?)FXMiddlewareReturns middleware that enforces a permission check. Accepts optional roles map to use instead of the instance default.
requireRole(role)FXMiddlewareReturns middleware that checks whether the principal holds a specific role string.
can(principal, perm, roles?)booleanSynchronous permission check. Returns true if the principal holds the permission via any of its roles.
permissionMatches(granted, required)booleanLow-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.

src/auth/principal.ts
import { jwtAuth } from '@kynetra/fx-auth'
import { kernel } from './kernel'
 
// Enrich the principal with live roles from the database
const 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

RBAC in Kynetra FX is intentionally decoupled from the SaaS kernel. You can use @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():

src/app.ts
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 })
})
})