Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@ All notable changes to **Tiger Core** (`webtigers/tiger-core`). Format follows

## [Unreleased]

## [1.29.0] — 2026-10-21

### Changed
- **A signed-in user who hits a page above their role is now redirected to their own dashboard, not
walled with a themed 403** (TIGER-359). `Tiger_Controller_Plugin_Authorization::_deny()` now, for an
authenticated-but-forbidden caller, **logs the attempt** (`Tiger_Log::warn('acl.denied', …)`,
auto-enriched with user/org/role) and redirects to `Tiger_Service_Authentication::homeFor()` — the
back office (`/admin`) for an admin-class role, else the account surface (`/account`). A loop guard
falls back to the themed 403 only when the forbidden resource *is* the caller's own dashboard. Guests
still go to `/auth/login` and a locked screen still goes to `/auth/lock`, unchanged.

### Added
- **`Tiger_Service_Authentication::homeFor($identity)`** — the one authority for an identity's
post-login landing (dashboard), shared by the login flow (`AuthController`) and the authorization
gate, so "where does this person belong" is answered the same way everywhere.

## [1.28.0] — 2026-10-21

### Added
Expand Down
4 changes: 2 additions & 2 deletions core/controllers/AuthController.php
Original file line number Diff line number Diff line change
Expand Up @@ -399,8 +399,8 @@ public function meAction()
/** Role-appropriate landing after auth: admin back office for admin+, else the "My Account" dashboard. */
protected function _roleHome($identity)
{
$role = ($identity && isset($identity->role)) ? $identity->role : '';
return in_array($role, self::ADMIN_ROLES, true) ? '/admin' : '/account';
// One authority for "the identity's dashboard", shared with the authorization gate (TIGER-359).
return Tiger_Service_Authentication::homeFor($identity);
}

/**
Expand Down
43 changes: 37 additions & 6 deletions library/Tiger/Controller/Plugin/Authorization.php
Original file line number Diff line number Diff line change
Expand Up @@ -154,11 +154,12 @@ protected function _resourceFor(Zend_Controller_Request_Abstract $request)
return $class;
}

/** Denied: locked -> lock card; guests -> login (302); authed-but-forbidden -> themed 403. */
/** Denied: locked -> lock card; guests -> login (302); authed-but-forbidden -> their dashboard (logged). */
protected function _deny($role)
{
$auth = new Tiger_Service_Authentication();
$path = (string) $this->getRequest()->getPathInfo();
$request = $this->getRequest();
$path = (string) $request->getPathInfo();
$redirector = Zend_Controller_Action_HelperBroker::getStaticHelper('redirector');

// Remember where they were headed (in SESSION, not a URL param) so login /
Expand All @@ -176,17 +177,47 @@ protected function _deny($role)
$redirector->gotoUrlAndExit('/auth/login');
}

// Authenticated but forbidden: re-dispatch to the themed 403 page instead of
// emitting a bare string. ErrorController is public in acl.ini, so the
// re-dispatch passes this same gate cleanly (no deny loop).
$request = $this->getRequest();
// Authenticated but forbidden. They likely just reached for something above their role, so
// DON'T wall them with a themed 403 — LOG the attempt (audit; Tiger_Log auto-enriches with
// user/org/role) and send them back to their own dashboard (TIGER-359). The themed 403 stays
// as the loop-guard fallback for the one case where the forbidden resource IS their dashboard.
Tiger_Log::warn('acl.denied', [
'resource' => $this->_resourceFor($request),
'action' => (string) $request->getActionName(),
'path' => $path,
'role' => $role,
]);

$target = $this->_forbiddenRedirect($path, Zend_Auth::getInstance()->getIdentity());
if ($target !== null) {
$redirector->gotoUrlAndExit($target);
return;
}

// Loop guard: forbidden their OWN dashboard — re-dispatch to the themed 403 instead of
// bouncing. ErrorController is public in acl.ini, so the re-dispatch passes this gate cleanly.
$request->setModuleName('default')
->setControllerName('error')
->setActionName('forbidden')
->setDispatched(false);
$this->getResponse()->setHttpResponseCode(403);
}

/**
* Where an authenticated-but-forbidden caller who hit $path should be sent: their dashboard
* (Tiger_Service_Authentication::homeFor), unless the forbidden resource IS that dashboard — then
* null, so _deny() falls back to the themed 403 and no redirect loop can form.
*
* @param string $path the forbidden request path
* @param object|null $identity the Zend_Auth identity
* @return string|null the dashboard path, or null to show the themed 403
*/
protected function _forbiddenRedirect($path, $identity)
{
$home = Tiger_Service_Authentication::homeFor($identity);
return (rtrim((string) $path, '/') === rtrim($home, '/')) ? null : $home;
}

/** hyphen/dot/underscore-slug -> StudlyCase (user-admin -> UserAdmin). */
private function _studly($name)
{
Expand Down
18 changes: 18 additions & 0 deletions library/Tiger/Service/Authentication.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,24 @@ class Tiger_Service_Authentication
/** Role for an unauthenticated request. */
const ROLE_GUEST = 'guest';

/** Roles whose dashboard is the back office (`/admin`); every other role lands on `/account`. */
const ADMIN_HOME_ROLES = ['admin', 'superadmin', 'developer'];

/**
* The post-login landing — the identity's "dashboard": the back office for an admin-class role,
* otherwise the account surface. ONE authority shared by the login flow (AuthController) and the
* authorization gate (Tiger_Controller_Plugin_Authorization), so "where does this person belong"
* is answered the same way whether they just signed in or were bounced off a forbidden page.
*
* @param object|null $identity the Zend_Auth identity (reads ->role; null/role-less → /account)
* @return string a local path — '/admin' or '/account'
*/
public static function homeFor($identity)
{
$role = ($identity && isset($identity->role)) ? (string) $identity->role : '';
return in_array($role, self::ADMIN_HOME_ROLES, true) ? '/admin' : '/account';
}

/**
* Sentinel returned by login() when the password was correct but a second factor
* (TOTP/recovery) is still required. Distinguishable from an identity object and
Expand Down
2 changes: 1 addition & 1 deletion library/Tiger/Version.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@
class Tiger_Version
{
/** Current Tiger Core version. Keep in lockstep with the git tag cut for a release. */
const VERSION = '1.28.0';
const VERSION = '1.29.0';
}
40 changes: 30 additions & 10 deletions tests/Integration/Controller/AuthorizationPluginExtraTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
use PHPUnit\Framework\Attributes\Test;
use Tiger\Tests\Support\ControllerTestCase;
use Tiger_Controller_Plugin_Authorization;
use Tiger_Log;
use Zend_Config;
use Zend_Controller_Front;
use Zend_Controller_Request_Http;
use Zend_Controller_Response_Http;
Expand All @@ -23,10 +25,11 @@
* to login for a URL that doesn't exist);
* - the gate fails OPEN when no `Zend_Acl` is registered (a partial boot never locks the app out);
* - a guest-allowed controller (IndexController) proceeds untouched;
* - an authenticated-but-forbidden caller is DENIED → re-dispatched to the themed 403 (error/forbidden,
* HTTP 403) rather than emitting a bare string. That forbidden branch of `_deny()` doesn't exit the
* process, so it's assertable in-process (the guest/locked branches call gotoUrlAndExit — a real
* process exit — so they stay at the functional/smoke level).
* - an authenticated-but-forbidden caller is DENIED → redirected to their own dashboard (TIGER-359),
* EXCEPT when the forbidden resource IS that dashboard, which falls back to the themed 403
* (error/forbidden, HTTP 403) — the loop-guard branch, which doesn't exit the process, so it's
* assertable in-process. The ordinary redirect + the guest/locked branches call gotoUrlAndExit (a
* real process exit), so the redirect TARGET is pinned by `_forbiddenRedirect` in the sibling test.
*
* Uses Zend's array-backed unit-test session so `setReturnTo()` / `isLocked()` (session reads/writes in
* the deny path) run for real under CLI.
Expand Down Expand Up @@ -109,16 +112,33 @@ public function a_guest_allowed_controller_proceeds(): void
}

#[Test]
public function an_authenticated_but_forbidden_caller_is_forwarded_to_the_403(): void
public function an_authenticated_forbidden_caller_hitting_their_own_dashboard_falls_back_to_403(): void
{
$this->loginAsReal('user'); // a plain user...
$req = $this->request('admin'); // ...hitting AdminController, which has no allow rule
$res = $this->runGate($req);
// The ordinary forbidden case now REDIRECTS to the dashboard via gotoUrlAndExit (a real exit —
// its target is pinned by _forbiddenRedirect in the sibling test). The one in-process-assertable
// branch is the LOOP GUARD: an authenticated user forbidden their OWN dashboard path must NOT be
// bounced to the dashboard again — it re-dispatches to the themed 403 instead (TIGER-359).
$this->loginAsReal('user'); // a plain user → dashboard is /account...
$req = $this->request('admin'); // ...hitting AdminController (no allow rule for user)
$req->setPathInfo('/account'); // ...reached AT the dashboard path → loop guard fires

// _deny() logs the attempt via Tiger_Log; route it to a null writer so the diagnostic line
// doesn't print during the test (the established Tiger_Log test pattern).
$prior = Zend_Registry::isRegistered('Zend_Config') ? Zend_Registry::get('Zend_Config') : null;
$arr = $prior ? $prior->toArray() : [];
$arr['tiger']['log']['writer'] = 'null';
Zend_Registry::set('Zend_Config', new Zend_Config($arr));
Tiger_Log::reset();
try {
$res = $this->runGate($req);
} finally {
if ($prior !== null) { Zend_Registry::set('Zend_Config', $prior); }
Tiger_Log::reset();
}

// _deny(): not a guest, not locked → re-dispatch to the themed 403 (no process exit).
$this->assertSame(403, $res->getHttpResponseCode());
$this->assertSame('error', $req->getControllerName());
$this->assertSame('forbidden', $req->getActionName());
$this->assertFalse($req->isDispatched(), 'the request is re-queued for the error controller');
$this->assertFalse($req->isDispatched(), 'the request is re-queued for the error controller, no redirect loop');
}
}
35 changes: 35 additions & 0 deletions tests/Integration/Controller/AuthorizationPluginTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use PHPUnit\Framework\Attributes\Test;
use Tiger\Tests\Support\IntegrationTestCase;
use Tiger_Controller_Plugin_Authorization;
use Tiger_Service_Authentication;
use Tiger_Model_Org;
use Tiger_Model_OrgUser;
use Tiger_Model_Table;
Expand Down Expand Up @@ -62,9 +63,43 @@ private function plugin(): Tiger_Controller_Plugin_Authorization
return new class extends Tiger_Controller_Plugin_Authorization {
public function pubResolveRole() { return $this->_resolveRole(); }
public function pubResourceFor($request) { return $this->_resourceFor($request); }
public function pubForbiddenRedirect($path, $identity) { return $this->_forbiddenRedirect($path, $identity); }
};
}

// ----- authenticated-but-forbidden → dashboard, not a themed 403 wall (TIGER-359) ------------

#[Test]
public function home_for_sends_admins_to_the_back_office_and_everyone_else_to_account(): void
{
$this->assertSame('/admin', Tiger_Service_Authentication::homeFor((object) ['role' => 'admin']));
$this->assertSame('/admin', Tiger_Service_Authentication::homeFor((object) ['role' => 'superadmin']));
$this->assertSame('/admin', Tiger_Service_Authentication::homeFor((object) ['role' => 'developer']));
$this->assertSame('/account', Tiger_Service_Authentication::homeFor((object) ['role' => 'user']));
$this->assertSame('/account', Tiger_Service_Authentication::homeFor((object) ['role' => 'manager']));
$this->assertSame('/account', Tiger_Service_Authentication::homeFor(null), 'no identity → the account surface');
}

#[Test]
public function a_forbidden_caller_is_routed_to_their_own_dashboard(): void
{
$p = $this->plugin();
$this->assertSame('/account', $p->pubForbiddenRedirect('/admin/users', (object) ['role' => 'user']),
'a plain user who reached an admin page goes to their account dashboard, not a 403');
$this->assertSame('/admin', $p->pubForbiddenRedirect('/billing/secret', (object) ['role' => 'admin']),
'an admin forbidden some other resource goes to the back office');
}

#[Test]
public function the_loop_guard_falls_back_to_403_when_the_dashboard_itself_is_forbidden(): void
{
$p = $this->plugin();
$this->assertNull($p->pubForbiddenRedirect('/account', (object) ['role' => 'user']),
'forbidden their OWN dashboard → null (themed 403), never a redirect loop');
$this->assertNull($p->pubForbiddenRedirect('/admin/', (object) ['role' => 'admin']),
'trailing slash is normalized so the guard still catches it');
}

/** Seed a user + org + an active membership carrying $role; sign the user in. Returns [userId, orgId]. */
private function signInWithMembership(string $role): array
{
Expand Down
Loading