diff --git a/CHANGELOG.md b/CHANGELOG.md index 7451a0f5..0f1715e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/core/controllers/AuthController.php b/core/controllers/AuthController.php index 7127355c..7dcc773f 100644 --- a/core/controllers/AuthController.php +++ b/core/controllers/AuthController.php @@ -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); } /** diff --git a/library/Tiger/Controller/Plugin/Authorization.php b/library/Tiger/Controller/Plugin/Authorization.php index e5252e0b..1c724ec7 100644 --- a/library/Tiger/Controller/Plugin/Authorization.php +++ b/library/Tiger/Controller/Plugin/Authorization.php @@ -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 / @@ -176,10 +177,25 @@ 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') @@ -187,6 +203,21 @@ protected function _deny($role) $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) { diff --git a/library/Tiger/Service/Authentication.php b/library/Tiger/Service/Authentication.php index 610f8996..a9b33a95 100644 --- a/library/Tiger/Service/Authentication.php +++ b/library/Tiger/Service/Authentication.php @@ -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 diff --git a/library/Tiger/Version.php b/library/Tiger/Version.php index 36b45dd1..4c33dac9 100644 --- a/library/Tiger/Version.php +++ b/library/Tiger/Version.php @@ -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'; } diff --git a/tests/Integration/Controller/AuthorizationPluginExtraTest.php b/tests/Integration/Controller/AuthorizationPluginExtraTest.php index e0dd649b..f8f68ab6 100644 --- a/tests/Integration/Controller/AuthorizationPluginExtraTest.php +++ b/tests/Integration/Controller/AuthorizationPluginExtraTest.php @@ -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; @@ -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. @@ -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'); } } diff --git a/tests/Integration/Controller/AuthorizationPluginTest.php b/tests/Integration/Controller/AuthorizationPluginTest.php index 89264def..009b0ce5 100644 --- a/tests/Integration/Controller/AuthorizationPluginTest.php +++ b/tests/Integration/Controller/AuthorizationPluginTest.php @@ -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; @@ -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 {