Multi-Level RBAC Done Right: Module → Feature → Resource
Role flags do not survive contact with a real enterprise app. Here is the three-tier access model — module, feature, resource — I built for a large manufacturing dashboard, and why each tier earns its place.

On this page
Most apps start with role: 'admin' | 'user' and a few if checks. That holds until the first customer says “this manager can see reports but must not export them, and only for their own department.” On a large garment-manufacturing dashboard at BEXIMCO, access control was the product — so a boolean role was never going to cut it. We modelled permissions in three tiers.
The three tiers
- Module — a whole area of the app (e.g. Production, Reports, Admin). Coarse, the first gate.
- Feature — a capability inside a module (View reports, Export reports, Edit production line).
- Resource — a specific object the feature acts on (this report, this department, this line).
A request is allowed only if the user clears all three: they have the module, the feature within it, and access to the specific resource. Each tier narrows the last.
Permissions attach to users, roles, and groups
Granting per-user does not scale; only-roles is too rigid. So permissions can be assigned to a user directly, a role, or a group, and a user’s effective set is the union of all three. New hire? Drop them in a group and they inherit the right access instantly.
// Effective permissions = direct ∪ role-derived ∪ group-derived
async function effectivePermissions(userId: string): Promise<Set<string>> {
const [direct, viaRoles, viaGroups] = await Promise.all([
perms.forUser(userId),
perms.forRolesOf(userId),
perms.forGroupsOf(userId),
]);
return new Set([...direct, ...viaRoles, ...viaGroups]);
}Enforcing it without if-soup
The check belongs in one place — a guard — keyed by a declarative permission string like reports:export. Controllers declare what they need; the guard resolves the rest:
@RequirePermission('reports:export')
@Get('reports/:id/export')
exportReport(@Param('id') id: string, @CurrentUser() user: AuthUser) {
// Module + feature already verified by the guard.
// Resource-level check stays close to the data:
return this.reports.exportForDepartment(id, user.departmentIds);
}Module and feature are generic enough to verify centrally. Resource scoping lives next to the query — usually as a WHERE department_id = ANY(:ids) clause — because only the data layer knows what “owns” a row. Trying to centralize resource checks is how you end up with a permission engine nobody understands.
Why three tiers, not two
You could merge feature and resource, but you lose the ability to say “can export, in general” separately from “can export this one.” Keeping them apart means the UI can hide the Export button (no feature) without running an expensive per-row check, and the API can still enforce ownership when the button is present. Cheap UX decisions up top, authoritative checks at the bottom.
RBAC is not glamorous, but in enterprise software it is often the feature. Model it in tiers, let permissions flow through groups, enforce the coarse tiers in a guard and the fine tier in the query — and it stays comprehensible as the app grows.
Comments
Loading comments…