Authorization in OrangeHRM
SkillSecurityReference for OrangeHRM's authorization model — how REST endpoints and Vue/page controllers are gated by authentication, role-based screen/data-group permissions, and the marker interface that opts controllers out for pre-login routes. Use whenever the user is adding a new REST endpoint or page, making something public (login / forgot-password / version / captcha-style routes), debugging a 403 / "Unauthorized" / "Session expired" response, asking about user roles, data groups, screen permissions, the `self` flag, or `CapableViewController`. Covers both the runtime mechanism and the seeding patterns (the `permission/api.yaml` and `permission/screens.yaml` conventions). The actual seeding executes inside a database migration — migration mechanics are a separate concern (see the `migrations` skill), this skill includes only the minimal migration stub needed to land a permission change.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Authorization in OrangeHRM skill
What this skill tells your AI
The instructions your AI receives, as published by orangehrm/orangehrm in .agents/skills/authorization/SKILL.md and read by ahel’s review.
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
Signals
- GitHub stars
- 1k
- Forks
- 749
- Last commit
- Jun 2026
Advanced
- Catalog kind
- skill
- Gateway key
authorization- Source
- github.com/orangehrm/orangehrm