481 lines
14 KiB
PHP
481 lines
14 KiB
PHP
<?php
|
||
/**
|
||
* ACL / Access Control - content only
|
||
* app/Views/docs/acl.php
|
||
*
|
||
* Based on app/Config/Acl.php and app/Filters/AclFilter.php
|
||
*/
|
||
?>
|
||
|
||
<p>
|
||
Route-level access control is handled by <code>Config\Acl</code> plus the
|
||
global <code>AclFilter</code>. 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.
|
||
</p>
|
||
|
||
<div class="callout info">
|
||
<span>i</span>
|
||
<div>
|
||
<strong>Core files</strong>
|
||
ACL rules live in <code>app/Config/Acl.php</code>. Enforcement lives in
|
||
<code>app/Filters/AclFilter.php</code>. Global activation is configured in
|
||
<code>app/Config/Filters.php</code>.
|
||
</div>
|
||
</div>
|
||
|
||
<h2 id="overview">Overview</h2>
|
||
|
||
<div class="mermaid-wrapper">
|
||
<div class="mermaid">
|
||
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]
|
||
</div>
|
||
</div>
|
||
|
||
<h2 id="where-it-is-wired">Where it is wired</h2>
|
||
|
||
<p>
|
||
<code>AclFilter</code> is registered as an alias and applied in the global
|
||
<code>before</code> filter stack, with a route exception list.
|
||
</p>
|
||
|
||
<pre><code class="language-php">'AclFilter' => AclFilter::class,
|
||
|
||
'before' => [
|
||
'AclFilter' => ['except' => [
|
||
'login',
|
||
'logout',
|
||
'auth/*',
|
||
'oauth2callback',
|
||
'claim-form-download',
|
||
'claims-feedback-form',
|
||
'autobookstackLogin',
|
||
'employeeRest/*',
|
||
'processjob',
|
||
'getCommission',
|
||
'downloadEmployeeEcardZip',
|
||
'downloadClaimFile/*',
|
||
'api/v1/*'
|
||
]],
|
||
]</code></pre>
|
||
|
||
<p>
|
||
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.
|
||
</p>
|
||
|
||
<h2 id="rule-format">Rule format</h2>
|
||
|
||
<p>
|
||
Each ACL rule in <code>Config\Acl::$rules</code> uses a regex pattern as the
|
||
key and a rule definition array as the value.
|
||
</p>
|
||
|
||
<table>
|
||
<thead>
|
||
<tr><th>Key</th><th>Meaning</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr>
|
||
<td><code>public</code></td>
|
||
<td>If truthy, the route is allowed without session, role, or team checks.</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>roles</code></td>
|
||
<td>List of allowed role IDs, typically using constants like <code>ADMIN_ROLE_ID</code>.</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>teams</code></td>
|
||
<td>List of allowed team IDs, typically using constants like <code>CLAIMS_TEAM_ID</code>.</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
|
||
<pre><code class="language-php">'#^/client#' => [
|
||
'roles' => [HEAD_ROLE_ID, ADMIN_ROLE_ID, MANAGER_ROLE_ID, ACCOUNT_MANAGER_ROLE_ID],
|
||
'teams' => []
|
||
],
|
||
|
||
'#^/claims-feedback-form#' => ['public' => true],</code></pre>
|
||
|
||
<p>
|
||
Regex patterns are matched against normalized paths such as
|
||
<code>/dashboard/view</code>, <code>/client/list</code>, or
|
||
<code>/ticket/view/123</code>.
|
||
</p>
|
||
|
||
<h2 id="matching-behavior">Matching behavior</h2>
|
||
|
||
<p>
|
||
Matching is ordered and strict:
|
||
</p>
|
||
|
||
<ol class="steps">
|
||
<li>
|
||
<strong>The filter normalizes the path</strong>
|
||
<p>It removes the base application path and strips <code>/index.php</code> if present.</p>
|
||
</li>
|
||
<li>
|
||
<strong>Rules are evaluated top to bottom</strong>
|
||
<p>The filter loops through <code>$rules</code> and stops on the first regex match.</p>
|
||
</li>
|
||
<li>
|
||
<strong>First match wins</strong>
|
||
<p>Later rules are ignored once an earlier pattern matches.</p>
|
||
</li>
|
||
<li>
|
||
<strong>No match means deny</strong>
|
||
<p>If nothing matches, the request is blocked immediately.</p>
|
||
</li>
|
||
</ol>
|
||
|
||
<div class="callout warning">
|
||
<span>!</span>
|
||
<div>
|
||
<strong>Order is critical</strong>
|
||
Place more specific patterns before broad prefixes. A broad rule like
|
||
<code>#^/client#</code> will swallow more specific client routes if it appears
|
||
earlier and already matches what you need.
|
||
</div>
|
||
</div>
|
||
|
||
<p>
|
||
The config also ends with a zero-trust fallback:
|
||
</p>
|
||
|
||
<pre><code class="language-php">'#^/#' => [
|
||
'roles' => [ADMIN_ROLE_ID, HEAD_ROLE_ID],
|
||
'teams' => []
|
||
],</code></pre>
|
||
|
||
<p>
|
||
That default rule makes unmatched routes deny by default unless explicitly
|
||
opened earlier.
|
||
</p>
|
||
|
||
<p>
|
||
In practice, pattern matching works like this:
|
||
</p>
|
||
|
||
<table>
|
||
<thead>
|
||
<tr><th>Pattern</th><th>Matches</th><th>Does not match</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr>
|
||
<td><code>#^/client#</code></td>
|
||
<td><code>/client</code>, <code>/client/list</code>, <code>/client/create</code></td>
|
||
<td><code>/api/client</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>#^/client/special-report#</code></td>
|
||
<td><code>/client/special-report</code>, <code>/client/special-report/view</code></td>
|
||
<td><code>/client/list</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>#^/download-#</code></td>
|
||
<td><code>/download-e-card/123</code>, <code>/download-kyc-docs/abc</code></td>
|
||
<td><code>/client/download</code></td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
|
||
<div class="callout info">
|
||
<span>i</span>
|
||
<div>
|
||
<strong>How to think about it</strong>
|
||
The filter does not look at controller names or route groups. It only checks
|
||
the normalized request path string against the regex keys in
|
||
<code>Config\Acl::$rules</code>.
|
||
</div>
|
||
</div>
|
||
|
||
<h2 id="auth-context">Auth context</h2>
|
||
|
||
<p>
|
||
<code>AclFilter</code> relies on session helper functions for the current user
|
||
context:
|
||
</p>
|
||
|
||
<table>
|
||
<thead>
|
||
<tr><th>Helper</th><th>Expected result</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr>
|
||
<td><code>check_session()</code></td>
|
||
<td>Returns <code>true</code> when the session contains <code>isLoggedIn === true</code>.</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>check_role()</code></td>
|
||
<td>Returns the current user's role ID from <code>get_session_userdata()->role</code>.</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>user_team()</code></td>
|
||
<td>Returns an array of current team IDs from the session key <code>user_team</code>.</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
|
||
<pre><code class="language-php">$userRole = check_role();
|
||
$userTeams = user_team();</code></pre>
|
||
|
||
<p>
|
||
For developers, this means ACL correctness depends on login/session setup
|
||
putting the right role and team data into session.
|
||
</p>
|
||
|
||
<h2 id="allow-and-deny-flow">Allow and deny flow</h2>
|
||
|
||
<p>
|
||
The allow sequence is:
|
||
</p>
|
||
|
||
<ol class="steps">
|
||
<li>
|
||
<strong>Public route check</strong>
|
||
<p>If the matched rule has <code>public</code>, access is allowed immediately.</p>
|
||
</li>
|
||
<li>
|
||
<strong>Authentication check</strong>
|
||
<p>If the route is not public and the session is missing, the filter returns either a JSON 401 or a web logout/redirect flow.</p>
|
||
</li>
|
||
<li>
|
||
<strong>Role-first authorization</strong>
|
||
<p>If the user's role ID is in <code>roles</code>, access is allowed.</p>
|
||
</li>
|
||
<li>
|
||
<strong>Team fallback authorization</strong>
|
||
<p>If no role matched but any current team ID is in <code>teams</code>, access is allowed.</p>
|
||
</li>
|
||
<li>
|
||
<strong>Deny otherwise</strong>
|
||
<p>The filter logs the block and returns a 403 response.</p>
|
||
</li>
|
||
</ol>
|
||
|
||
<table>
|
||
<thead>
|
||
<tr><th>Request type</th><th>Deny behavior</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr>
|
||
<td>AJAX / API / <code>/employeeRest</code></td>
|
||
<td>JSON error response with status <code>401</code> or <code>403</code>.</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Normal web request</td>
|
||
<td>403 page rendered through <code>errors/403</code>, or logout redirect when session is missing.</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
|
||
<h2 id="developer-steps">Developer steps</h2>
|
||
|
||
<p>
|
||
When adding or changing a route, use this exact checklist:
|
||
</p>
|
||
|
||
<ol class="steps">
|
||
<li>
|
||
<strong>Decide whether the route should be public or protected</strong>
|
||
<p>If it should be accessible without login, add a <code>public</code> ACL rule or confirm that it is intentionally excluded from the global filter.</p>
|
||
</li>
|
||
<li>
|
||
<strong>Choose the correct path pattern</strong>
|
||
<p>Write the regex against the normalized route path, not the full server URL and not a filesystem path.</p>
|
||
</li>
|
||
<li>
|
||
<strong>Add the ACL rule in the right order</strong>
|
||
<p>Insert the new rule in <code>app/Config/Acl.php</code> before any broader pattern that would match first.</p>
|
||
</li>
|
||
<li>
|
||
<strong>Prefer role rules first, team rules second</strong>
|
||
<p>If a route belongs to a business function, define the required role IDs and then optionally add team IDs for fallback access.</p>
|
||
</li>
|
||
<li>
|
||
<strong>Check whether the route is excluded in <code>Filters.php</code></strong>
|
||
<p>If it is listed in the ACL exception list, your new ACL rule will never run until the exception is removed.</p>
|
||
</li>
|
||
<li>
|
||
<strong>Test both success and failure paths</strong>
|
||
<p>Verify access with an allowed user, a disallowed user, and an unauthenticated request.</p>
|
||
</li>
|
||
</ol>
|
||
|
||
<p>
|
||
For a brand-new route, the safest developer workflow is:
|
||
</p>
|
||
|
||
<ol class="steps">
|
||
<li>
|
||
<strong>Create or confirm the route path first</strong>
|
||
<p>Know the actual URL path that the browser will hit, for example <code>/reports/monthly</code>.</p>
|
||
</li>
|
||
<li>
|
||
<strong>Write the narrowest ACL regex that covers exactly that area</strong>
|
||
<p>If only one route needs different access, do not start with a broad prefix rule.</p>
|
||
</li>
|
||
<li>
|
||
<strong>Place the new rule above any broader parent rule</strong>
|
||
<p>A specific child path must appear before its parent path if they need different access.</p>
|
||
</li>
|
||
<li>
|
||
<strong>Choose whether access is role-based, team-based, or public</strong>
|
||
<p>Prefer explicit <code>roles</code>. Use <code>teams</code> as fallback or business-group access where appropriate.</p>
|
||
</li>
|
||
<li>
|
||
<strong>Check the global ACL exception list</strong>
|
||
<p>If the route is bypassed in <code>Filters.php</code>, adding a rule in <code>Acl.php</code> alone will not protect it.</p>
|
||
</li>
|
||
<li>
|
||
<strong>Test the final path, not just the config</strong>
|
||
<p>Open the actual route in browser or hit it through the expected frontend flow with different user profiles.</p>
|
||
</li>
|
||
</ol>
|
||
|
||
<h2 id="examples">Examples</h2>
|
||
|
||
<p>
|
||
Example 1: add a protected MVC section for a new module:
|
||
</p>
|
||
|
||
<pre><code class="language-php">'#^/reports#' => [
|
||
'roles' => [HEAD_ROLE_ID, ADMIN_ROLE_ID, MANAGER_ROLE_ID],
|
||
'teams' => [FINANCE_TEAM_ID]
|
||
],</code></pre>
|
||
|
||
<p>
|
||
Example 2: add a public callback route:
|
||
</p>
|
||
|
||
<pre><code class="language-php">'#^/external-callback#' => ['public' => true],</code></pre>
|
||
|
||
<p>
|
||
Example 3: protect a narrow route before a broad one:
|
||
</p>
|
||
|
||
<pre><code class="language-php">'#^/client/special-report#' => [
|
||
'roles' => [ADMIN_ROLE_ID],
|
||
'teams' => []
|
||
],
|
||
|
||
'#^/client#' => [
|
||
'roles' => [HEAD_ROLE_ID, ADMIN_ROLE_ID, MANAGER_ROLE_ID, ACCOUNT_MANAGER_ROLE_ID],
|
||
'teams' => []
|
||
],</code></pre>
|
||
|
||
<p>
|
||
Example 4: add a new route safely without breaking an existing broad rule:
|
||
</p>
|
||
|
||
<pre><code class="language-php">// 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' => []
|
||
],</code></pre>
|
||
|
||
<p>
|
||
The specific <code>/master/export-audit</code> rule must stay above the broader
|
||
<code>/master</code> rule, otherwise the broad rule will match first and the
|
||
special restriction will never apply.
|
||
</p>
|
||
|
||
<h2 id="do-and-dont">Do and don’t</h2>
|
||
|
||
<table>
|
||
<thead>
|
||
<tr><th>Do</th><th>Don’t</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr>
|
||
<td>Write rules against normalized URL paths like <code>/client/list</code>.</td>
|
||
<td>Do not write ACL rules against controller class names or filesystem paths.</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Put specific patterns before broad patterns.</td>
|
||
<td>Do not place <code>#^/client#</code> above a more specific child route that needs different access.</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Check <code>Filters.php</code> exceptions before assuming ACL applies.</td>
|
||
<td>Do not assume a new ACL rule is active if the route is globally excluded.</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Use <code>public</code> only for routes that truly must bypass auth.</td>
|
||
<td>Do not mark internal routes public just to “make it work”.</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Test with allowed, denied, and logged-out users.</td>
|
||
<td>Do not test only as admin and assume the ACL is correct.</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Keep the fallback deny model intact.</td>
|
||
<td>Do not weaken the final catch-all rule unless you fully understand the impact.</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
|
||
<h2 id="common-pitfalls">Common pitfalls</h2>
|
||
|
||
<table>
|
||
<thead>
|
||
<tr><th>Pitfall</th><th>Why it happens</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr>
|
||
<td>ACL rule added but never used</td>
|
||
<td>The route is still listed in the ACL filter <code>except</code> list.</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Specific rule appears correct but never matches</td>
|
||
<td>A broader earlier regex already matched first.</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Team access does not work</td>
|
||
<td><code>user_team()</code> must return an array of team IDs in session.</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Unexpected 403 on web routes</td>
|
||
<td>No matching rule, wrong ordering, wrong regex, or missing role/team data in session.</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Unexpected JSON 403/401</td>
|
||
<td>The request is AJAX or under an API-style prefix, so the filter returns JSON instead of a web page.</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
|
||
<div class="callout success">
|
||
<span>+</span>
|
||
<div>
|
||
<strong>Practical rule for developers</strong>
|
||
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.
|
||
</div>
|
||
</div>
|