Skip to main content
The @ooneex/role package provides config-agnostic role-based access control (RBAC). You declare your roles and how they inherit one another in a single configuration, and the Role class answers one question at a time: does a user’s role grant a required role through the hierarchy? Roles attach to the user; routes declare which roles may reach them; the runtime checks the two against the inheritance graph before a controller runs.

Why roles

  • Config-agnostic. You define your own role names and hierarchy in YAML (or in code) — the package ships sensible defaults but imposes no fixed set.
  • Hierarchy, not duplication. A role inherits its ancestors, so granting ROLE_ADMIN automatically grants everything ROLE_USER can do. No copy-pasting grants across roles.
  • Type-safe. Role identifiers are Uppercase<string>, and generateRolesTypes turns your config into a literal union so typos fail at compile time.
  • Zero dependencies. Pure graph traversal — works in the browser and on Bun, with nothing to install at runtime.
  • Framework integration. The roles field on a route plugs straight into Ooneex routing and the auth pipeline.

How it works

Authorization resolves against the inheritance graph. A user holds a role; a route requires one or more roles; access is granted when the user’s role is a required role or inherits it (directly or transitively). Siblings on different branches never satisfy each other.
StageWhat happens
ConfigRoles and their inherits edges are declared in roles.yml (or a RolesConfigType object).
ValidationvalidateConfig checks required keys exist and every inherits target is defined, throwing RoleException otherwise.
ResolutionRole.getInheritedRoles(role, config) walks the graph, returning ancestors first and the role itself last.
EnforcementRole.hasRole(userRole, requiredRole, config) returns true when the user’s role is or inherits the required role.

The roles.yml configuration

A config has two sections. roles maps short keys to their full ROLE_* identifiers; hierarchy describes each role’s inherits edges and a human description. Inheritance flows upward — a child lists the parents it absorbs.
roles.yml
roles:
  GUEST: ROLE_GUEST
  TRIAL_USER: ROLE_TRIAL_USER
  USER: ROLE_USER
  PREMIUM_USER: ROLE_PREMIUM_USER
  MODERATOR: ROLE_MODERATOR
  MANAGER: ROLE_MANAGER
  ADMIN: ROLE_ADMIN
  SUPER_ADMIN: ROLE_SUPER_ADMIN
  SYSTEM: ROLE_SYSTEM

hierarchy:
  ROLE_GUEST:
    description: Unauthenticated visitor with read-only access to public content

  ROLE_TRIAL_USER:
    inherits: [ROLE_GUEST]
    description: Registered user on a limited trial period

  ROLE_USER:
    inherits: [ROLE_TRIAL_USER]
    description: Standard authenticated user with full access to core features

  ROLE_PREMIUM_USER:
    inherits: [ROLE_USER]
    description: Paid subscriber with access to premium features

  ROLE_MODERATOR:
    inherits: [ROLE_USER]
    description: Community moderator who can manage posts and reports

  ROLE_MANAGER:
    inherits: [ROLE_USER]
    description: Operational manager with team and resource tools

  ROLE_ADMIN:
    inherits: [ROLE_MANAGER]
    description: Application administrator with full control

  ROLE_SUPER_ADMIN:
    inherits: [ROLE_ADMIN]
    description: Super administrator with unrestricted access across all tenants

  ROLE_SYSTEM:
    inherits: [ROLE_SUPER_ADMIN]
    description: Internal system identity for automated processes
The config is exported ready to use as rolesConfig:
import { rolesConfig } from "@ooneex/role";

Role naming convention

Role identifiers are uppercase and prefixed with ROLE_ — they are typed as Uppercase<string>. The same identifiers appear everywhere a role is referenced: in the hierarchy keys, in a user’s assigned roles, and in the roles array on a route. Routing declares the field as roles: Uppercase<string>[], so a protected route reads roles: ["ROLE_ADMIN", "ROLE_SUPER_ADMIN"]. Keeping the convention consistent is what lets the generated types catch a misspelled role.

The Role class

Role is the access-control engine. Construct it with no arguments and pass your config to each call — it holds no state.
import { Role } from "@ooneex/role";

const role = new Role();
MethodSignatureReturns
hasRolehasRole(userRole: Uppercase<string>, requiredRole: Uppercase<string>, config: RolesConfigType)booleantrue when userRole is or inherits requiredRole; false for unknown roles or siblings.
getInheritedRolesgetInheritedRoles(role: Uppercase<string>, config: RolesConfigType)Uppercase<string>[] — every role inherited, ancestors first, ending with the role itself; [] if the role is unknown.
import { Role, rolesConfig } from "@ooneex/role";

const role = new Role();

role.hasRole("ROLE_ADMIN", "ROLE_USER", rolesConfig); // true  — admin inherits user
role.hasRole("ROLE_USER", "ROLE_ADMIN", rolesConfig); // false — user does not inherit admin
role.hasRole("ROLE_MODERATOR", "ROLE_MANAGER", rolesConfig); // false — siblings, no inheritance

role.getInheritedRoles("ROLE_ADMIN", rolesConfig);
// ["ROLE_GUEST", "ROLE_TRIAL_USER", "ROLE_USER", "ROLE_MANAGER", "ROLE_ADMIN"]
Both methods are described by the IRole interface, so you can swap in your own implementation where one is expected.

Validating the config

validateConfig(config) enforces the contract before the config is trusted. It checks that the required role keys exist (GUEST, TRIAL_USER, USER, PREMIUM_USER, ADMIN, SUPER_ADMIN, SYSTEM), that every role maps to a hierarchy entry, that each entry has a non-empty description, and that every inherits target is itself defined. Any failure throws a RoleException. Run it once at startup or in a test so a broken config never ships.
import { rolesConfig, validateConfig } from "@ooneex/role";

validateConfig(rolesConfig); // throws RoleException on the first problem found

Generating role types

generateRolesTypes(config) returns a string of TypeScript that turns your config into literal types — a RoleType union of role keys, a RoleHierarchyRoleType union of hierarchy roles, and a TypedRolesConfigType that ties them together. Writing this output to a .ts file gives you compile-time safety: referencing a role that does not exist becomes a type error instead of a silent runtime miss.
import { generateRolesTypes, rolesConfig } from "@ooneex/role";

const source = generateRolesTypes(rolesConfig);
await Bun.write("roles.types.ts", source);
// export type RoleType = "GUEST" | "TRIAL_USER" | "USER" | ...;
Regenerate the file whenever you change roles.yml so the types stay in sync with the config.

Enforcing access on a route

A route declares the roles permitted to reach it through the roles field. The runtime resolves the request’s user, reads the user’s role, and grants access only when it satisfies one of the listed roles through the hierarchy — so listing ROLE_MANAGER also admits ROLE_ADMIN and ROLE_SUPER_ADMIN. Roles answer who a user is; permissions answer what they may do — the two compose.
import { Route } from "@ooneex/routing";
import type { ContextType, IController, IResponse } from "@ooneex/controller";

@Route.http({
  name: "admin.users.list",
  path: "/admin/users",
  method: "GET",
  description: "List all users (admin only)",
  roles: ["ROLE_ADMIN", "ROLE_SUPER_ADMIN"],
})
class AdminUserListController implements IController {
  public async index(context: ContextType): Promise<IResponse> {
    return context.response.json({ users: await this.userService.findAll() });
  }
}
A request from a user without a qualifying role is rejected before the controller runs. See routing for how roles sits alongside permission, env, ip, and host in the access-control checks.

RoleException

RoleException extends the framework Exception and is thrown for role failures — a malformed config or a denied check. It carries the offending role as its key and resolves to HTTP 403 Forbidden, so a denial surfaces as the correct status without extra mapping.
import { RoleException } from "@ooneex/role";

throw new RoleException("Role is not defined in hierarchy", "ROLE_GHOST");
// status: 403 Forbidden, key: "ROLE_GHOST"

Best practices

  • Prefer hierarchies over flat grants. Model inherits edges instead of duplicating the same role across many routes; granting an ancestor grants its descendants’ reach automatically.
  • Keep role names stable. Routes, users, and stored data all reference the ROLE_* identifiers — renaming one is a breaking change across the system.
  • Validate at startup. Call validateConfig early (or in a test) so an undefined inherits target or missing description fails fast rather than at request time.
  • Regenerate types after config changes. Re-run generateRolesTypes whenever you edit roles.yml so a typo’d role is a compile error, not a silent denial.
  • List the lowest role that qualifies. Because higher roles inherit lower ones, name the minimum required role on a route and let the hierarchy admit everyone above it.
  • Compose with permissions. Use roles for broad identity tiers and permissions for fine-grained actions; combine both on sensitive routes.