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.

i
Core files ACL rules live in app/Config/Acl.php. Enforcement lives in app/Filters/AclFilter.php. Global activation is configured in app/Config/Filters.php.

Overview

flowchart TD A[Incoming web request] --> B[ACL filter runs] B --> C[Bypass CLI] C --> D[Normalize request path] D --> E[Load ordered ACL rules] E --> F[Find first matching regex] F --> G{Public route} G -->|Yes| H[Allow] G -->|No| I{Logged in} I -->|No| J[401 JSON or logout redirect] I -->|Yes| K[Read role and team context] K --> L{Role allowed} L -->|Yes| H L -->|No| M{Team allowed} M -->|Yes| H M -->|No| N[403 deny]

Where it is wired

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.

Rule format

Each ACL rule in Config\Acl::$rules uses a regex pattern as the key and a rule definition array as the value.

KeyMeaning
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 behavior

Matching is ordered and strict:

  1. The filter normalizes the path

    It removes the base application path and strips /index.php if present.

  2. Rules are evaluated top to bottom

    The filter loops through $rules and stops on the first regex match.

  3. First match wins

    Later rules are ignored once an earlier pattern matches.

  4. No match means deny

    If nothing matches, the request is blocked immediately.

!
Order is critical Place more specific patterns before broad prefixes. A broad rule like #^/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:

PatternMatchesDoes 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
i
How to think about it The filter does not look at controller names or route groups. It only checks the normalized request path string against the regex keys in Config\Acl::$rules.

Auth context

AclFilter relies on session helper functions for the current user context:

HelperExpected 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.

Allow and deny flow

The allow sequence is:

  1. Public route check

    If the matched rule has public, access is allowed immediately.

  2. Authentication check

    If the route is not public and the session is missing, the filter returns either a JSON 401 or a web logout/redirect flow.

  3. Role-first authorization

    If the user's role ID is in roles, access is allowed.

  4. Team fallback authorization

    If no role matched but any current team ID is in teams, access is allowed.

  5. Deny otherwise

    The filter logs the block and returns a 403 response.

Request typeDeny 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.

Developer steps

When adding or changing a route, use this exact checklist:

  1. Decide whether the route should be public or protected

    If it should be accessible without login, add a public ACL rule or confirm that it is intentionally excluded from the global filter.

  2. Choose the correct path pattern

    Write the regex against the normalized route path, not the full server URL and not a filesystem path.

  3. Add the ACL rule in the right order

    Insert the new rule in app/Config/Acl.php before any broader pattern that would match first.

  4. Prefer role rules first, team rules second

    If a route belongs to a business function, define the required role IDs and then optionally add team IDs for fallback access.

  5. Check whether the route is excluded in Filters.php

    If it is listed in the ACL exception list, your new ACL rule will never run until the exception is removed.

  6. Test both success and failure paths

    Verify access with an allowed user, a disallowed user, and an unauthenticated request.

For a brand-new route, the safest developer workflow is:

  1. Create or confirm the route path first

    Know the actual URL path that the browser will hit, for example /reports/monthly.

  2. Write the narrowest ACL regex that covers exactly that area

    If only one route needs different access, do not start with a broad prefix rule.

  3. Place the new rule above any broader parent rule

    A specific child path must appear before its parent path if they need different access.

  4. Choose whether access is role-based, team-based, or public

    Prefer explicit roles. Use teams as fallback or business-group access where appropriate.

  5. Check the global ACL exception list

    If the route is bypassed in Filters.php, adding a rule in Acl.php alone will not protect it.

  6. Test the final path, not just the config

    Open the actual route in browser or hit it through the expected frontend flow with different user profiles.

Examples

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 and don’t

DoDon’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.

Common pitfalls

PitfallWhy 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.
+
Practical rule for developers Whenever you add a new route, treat ACL as part of the feature definition. Add or verify the route rule, confirm it is not bypassed by filter exceptions, and test it with the real role/team combinations expected in production.