Authorization in OrangeHRM
OrangeHRM has one authorization mechanism applied at two layers: REST endpoints (via data groups) and Vue/page controllers (via screens). Both layers use the same user-role table and the same marker-interface escape hatch for public routes. Get the shared model right first, then the path-specific details.
Foundation (shared by both paths)
The three-gate flow
Every request that reaches a controller passes through three Symfony event subscribers (all listening on KernelEvents::CONTROLLER):
| Priority | Subscriber | Question | On fail |
|---|---|---|---|
| 100000 | AuthenticationSubscriber |
Logged in? User still active, not terminated, last-modified token still valid? | Pages: SessionExpiredException → 302 to /auth/login. REST: UnauthorizedException → 401 JSON. |
| 80000 | ScreenAuthorizationSubscriber |
Does any of the user's effective roles have can_read on this module+screen? Then if controller is a CapableViewController, does isCapable() return true? |
Forwards to ForbiddenController (a public Vue page rendering 403). |
| 80000 | ApiAuthorizationSubscriber |
Does any of the user's effective roles have the CRUD bit matching the HTTP verb on this Endpoint's data group? | ForbiddenException → 403 JSON {error:{status:403,message:"Unauthorized"}}. |
The screen subscriber only acts when the controller is an AbstractViewController (i.e. a page). The API subscriber only acts when the controller is an AbstractRestController. The auth subscriber acts on all of them.
The single switch: PublicControllerInterface
OrangeHRM\Core\Controller\PublicControllerInterface is an empty marker interface. Implementing it on a controller class makes all three subscribers return early. This is the only mechanism for making a route public — there is no per-route flag in routes.yaml, no config, no role called "anonymous". The class either implements it or doesn't.
Effective user roles (computed per request)
BasicUserRoleManager::computeUserRoles(User) produces the role set authorization checks against:
roles = [user.userRole] // the static role from `users.user_role_id` (Admin or ESS)
roles += ESS // everyone is at least ESS
if isSupervisor(empNumber) → +Supervisor
if isProjectAdmin(empNumber) → +ProjectAdmin
if isHiringManager(empNumber) → +HiringManager
if isInterviewer(empNumber) → +Interviewer
if isTrackerReviewer(empNumber) → +Reviewer
So an Admin who also supervises someone evaluates against [Admin, ESS, Supervisor]. Clients may add custom roles on top — those come from users.user_role_id like Admin/ESS, not from the dynamic checks.
OR-merge semantics
When multiple roles match the same resource, their permissions are OR-merged: any role granting read = read granted. Most-permissive wins. There is no deny rule, no priority order — only union.
Path A — REST endpoint authorization
The model
Module ──┐
├── ApiPermission (api_name = Endpoint FQCN)
DataGroup┘ table: ohrm_api_permission
│ ┌── UserRole
└── DataGroupPermission (can_read/create/update/delete, self) ──┘
table: ohrm_user_role_data_group
Entities (all in src/plugins/orangehrmCorePlugin/entity/):
DataGroup(ohrm_data_group) — a permission scope (typical nameapiv2_<thing>). Its own CRUD flags are the capability ceiling for the scope, not what any user gets.ApiPermission(ohrm_api_permission) — binds aDataGroupto a specific Endpoint by FQCN (api_name) within aModule. The route's_apiattribute is the lookup key into this table.DataGroupPermission(ohrm_user_role_data_group) — the actual grant row:(user_role_id, data_group_id, can_read, can_create, can_update, can_delete, self). This is where the yes/no decision lives.UserRole(ohrm_user_role) — Admin, ESS, Supervisor, ProjectAdmin, HiringManager, Interviewer, Reviewer, plus custom client roles.
Runtime resolution
- Symfony route resolves;
_apiattribute holds the Endpoint FQCN (set inroutes.yaml). ApiAuthorizationSubscriber::onControllerEventreads_api.UserRoleManager::getApiPermissions($apiClass)→DataGroupService::getApiPermissions($apiClass, $userRoles).- SQL joins
ohrm_api_permission→ohrm_data_group→ohrm_user_role_data_groupfiltered by the effective role IDs. - Every matching row is OR-merged into a single
ResourcePermission. - HTTP verb → CRUD bit:
GET→canRead,POST→canCreate,PUT→canUpdate,DELETE→canDelete. - Bit false →
ForbiddenException→ 403.
The self flag
self: true does not restrict by itself. It means "this grant is conditional on the row belonging to the current user." The Endpoint code must enforce the ownership check. Typical pattern: read the data group permission with getDataGroupPermissions(..., $selfPermission = true), and gate the operation on $resourcePermission->isSelf() && $entityOwnedByCurrentUser.
Recipes
Add an authenticated REST endpoint
- Endpoint class in
src/plugins/orangehrm{X}Plugin/Api/:namespace OrangeHRM\X\Api; class WidgetAPI extends Endpoint implements CrudEndpoint { /* … */ } - Route in
src/plugins/orangehrm{X}Plugin/config/routes.yamlpointing at the gated controller:apiv2_x_widgets: path: /api/v2/x/widgets controller: OrangeHRM\Core\Controller\Rest\V2\GenericRestController::handle methods: [ GET, POST ] defaults: _api: OrangeHRM\X\Api\WidgetAPI - Seed permissions via a migration (see "Minimum viable migration stub" below). Drop a
permission/api.yaml:
And inapiv2_x_widgets: description: 'X - Widgets' api: OrangeHRM\X\Api\WidgetAPI module: x # must match an ohrm_module.name allowed: # data-group capability ceiling read: true create: true update: true delete: true permissions: - { role: Admin, permission: { read: true, create: true, update: true, delete: true } } - { role: ESS, permission: { read: true, create: false, update: false, delete: false } } - { role: Supervisor, permission: { read: true, create: true, update: false, delete: false, self: true } }Migration.php::up():$this->getDataGroupHelper()->insertApiPermissions(__DIR__ . '/permission/api.yaml');
That single helper call writes the ohrm_data_group row, the ohrm_api_permission row, and one ohrm_user_role_data_group row per permissions: entry.
Add a public REST endpoint
The Endpoint class itself stays a normal Endpoint — the marker goes on the front controller, not on the Endpoint. Convention: put it in a PublicApi/ directory so it's visually obvious.
- Endpoint in
src/plugins/orangehrm{X}Plugin/PublicApi/(just convention, the namespace doesn't matter to the framework). - Route points at the public generic controller:
apiv2_x_public_thing: path: /api/v2/x/public/thing controller: OrangeHRM\Core\Controller\Rest\V2\GenericPublicRestController::handle methods: [ POST ] defaults: _api: OrangeHRM\X\PublicApi\ThingAPI - No permission rows.
GenericPublicRestController extends GenericRestController implements PublicControllerInterface— that's the entire file. All three gates skip it.
Examples in tree: Authentication\PublicApi\PasswordStrengthValidationAPI, the core version endpoint.
Make an existing authenticated endpoint public
You can't just change the Endpoint class — every Endpoint that uses GenericRestController is authenticated. To make an endpoint public:
- Change
controller:inroutes.yamlfromGenericRestController::handletoGenericPublicRestController::handle. - Move the Endpoint into a
PublicApi/subdir for consistency (optional but conventional). - In a migration, delete the
ohrm_api_permissionrow (and any orphanedohrm_data_group/ohrm_user_role_data_grouprows). They're harmless if left behind — they just stop having any effect — but cleaning them up keeps the permission tables honest.
Debugging a 403 on a REST call
Work through this list in order:
- Is the route hitting the right controller?
grep -n '<path>' src/plugins/*/config/routes.yaml. If you intended public, controller should beGenericPublicRestController::handle. - Is
_apiset on the route?ApiAuthorizationSubscriberthrows immediately if not. Missing_apialways returns 403 with body_api parameter not defined in API routes. - Does
ohrm_api_permissionhave a row for this exact FQCN? RunSELECT * FROM ohrm_api_permission WHERE api_name = 'OrangeHRM\\X\\Api\\WidgetAPI'. (Note the doubled backslashes in SQL string literal.) - Does
ohrm_user_role_data_grouphave a row for the user's role × the data group? Join:SELECT ur.name, dgp.* FROM ohrm_user_role_data_group dgp JOIN ohrm_user_role ur ON ur.id = dgp.user_role_id JOIN ohrm_data_group dg ON dg.id = dgp.data_group_id WHERE dg.name = 'apiv2_x_widgets'. - Is the user's effective role what you think it is? Don't just look at
users.user_role_id— Supervisor / ProjectAdmin / HiringManager / Interviewer / Reviewer are computed, not stored. If the grant is on Supervisor and the user doesn't supervise anyone, they won't get it. - Verb mismatch? A row with
can_read=1, can_create=0answers GET but not POST. The 403 looks identical either way. - Self-scoped? If
self=1, the Endpoint must affirmatively check ownership. Forgetting that check returns 403 to the row's owner too.
Path B — Screen / Vue page authorization
The model
Module ──┐
├── Screen (action_url, e.g. "viewEmployeeList")
│ table: ohrm_screen
│ ├── ScreenPermission (can_read/create/update/delete) ── UserRole
│ │ table: ohrm_user_role_screen
│ └── (optionally) menu_configurator class for nav rendering
Entities:
Screen(ohrm_screen) — one row per page. Identified by(module_id, action_url). Theaction_urlis the URL path segment (without the module prefix), e.g.viewEmployeeList,viewSystemUsers.ScreenPermission(ohrm_user_role_screen) — role × screen × CRUD. The CRUD bits exist on screens too, but practically onlycan_readis checked byScreenAuthorizationSubscriberfor page access; the others can be used by templates to conditionally render edit buttons.
Runtime resolution
- Symfony route resolves to an
AbstractViewController(typically a subclass ofAbstractVueController). ScreenAuthorizationSubscriber::onControllerEventreads the current module + screen from the request (viaModuleScreenHelperTrait::getCurrentModuleAndScreen()).- If
module == 'auth' && screen == 'logout'→ bypass (hardcoded; logout must always be reachable). UserRoleManager::getScreenPermissions($module, $screen)→ joinsohrm_screen→ohrm_user_role_screenfor the user's effective roles → OR-mergedResourcePermission.- If
!canRead()→ForbiddenException→ forwards toForbiddenController(a public Vue page). - If the controller implements
CapableViewController,isCapable($request)is called after the screen check passes. Returning false →ForbiddenException.
CapableViewController — runtime gating on top of static permissions
namespace OrangeHRM\Core\Authorization\Controller;
interface CapableViewController { public function isCapable(Request $request): bool; }
Use when the static role-screen permission isn't expressive enough — for example:
- A screen that's only meaningful when a feature flag is on.
- A screen that depends on a specific employee state (terminated, on probation).
- A screen behind module-availability config (
ModuleNotAvailableSubscriberhandles module-level on/off, butCapableViewControlleris for finer conditions).
Return false → user sees 403 even though their role nominally has access.
There is no equivalent on the REST side — for APIs, throw BadRequestException / ForbiddenException from inside the Endpoint method when the row-level / runtime check fails.
Recipes
Add an authenticated page
- Controller in
src/plugins/orangehrm{X}Plugin/Controller/:namespace OrangeHRM\X\Controller; use OrangeHRM\Core\Controller\AbstractVueController; use OrangeHRM\Core\Vue\Component; class WidgetListController extends AbstractVueController { public function preRender(Request $request): void { $component = new Component('widget-list'); // optionally: $component->addProp(new Prop('foo', Prop::TYPE_NUMBER, 1)); $this->setComponent($component); } } - Route in
routes.yaml:x_view_widget_list: path: /x/viewWidgetList controller: OrangeHRM\X\Controller\WidgetListController::handle - Vue page component at
src/client/src/orangehrm{X}Plugin/pages/widget-list/WidgetList.vueand register it insrc/client/src/pages.ts. - Seed the screen + permissions via a migration. Drop a
permission/screens.yaml:
And inviewWidgetList: name: 'View Widget List' module: x url: viewWidgetList # matches the action_url portion # menu_configurator: OrangeHRM\X\Menu\WidgetListConfigurator # optional permissions: - { role: Admin, permission: { read: true, create: true, update: true, delete: true } } - { role: ESS, permission: { read: true, create: false, update: false, delete: false } }Migration.php::up():$this->getDataGroupHelper()->insertScreenPermissions(__DIR__ . '/permission/screens.yaml');
Add a public page (pre-login)
Just add the marker interface:
class ForgotSomethingController extends AbstractVueController implements PublicControllerInterface
{
public function preRender(Request $request): void { /* … */ }
}
No ohrm_screen or ohrm_user_role_screen rows needed. The route stays normal. Existing examples to copy from: LoginController, RequestPasswordController, ResetPasswordController, WeakPasswordResetController, ForbiddenController, RootController.
Add a conditional page (role permission + runtime check)
Same as authenticated page, plus:
use OrangeHRM\Core\Authorization\Controller\CapableViewController;
class TerminatedEmployeeReportController extends AbstractVueController implements CapableViewController
{
public function isCapable(Request $request): bool
{
return $this->getConfigService()->isTerminationReportingEnabled();
}
}
Make an existing page public
- Add
implements PublicControllerInterfaceto the controller. - In a migration, delete the
ohrm_screenrow (and dependentohrm_user_role_screenrows). Again — harmless if left, cleaner if removed.
Debugging a 403 on a page
- Marker interface intended? If the page should be reachable pre-login and isn't, check whether
implements PublicControllerInterfaceis actually there. ohrm_screenrow?SELECT s.*, m.name AS module FROM ohrm_screen s JOIN ohrm_module m ON m.id = s.module_id WHERE s.action_url = '<screen>'.ohrm_user_role_screenrow for the user's role × screen? Join through.- Effective role? Same caveat as Path A — dynamic roles (Supervisor etc.) aren't in
users.user_role_id. CapableViewController::isCapable()returning false? Trace the implementation; common cause is a missing config row.- Module disabled?
ModuleNotAvailableSubscribershort-circuits when the module is turned off inohrm_module.status. This returns a different page (the disabled-module screen), not a 403, but the symptom of "page won't load" overlaps. - Wrong subclass? Only
AbstractViewControllersubclasses get screen checks. A controller extending something else (e.g.AbstractFileController) bypasses screen authorization and is gated only byAuthenticationSubscriber.
Where permission seeding actually runs
Both permission/api.yaml and permission/screens.yaml are consumed by DataGroupHelper methods called from a migration's up(). The migration mechanics — AbstractMigration base class, the MIGRATIONS_MAP registry, version range execution, the migration:up dev command for iterating — belong to the migrations skill. This skill includes only the minimum stub a permission-only change needs.
Minimum viable migration stub for a permission-only change
When the next version is 5.9.0:
<?php
// installer/Migration/V5_9_0/Migration.php
namespace OrangeHRM\Installer\Migration\V5_9_0;
use OrangeHRM\Installer\Util\V1\AbstractMigration;
class Migration extends AbstractMigration
{
public function up(): void
{
$this->getDataGroupHelper()->insertApiPermissions(__DIR__ . '/permission/api.yaml');
$this->getDataGroupHelper()->insertScreenPermissions(__DIR__ . '/permission/screens.yaml');
}
public function getVersion(): string
{
return '5.9.0';
}
}
Plus the version must be registered in installer/Util/AppSetupUtility.php::MIGRATIONS_MAP:
'5.9' => \OrangeHRM\Installer\Migration\V5_9_0\Migration::class,
That's the entire migration footprint for a permission change. For iterating during development, run it directly without a full reinstall:
php devTools/core/console.php migration:up "\OrangeHRM\Installer\Migration\V5_9_0\Migration"
For anything beyond this — schema changes, conditional column edits, lang strings, multi-step versions, recovering from a half-applied migration — see the migrations skill.
During development without a migration yet
If you're prototyping and don't want to write a migration immediately, two dev commands persist directly to the local DB and print the equivalent SQL for later promotion to a migration:
php devTools/core/console.php add-data-group # data group + (optional) ApiPermission row
php devTools/core/console.php add-role-permission # DataGroupPermission row
Don't ship a feature this way — always land the YAML + migration. These commands exist for fast local iteration.
Quick reference — common tasks
Add a new authenticated REST endpoint
- Create
Api/<Name>API.phpextendingEndpoint+ relevant CRUD interface - Add route to plugin's
config/routes.yamlpointing atGenericRestController::handlewith_apiset to FQCN - Create
permission/api.yamlentry:api,module,allowed(capability ceiling),permissions(role × CRUD,selfif row-scoped) - In a migration
up():$this->getDataGroupHelper()->insertApiPermissions(__DIR__ . '/permission/api.yaml'); - Register migration version in
AppSetupUtility::MIGRATIONS_MAP - If
self: trueon any role, enforce ownership check inside the Endpoint method - Locally:
php devTools/core/console.php migration:up "\…\Migration"to apply without reinstall
Add a new public REST endpoint
- Create
PublicApi/<Name>API.phpextendingEndpoint - Add route pointing at
GenericPublicRestController::handlewith_apiset to FQCN - No permission rows, no migration needed
Add a new authenticated page
- Create controller extending
AbstractVueController; setComponentinpreRender() - Add route to
routes.yaml - Add the Vue component and register it in
src/client/src/pages.ts - Create
permission/screens.yamlentry:name,module,url, optionallymenu_configurator, plus per-rolepermissions - In a migration
up():$this->getDataGroupHelper()->insertScreenPermissions(…); - Register migration version in
MIGRATIONS_MAP
Add a new public page (pre-login)
- Controller extends
AbstractVueControllerandimplements PublicControllerInterface - Normal route
- Vue component +
pages.tsregistration - No
ohrm_screenrow, no migration needed
Make an existing endpoint or page public
- API: swap
controller:in route toGenericPublicRestController::handle; move Endpoint toPublicApi/; (optional) migration to drop the old permission rows - Page: add
implements PublicControllerInterfaceto the controller; (optional) migration to drop the oldohrm_screenrow
Add a runtime condition on top of role permissions (pages only)
- Controller
implements CapableViewController; implementisCapable(Request): bool - No DB changes — purely code-level gating
Debug an unexpected 403 (REST)
- Route uses
GenericRestController(gated) — confirm intent -
_apiattribute set in route defaults -
ohrm_api_permissionrow exists for the Endpoint FQCN -
ohrm_user_role_data_grouprow for user's effective role × the data group, with the CRUD bit for the HTTP verb - If
self=1, ownership check enforced inside the Endpoint - Effective role includes dynamically-derived roles (Supervisor / ProjectAdmin / HiringManager / Interviewer / Reviewer) — not just
users.user_role_id
Debug an unexpected 403 (page)
- Controller implements
PublicControllerInterfaceif it should be public -
ohrm_screenrow exists for the module + action_url -
ohrm_user_role_screenrow for the user's effective role, withcan_read=1 - If
CapableViewController—isCapable()returns true - Effective role caveat applies (same as REST)
- Module is not disabled in
ohrm_module.status