nhance/app/Views/docs/acl.php
2026-05-18 12:28:19 +05:30

481 lines
14 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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()-&gt;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 dont</h2>
<table>
<thead>
<tr><th>Do</th><th>Dont</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>