@ambushsoftworks/nestjs-rbac
v0.1.0
Published
Framework-agnostic RBAC core for NestJS: per-account role/permission grant management with subset-confinement, anti-self-escalation, cross-tenant pinning, and anti-lockout guarantees
Maintainers
Readme
@ambushsoftworks/nestjs-rbac
A framework-agnostic RBAC grant-management core for NestJS.
It ships the hard part of role/permission administration — the security mechanism — and lets your app supply the policy (the role→permission matrix) and the persistence (how grants are stored). The core has zero Prisma, ORM, or domain coupling: NestJS is the only (peer) dependency.
The headline guarantee is subset-confinement: an administrator can only ever grant a subset of the access they themselves hold. Combined with anti-self-escalation, a cross-tenant pin, and anti-lockout, this closes the classic privilege-escalation holes in self-service role management — and the checks are unconditional throws in the service body, not opt-in guards.
Install
npm install @ambushsoftworks/nestjs-rbacNestJS packages are peer dependencies (so your app dedupes a single NestJS install):
npm install @nestjs/common @nestjs/core reflect-metadata rxjsWhat it does
The core service, PermissionAdminService, manages per-account, DIVISION-scoped
grants:
grantRole/revokeRole/changeMemberRolegrantPermission/revokePermission(direct-permission exceptions)listRoles/listPermissions/effectiveGrants(catalog + introspection)assertCanGrantRole(caller-side confinement check, e.g. when creating a new account + its first role in one transaction)
Every mutating operation enforces, unconditionally:
| Guarantee | What it prevents | |---|---| | Subset-confinement | Granting any role/permission whose effect exceeds the caller's own effective permission set (privilege escalation). | | Anti-self-escalation | Granting a role/permission to one's own account. | | Cross-tenant pin | Managing an account that lives in a different division/tenant than the caller — no super-admin bypass on this surface. | | Anti-lockout | Revoking/demoting the last active "owner" of a division. The owner re-count happens inside the revoke transaction, so concurrent last-owner revokes can't both commit. | | DIVISION-only scope | Persisting malformed CLIENT/SERVICE-scoped grants on a surface that carries no resource id. |
Errors are plain, transport-free subclasses you map to HTTP/GraphQL in your app:
RbacForbiddenError→ 403RbacNotFoundError→ 404RbacConflictError→ 409
The interfaces you implement
You provide three things. None of them leak back into the core.
1. IPermissionAdminStore — persistence
How role grants and direct-permission grants are stored, read, soft-revoked,
and counted. Implement it over your ORM/db. Key methods:
getAccountDivisionId, getEffectiveGrants, hasActiveRole,
hasActivePermission, grantRole, revokeRole, grantPermission,
revokePermission, countActiveRoleHoldersInDivision, and the atomic
revokeOwnerRoleIfNotLast (the anti-lockout race guard — implement this inside
one transaction).
2. IRoleCatalog — policy (the vocabulary)
Your code-defined role→permission matrix. listRoles, listPermissions,
getRolePermissions(roleCode), hasPermission(code). This stays in your app
because the matrix is policy, not mechanism.
3. ownerRoleCodes — anti-lockout policy
The role codes that anchor anti-lockout (at least one active holder must always
remain in a division), e.g. ['ORG_OWNER', 'SUPER_ADMIN'].
Optionally, IAccessGrantStore — the bare relationship-grant primitive
("account X has {read,write,share,delete} on resource-group Y"). Only needed if
you consume the ACCESS_GRANT_STORE token; the management service does not
require it.
Usage
Synchronous
import { Module } from '@nestjs/common';
import { RbacModule } from '@ambushsoftworks/nestjs-rbac';
import { MyPermissionAdminStore } from './rbac/my-permission-admin.store';
import { MyRoleCatalog } from './rbac/my-role-catalog';
@Module({
imports: [
RbacModule.forRoot({
permissionAdminStore: new MyPermissionAdminStore(/* db */),
roleCatalog: new MyRoleCatalog(),
ownerRoleCodes: ['ORG_OWNER', 'SUPER_ADMIN'],
}),
],
})
export class AppModule {}Async (instances come from DI)
import { RbacModule } from '@ambushsoftworks/nestjs-rbac';
RbacModule.forRootAsync({
imports: [PrismaModule],
inject: [MyPermissionAdminStore, MyRoleCatalog],
useFactory: (store: MyPermissionAdminStore, catalog: MyRoleCatalog) => ({
permissionAdminStore: store,
roleCatalog: catalog,
ownerRoleCodes: ['ORG_OWNER', 'SUPER_ADMIN'],
}),
});Consuming the service
Inject the service by token and expose it through your own resolver/controller, mapping the core errors to your transport:
import { Inject, Injectable } from '@nestjs/common';
import {
PERMISSION_ADMIN_SERVICE,
PermissionAdminService,
} from '@ambushsoftworks/nestjs-rbac';
@Injectable()
export class MembersService {
constructor(
@Inject(PERMISSION_ADMIN_SERVICE)
private readonly rbac: PermissionAdminService,
) {}
changeRole(callerAccountId: string, callerDivisionId: string, targetAccountId: string, newRoleCode: string) {
return this.rbac.changeMemberRole({
callerAccountId,
callerDivisionId,
targetAccountId,
newRoleCode,
});
}
}Exports
RbacModule(+RbacModuleOptions,RbacModuleAsyncOptions)PermissionAdminService- Interfaces:
IPermissionAdminStore,IRoleCatalog,IAccessGrantStore - Errors:
RbacError,RbacForbiddenError,RbacNotFoundError,RbacConflictError - Tokens:
PERMISSION_ADMIN_SERVICE,PERMISSION_ADMIN_STORE,ROLE_CATALOG,OWNER_ROLE_CODES,ACCESS_GRANT_STORE - Plain types:
GrantScope,EffectiveGrants,RoleCatalogEntry,PermissionCatalogEntry,AccessGrant, and the grant/revoke input types.
A note on scope
This management surface is DIVISION-only by design — "division" being the generic tenant/workspace boundary. Narrower CLIENT/SERVICE-scoped grants (which carry a resource id) are composed by your own adapters, not this surface; it rejects them rather than persist malformed rows. Cross-tenant and cross-org administration belong to a separate platform-admin layer in your app, not here.
License
MIT © Ambush Softworks
