Route-level access control is handled by Config\Acl plus the
global AclFilter. Developers should treat this as the single
source of truth for web ACL decisions: a normalized request path is matched
against ordered regex rules, then the user is allowed by public flag, role, or
team membership. Everything else is denied.
app/Config/Acl.php. Enforcement lives in
app/Filters/AclFilter.php. Global activation is configured in
app/Config/Filters.php.
AclFilter is registered as an alias and applied in the global
before filter stack, with a route exception list.
'AclFilter' => AclFilter::class,
'before' => [
'AclFilter' => ['except' => [
'login',
'logout',
'auth/*',
'oauth2callback',
'claim-form-download',
'claims-feedback-form',
'autobookstackLogin',
'employeeRest/*',
'processjob',
'getCommission',
'downloadEmployeeEcardZip',
'downloadClaimFile/*',
'api/v1/*'
]],
]
That means ACL is primarily enforcing browser/MVC routes. Several public, webhook, CLI, and API-style paths are intentionally excluded from the global filter and managed elsewhere.
Each ACL rule in Config\Acl::$rules uses a regex pattern as the
key and a rule definition array as the value.
| Key | Meaning |
|---|---|
public |
If truthy, the route is allowed without session, role, or team checks. |
roles |
List of allowed role IDs, typically using constants like ADMIN_ROLE_ID. |
teams |
List of allowed team IDs, typically using constants like CLAIMS_TEAM_ID. |
'#^/client#' => [
'roles' => [HEAD_ROLE_ID, ADMIN_ROLE_ID, MANAGER_ROLE_ID, ACCOUNT_MANAGER_ROLE_ID],
'teams' => []
],
'#^/claims-feedback-form#' => ['public' => true],
Regex patterns are matched against normalized paths such as
/dashboard/view, /client/list, or
/ticket/view/123.
Matching is ordered and strict:
It removes the base application path and strips /index.php if present.
The filter loops through $rules and stops on the first regex match.
Later rules are ignored once an earlier pattern matches.
If nothing matches, the request is blocked immediately.
#^/client# will swallow more specific client routes if it appears
earlier and already matches what you need.
The config also ends with a zero-trust fallback:
'#^/#' => [
'roles' => [ADMIN_ROLE_ID, HEAD_ROLE_ID],
'teams' => []
],
That default rule makes unmatched routes deny by default unless explicitly opened earlier.
In practice, pattern matching works like this:
| Pattern | Matches | Does not match |
|---|---|---|
#^/client# |
/client, /client/list, /client/create |
/api/client |
#^/client/special-report# |
/client/special-report, /client/special-report/view |
/client/list |
#^/download-# |
/download-e-card/123, /download-kyc-docs/abc |
/client/download |
Config\Acl::$rules.
AclFilter relies on session helper functions for the current user
context:
| Helper | Expected result |
|---|---|
check_session() |
Returns true when the session contains isLoggedIn === true. |
check_role() |
Returns the current user's role ID from get_session_userdata()->role. |
user_team() |
Returns an array of current team IDs from the session key user_team. |
$userRole = check_role();
$userTeams = user_team();
For developers, this means ACL correctness depends on login/session setup putting the right role and team data into session.
The allow sequence is:
If the matched rule has public, access is allowed immediately.
If the route is not public and the session is missing, the filter returns either a JSON 401 or a web logout/redirect flow.
If the user's role ID is in roles, access is allowed.
If no role matched but any current team ID is in teams, access is allowed.
The filter logs the block and returns a 403 response.
| Request type | Deny behavior |
|---|---|
AJAX / API / /employeeRest |
JSON error response with status 401 or 403. |
| Normal web request | 403 page rendered through errors/403, or logout redirect when session is missing. |
When adding or changing a route, use this exact checklist:
If it should be accessible without login, add a public ACL rule or confirm that it is intentionally excluded from the global filter.
Write the regex against the normalized route path, not the full server URL and not a filesystem path.
Insert the new rule in app/Config/Acl.php before any broader pattern that would match first.
If a route belongs to a business function, define the required role IDs and then optionally add team IDs for fallback access.
Filters.php
If it is listed in the ACL exception list, your new ACL rule will never run until the exception is removed.
Verify access with an allowed user, a disallowed user, and an unauthenticated request.
For a brand-new route, the safest developer workflow is:
Know the actual URL path that the browser will hit, for example /reports/monthly.
If only one route needs different access, do not start with a broad prefix rule.
A specific child path must appear before its parent path if they need different access.
Prefer explicit roles. Use teams as fallback or business-group access where appropriate.
If the route is bypassed in Filters.php, adding a rule in Acl.php alone will not protect it.
Open the actual route in browser or hit it through the expected frontend flow with different user profiles.
Example 1: add a protected MVC section for a new module:
'#^/reports#' => [
'roles' => [HEAD_ROLE_ID, ADMIN_ROLE_ID, MANAGER_ROLE_ID],
'teams' => [FINANCE_TEAM_ID]
],
Example 2: add a public callback route:
'#^/external-callback#' => ['public' => true],
Example 3: protect a narrow route before a broad one:
'#^/client/special-report#' => [
'roles' => [ADMIN_ROLE_ID],
'teams' => []
],
'#^/client#' => [
'roles' => [HEAD_ROLE_ID, ADMIN_ROLE_ID, MANAGER_ROLE_ID, ACCOUNT_MANAGER_ROLE_ID],
'teams' => []
],
Example 4: add a new route safely without breaking an existing broad rule:
// New route to add: /master/export-audit
'#^/master/export-audit#' => [
'roles' => [ADMIN_ROLE_ID, HEAD_ROLE_ID],
'teams' => []
],
'#^/master#' => [
'roles' => [HEAD_ROLE_ID, ADMIN_ROLE_ID, MANAGER_ROLE_ID, ACCOUNT_MANAGER_ROLE_ID],
'teams' => []
],
The specific /master/export-audit rule must stay above the broader
/master rule, otherwise the broad rule will match first and the
special restriction will never apply.
| Do | Don’t |
|---|---|
Write rules against normalized URL paths like /client/list. |
Do not write ACL rules against controller class names or filesystem paths. |
| Put specific patterns before broad patterns. | Do not place #^/client# above a more specific child route that needs different access. |
Check Filters.php exceptions before assuming ACL applies. |
Do not assume a new ACL rule is active if the route is globally excluded. |
Use public only for routes that truly must bypass auth. |
Do not mark internal routes public just to “make it work”. |
| Test with allowed, denied, and logged-out users. | Do not test only as admin and assume the ACL is correct. |
| Keep the fallback deny model intact. | Do not weaken the final catch-all rule unless you fully understand the impact. |
| Pitfall | Why it happens |
|---|---|
| ACL rule added but never used | The route is still listed in the ACL filter except list. |
| Specific rule appears correct but never matches | A broader earlier regex already matched first. |
| Team access does not work | user_team() must return an array of team IDs in session. |
| Unexpected 403 on web routes | No matching rule, wrong ordering, wrong regex, or missing role/team data in session. |
| Unexpected JSON 403/401 | The request is AJAX or under an API-style prefix, so the filter returns JSON instead of a web page. |