From 31f55bebfb0dfe235af52d4e815a3355878c97db Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Wed, 21 Oct 2026 10:27:38 -0400 Subject: [PATCH 1/3] TIGER-335/357/358: remove the role comparers + scope guest grants + add guards (v1.37.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deprecation window closes. With every call site migrated in v1.32.0–v1.36.0, this deletes the three role comparers — Tiger_Service_Service::_isAdmin()/_isAtLeastAdmin() and Tiger_Message::isAtLeastAdmin() (+ the dead ADMIN_ROLE const) — so authorization is decided only via Zend_Acl::isAllowed(). Stale comments/docs (AGENTS.md, WEBSERVICES.md, signup/AGENTS.md) updated to the _isAllowed() primitive. TIGER-357: scoped the remaining blanket guest SERVICE grants to their public methods — Signup_Service_Signup (create/verifyEmail), Tiger_Service_Validate (field), Tiger_Service_Location (suggest/reverse/ip), Search_Service_Search (query). No service grants guest a resource-level allow. Guards (tests/Unit/Acl/AuthorizationGuardTest, mutation-tested): the build fails if a role-string comparison reappears in an /api service, or if any acl.ini grants guest a blanket service resource. Full suite green: 1131 unit + 1481 integration. Closes TIGER-357, TIGER-358. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ASauLLscjqdsNqBNsx2Typ --- AGENTS.md | 12 +- CHANGELOG.md | 20 +++ WEBSERVICES.md | 2 +- core/configs/acl.ini | 16 +- library/Tiger/Ajax/ServiceFactory.php | 4 +- .../Tiger/Controller/Plugin/Authorization.php | 2 +- library/Tiger/Message.php | 29 ---- library/Tiger/Service/Authentication.php | 2 +- library/Tiger/Service/Service.php | 61 +------ library/Tiger/Version.php | 2 +- modules/message/services/Message.php | 2 +- modules/search/configs/acl.ini | 1 + modules/signup/AGENTS.md | 2 +- modules/signup/configs/acl.ini | 13 +- .../Integration/Signup/SignupServiceTest.php | 3 +- tests/Unit/Acl/AuthorizationGuardTest.php | 149 ++++++++++++++++++ 16 files changed, 211 insertions(+), 109 deletions(-) create mode 100644 tests/Unit/Acl/AuthorizationGuardTest.php diff --git a/AGENTS.md b/AGENTS.md index 356f3a72..bd29580c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -195,7 +195,7 @@ transaction:** ```php public function create(array $params): void { - if (!$this->_isAdmin()) { $this->_error('core.api.error.not_allowed'); return; } + if (!$this->_isAllowed('create')) { $this->_error('core.api.error.not_allowed'); return; } $form = new Billing_Form_Invoice(); if (!$form->isValid($params)) { $this->_formErrors($form); return; } // isValidPartial() for PATCH @@ -231,8 +231,9 @@ acl.rules.billing_invoice_svc.permission = "allow" Adding a method to an already-allowed service inherits that blanket allow — correct when the whole service shares one role (an admin-only settings service, a guest-allowed search service). When methods need *different* access (a public read on an otherwise-admin service), name the method as -the `privilege` on a scoped rule instead of a blanket one. The in-method `_isAdmin()` guard is -defense-in-depth — it does not replace the `acl.ini` rule. +the `privilege` on a scoped rule instead of a blanket one. The in-method `_isAllowed('')` +guard is defense-in-depth (and the only gate for an in-process caller) — it does not replace the +`acl.ini` rule. ## Forms @@ -326,8 +327,9 @@ The four rules that keep the F5 class from recurring — follow them in every ne `acl.ini` already makes a renamed/added admin-class role Just Work. 4. **A feature flag is checked AFTER authorization, never instead of it** (authz before the flag). -`_isAdmin()` / `_isAtLeastAdmin()` / `Tiger_Message::isAtLeastAdmin()` are **`@deprecated`** (TIGER-335) -and being removed — never add a new call to them. `modules/message` is the reference conversion. +The old role comparers `_isAdmin()` / `_isAtLeastAdmin()` / `Tiger_Message::isAtLeastAdmin()` were +**removed** (TIGER-335/358) — do not reintroduce them; a guard test fails the build on a role-string +comparison in a service. `modules/message` is the reference conversion. ## Internationalization diff --git a/CHANGELOG.md b/CHANGELOG.md index ad274dda..5b700e46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,26 @@ All notable changes to **Tiger Core** (`webtigers/tiger-core`). Format follows ## [Unreleased] +## [1.37.0] — 2026-10-21 + +### Removed +- **The role comparers are gone** (TIGER-335/358) — `Tiger_Service_Service::_isAdmin()` / + `_isAtLeastAdmin()` and `Tiger_Message::isAtLeastAdmin()` (+ the dead `Tiger_Message::ADMIN_ROLE`) are + **deleted**. Every one of the ~160 call sites across 21 modules was migrated to the privilege + primitives `_isAllowed($privilege)` / `_roleIsAllowed($role, $privilege)` in the preceding releases; + no code calls a role comparer any more. Authorization is decided only through `Zend_Acl::isAllowed()`. + +### Added / Changed +- **Guest grants are privilege-scoped, never blanket** (TIGER-357) — scoped the remaining blanket + `guest` service grants to their actual public methods: `Signup_Service_Signup` (create/verifyEmail), + `Tiger_Service_Validate` (field), `Tiger_Service_Location` (suggest/reverse/ip), `Search_Service_Search` + (query). No service hands `guest` a resource-level (any-method) allow. +- **Structural guards** (`tests/Unit/Acl/AuthorizationGuardTest`) — the build now fails if a role-string + comparison (`_isAdmin`/`_isAtLeastAdmin`/`inheritsRole`/`=== 'admin'` …) reappears in any `/api` + service, or if any `acl.ini` grants `guest` a blanket service resource. Both are mutation-tested (the + detector is proven to catch the exact defect it forbids). Docs (`AGENTS.md`, `WEBSERVICES.md`) updated + to the primitive. + ## [1.36.0] — 2026-10-21 ### Changed diff --git a/WEBSERVICES.md b/WEBSERVICES.md index 61af0c58..70cf8641 100644 --- a/WEBSERVICES.md +++ b/WEBSERVICES.md @@ -87,7 +87,7 @@ class Account_Service_User extends Tiger_Service_Service // Called for { service: 'user', method: 'save', ... } public function save(array $params): void { - if (!$this->_isAdmin()) { // ACL gate — never a role-string compare + if (!$this->_isAllowed('save')) { // ACL gate — never a role-string compare $this->_error('api.error.not_allowed'); return; } diff --git a/core/configs/acl.ini b/core/configs/acl.ini index 767867f8..07482815 100644 --- a/core/configs/acl.ini +++ b/core/configs/acl.ini @@ -91,14 +91,24 @@ acl.rules.pub_api.permission = "allow" ; fields (read-only), so it's safe for guests (e.g. a public signup form). acl.rules.pub_validate.role = "guest" acl.rules.pub_validate.resource = "Tiger_Service_Validate" +acl.rules.pub_validate.privilege = "field" acl.rules.pub_validate.permission = "allow" ; Location Service — a public core service (address autocomplete / reverse / own-IP lookup) ; so public forms like signup can use it. Read-only lookups; safe for guests. acl.resources.location.resource = "Tiger_Service_Location" -acl.rules.pub_location.role = "guest" -acl.rules.pub_location.resource = "Tiger_Service_Location" -acl.rules.pub_location.permission = "allow" +acl.rules.pub_location_suggest.role = "guest" +acl.rules.pub_location_suggest.resource = "Tiger_Service_Location" +acl.rules.pub_location_suggest.privilege = "suggest" +acl.rules.pub_location_suggest.permission = "allow" +acl.rules.pub_location_reverse.role = "guest" +acl.rules.pub_location_reverse.resource = "Tiger_Service_Location" +acl.rules.pub_location_reverse.privilege = "reverse" +acl.rules.pub_location_reverse.permission = "allow" +acl.rules.pub_location_ip.role = "guest" +acl.rules.pub_location_ip.resource = "Tiger_Service_Location" +acl.rules.pub_location_ip.privilege = "ip" +acl.rules.pub_location_ip.permission = "allow" acl.rules.pub_error.role = "guest" acl.rules.pub_error.resource = "ErrorController" diff --git a/library/Tiger/Ajax/ServiceFactory.php b/library/Tiger/Ajax/ServiceFactory.php index f81b21f0..a79bc6b5 100644 --- a/library/Tiger/Ajax/ServiceFactory.php +++ b/library/Tiger/Ajax/ServiceFactory.php @@ -261,7 +261,7 @@ protected function _identity() $this->_identityResolved = true; // An explicit override (e.g. an org-acting MCP identity) wins — write it to REQUEST-ONLY storage so - // the service's helpers (_isAdmin, _userId) see it, and never touch the session. + // the service's helpers (_isAllowed, _userId) see it, and never touch the session. if ($this->_identityOverride !== null) { Zend_Registry::set('tiger.auth.stateless', true); $auth = Zend_Auth::getInstance(); @@ -275,7 +275,7 @@ protected function _identity() $identity = (new Tiger_Service_Authentication())->identityFromToken($token); if ($identity !== null) { Zend_Registry::set('tiger.auth.stateless', true); - // Make the token identity visible to the service + its helpers (_isAdmin, _userId) + // Make the token identity visible to the service + its helpers (_isAllowed, _userId) // via Zend_Auth — but through REQUEST-ONLY storage, so NOTHING is written to the // session. This is what keeps the token path truly stateless. $auth = Zend_Auth::getInstance(); diff --git a/library/Tiger/Controller/Plugin/Authorization.php b/library/Tiger/Controller/Plugin/Authorization.php index 1c724ec7..88a9d81a 100644 --- a/library/Tiger/Controller/Plugin/Authorization.php +++ b/library/Tiger/Controller/Plugin/Authorization.php @@ -133,7 +133,7 @@ protected function _resolveRole() } Tiger_Model_Table::setOrg($orgId); // org_id (tenant) flow - $identity->role = $role; // refresh for services (_isAdmin) + $identity->role = $role; // refresh for services (_isAllowed) return $role; } diff --git a/library/Tiger/Message.php b/library/Tiger/Message.php index e2f59319..6efac504 100644 --- a/library/Tiger/Message.php +++ b/library/Tiger/Message.php @@ -26,9 +26,6 @@ class Tiger_Message /** Person-to-person messaging. Off by default; `1` enables it for the install. */ const CFG_USER_TO_USER = 'tiger.message.user_to_user'; - /** Roles at or above this cannot be blocked, and receive `toAdmins()`. */ - const ADMIN_ROLE = 'admin'; - /** * The composing CAPABILITY, as an ACL resource (TIGER-335). "Who is admin-class for messaging" * is a privilege (`send`) on this resource in `modules/message/configs/acl.ini`, granted to @@ -98,32 +95,6 @@ public static function maySend($role) return self::_roleAllowed($role, 'send', self::RESOURCE_COMPOSE) || self::isUserToUserEnabled(); } - /** - * Is this role admin-or-higher? - * - * @param string|null $role - * @return bool - * @deprecated TIGER-335 — reads the ACL inheritance graph as a seniority ladder (`inheritsRole` - * means "inherits admin's RULES", not "is at least as senior"), returns true for `admin` before - * the ACL-registered check (so the more-privileged `superadmin`/`developer` fail where `admin` - * passes in a partial boot), and conflates a missing identity differently from its service twin. - * Use `maySend($role)` for the compose/notify/block questions, or `_roleAllowed()` directly for a - * bare capability check. Removed once the message module's own call sites are gone (TIGER-335). - */ - public static function isAtLeastAdmin($role) - { - $role = (string) $role; - if ($role === '') { return false; } - if ($role === self::ADMIN_ROLE) { return true; } - if (!Zend_Registry::isRegistered('Zend_Acl')) { return false; } - try { - $acl = Zend_Registry::get('Zend_Acl'); - return $acl->hasRole($role) && $acl->hasRole(self::ADMIN_ROLE) && $acl->inheritsRole($role, self::ADMIN_ROLE); - } catch (Throwable $e) { - return false; - } - } - /** * Does $role hold $privilege on $resource, per the live ACL? The facade's pure-ACL authorization * primitive — the static sibling of `Tiger_Service_Service::_roleIsAllowed()`, for a role that may diff --git a/library/Tiger/Service/Authentication.php b/library/Tiger/Service/Authentication.php index a9b33a95..4ae95b0f 100644 --- a/library/Tiger/Service/Authentication.php +++ b/library/Tiger/Service/Authentication.php @@ -926,7 +926,7 @@ public function isLocked() * session identity. While the screen is locked the authorization plugin downgrades * the stored identity to 'guest' (so /api services see guest too); call this right * after a successful unlock so the SAME request — the post-unlock redirect and any - * _isAdmin() read — sees the real role again instead of the stale guest. (Without + * _isAllowed() read — sees the real role again instead of the stale guest. (Without * it the role only self-corrects on the next request's _resolveRole.) * * @return void diff --git a/library/Tiger/Service/Service.php b/library/Tiger/Service/Service.php index 30baee2a..7f90a889 100644 --- a/library/Tiger/Service/Service.php +++ b/library/Tiger/Service/Service.php @@ -15,7 +15,7 @@ * { * public function create(array $params): void * { - * if (!$this->_isAdmin()) { $this->_error('core.api.error.not_allowed'); return; } + * if (!$this->_isAllowed('create')) { $this->_error('core.api.error.not_allowed'); return; } * // ... do work with $params ... * $this->_success($invoice); * } @@ -249,65 +249,6 @@ protected function _roleIsAllowed($role, $privilege, $resource = null) return $acl->isAllowed($role, $resource, $privilege); } - /** - * ACL gate — never a raw role-string compare. Resolves the caller's role from - * the authenticated identity (guest when unauthenticated) and asks Zend_Acl. - * Defaults the resource to the calling service's own class. - * - * NOTE (Tiger evolution): the role will be read from the caller's CURRENT - * org_user membership (role-on-membership) once Tiger_Service_Acl lands; the - * gate signature stays the same, only where `role` comes from sharpens. - * - * @param string|null $resource defaults to static::class - * @param string|null $privilege null = resource-level - * @deprecated TIGER-335 — misleading name + incoherent `$privilege = null` default (see the ticket). - * Use `_isAllowed($privilege)` about the caller, `_roleIsAllowed($role, $privilege)` about anyone - * else. Removed once every module's call site is migrated (the per-module subtasks of TIGER-335). - */ - protected function _isAdmin($resource = null, $privilege = null) - { - if (!Zend_Registry::isRegistered('Zend_Acl')) { - return false; - } - $acl = Zend_Registry::get('Zend_Acl'); - $identity = Zend_Auth::getInstance()->getIdentity(); - $role = $identity->role ?? 'guest'; - $resource = $resource ?? static::class; - - if (!$acl->has($resource)) { - return false; // an ungoverned resource can't grant access - } - return $acl->isAllowed($role, $resource, $privilege); - } - - /** - * "Is the caller an admin (or a role that inherits admin)?" — the question _isAdmin() does NOT - * answer. _isAdmin() asks whether the role is ALLOWED ON THIS SERVICE; on a service granted to - * guests (comments, search, signup) every caller is. A method that is admin-only inside a - * public service must ask THIS. - * - * @return bool - * @deprecated TIGER-335 — reads the inheritance graph as a trust ladder (`inheritsRole` means - * "inherits admin's RULES", not "is at least as senior"), inverts privilege when the ACL is not - * registered, and is org-blind. Express the gate as a privilege on a capability resource and ask - * `_isAllowed($privilege, $resource)` / `_roleIsAllowed($role, $privilege, $resource)` instead. - * Removed once the comment/newsletter call sites are migrated (TIGER-335 subtasks). - */ - protected function _isAtLeastAdmin() - { - $identity = Zend_Auth::getInstance()->getIdentity(); - $role = (string) ($identity->role ?? ''); - if ($role === '') { return false; } - if ($role === 'admin') { return true; } - if (!Zend_Registry::isRegistered('Zend_Acl')) { return false; } - try { - $acl = Zend_Registry::get('Zend_Acl'); - return $acl->hasRole($role) && $acl->hasRole('admin') && $acl->inheritsRole($role, 'admin'); - } catch (Throwable $e) { - return false; - } - } - // ----- data / transactions ---------------------------------------------- /** The default DB adapter, or a clear failure if none is configured. */ diff --git a/library/Tiger/Version.php b/library/Tiger/Version.php index d0942fc5..196a3fcf 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.36.0'; + const VERSION = '1.37.0'; } diff --git a/modules/message/services/Message.php b/modules/message/services/Message.php index d9ec4580..8d0e6159 100644 --- a/modules/message/services/Message.php +++ b/modules/message/services/Message.php @@ -238,7 +238,7 @@ protected function _signedIn() * * Delegates to Tiger_Message::maySend() — a privilege on the Tiger_Message_Compose capability ORed * with the user-to-user flag (TIGER-335) — so the Compose button (controller) and the API can never - * disagree, and no role string is compared. NOT a resource-level _isAdmin(): the service is granted + * disagree, and no role string is compared. NOT a resource-level check: the service is granted * to `user` per privilege precisely so every member can read their inbox. */ protected function _maySend() diff --git a/modules/search/configs/acl.ini b/modules/search/configs/acl.ini index a91bf283..ac87674f 100644 --- a/modules/search/configs/acl.ini +++ b/modules/search/configs/acl.ini @@ -16,6 +16,7 @@ acl.resources.search_index_ctrl.resource = "Search_IndexController" acl.rules.search_svc_pub.role = "guest" acl.rules.search_svc_pub.resource = "Search_Service_Search" +acl.rules.search_svc_pub.privilege = "query" acl.rules.search_svc_pub.permission = "allow" acl.rules.search_index_pub.role = "guest" diff --git a/modules/signup/AGENTS.md b/modules/signup/AGENTS.md index 8e6fdc45..e13ebbe0 100644 --- a/modules/signup/AGENTS.md +++ b/modules/signup/AGENTS.md @@ -30,7 +30,7 @@ picker). Match it rather than inventing. ## The `/api` surface (guest) - **`Signup_Service_Signup`** — `create`: validate the form → one `_transaction()` building the tenant - graph → email verification. No in-service `_isAdmin` gate (the `/api` factory already authorized + graph → email verification. No in-service `_isAllowed` gate (the `/api` factory already authorized guest). Plus `verifyEmail($cid, $code)` (redeems the `email_verify` challenge, flips the user to `active`). diff --git a/modules/signup/configs/acl.ini b/modules/signup/configs/acl.ini index b43441f7..9a3a9e33 100644 --- a/modules/signup/configs/acl.ini +++ b/modules/signup/configs/acl.ini @@ -20,9 +20,16 @@ acl.rules.signup_ctrl.role = "guest" acl.rules.signup_ctrl.resource = "Signup_IndexController" acl.rules.signup_ctrl.permission = "allow" -acl.rules.signup_svc.role = "guest" -acl.rules.signup_svc.resource = "Signup_Service_Signup" -acl.rules.signup_svc.permission = "allow" +; Enumerated per-privilege (TIGER-335/357) — never a blanket guest grant on a service. The signup +; flow's two public methods are the whole guest surface; a new method needs its own rule by design. +acl.rules.signup_svc_create.role = "guest" +acl.rules.signup_svc_create.resource = "Signup_Service_Signup" +acl.rules.signup_svc_create.privilege = "create" +acl.rules.signup_svc_create.permission = "allow" +acl.rules.signup_svc_verify.role = "guest" +acl.rules.signup_svc_verify.resource = "Signup_Service_Signup" +acl.rules.signup_svc_verify.privilege = "verifyEmail" +acl.rules.signup_svc_verify.permission = "allow" [staging : production] diff --git a/tests/Integration/Signup/SignupServiceTest.php b/tests/Integration/Signup/SignupServiceTest.php index 3e4b30c5..f9724275 100644 --- a/tests/Integration/Signup/SignupServiceTest.php +++ b/tests/Integration/Signup/SignupServiceTest.php @@ -359,7 +359,8 @@ public function the_shipped_acl_allows_a_guest_to_reach_the_signup_service(): vo $acl = Zend_Registry::get('Zend_Acl'); $this->assertTrue($acl->has('Signup_Service_Signup'), 'the service is a governed resource (loaded from signup/acl.ini)'); - $this->assertTrue($acl->isAllowed('guest', 'Signup_Service_Signup'), 'a guest is allowed — public signup'); + $this->assertTrue($acl->isAllowed('guest', 'Signup_Service_Signup', 'create'), 'a guest may create() — public signup'); + $this->assertFalse($acl->isAllowed('guest', 'Signup_Service_Signup'), 'but NOT at the resource level — the guest grant is privilege-scoped, never blanket (TIGER-335)'); } #[Test] diff --git a/tests/Unit/Acl/AuthorizationGuardTest.php b/tests/Unit/Acl/AuthorizationGuardTest.php new file mode 100644 index 00000000..1f7c1479 --- /dev/null +++ b/tests/Unit/Acl/AuthorizationGuardTest.php @@ -0,0 +1,149 @@ +\s*inheritsRole\s*\(/', + '/(===|!==|==|!=)\s*[\'"](' . $roles . ')[\'"]/', + '/[\'"](' . $roles . ')[\'"]\s*(===|!==|==|!=)/', + ]; + foreach ($patterns as $re) { + if (preg_match_all($re, $code, $m)) { + $hits = array_merge($hits, $m[0]); + } + } + return $hits; + } + + #[Test] + public function no_module_service_decides_authorization_by_comparing_a_role_string(): void + { + $offenders = []; + foreach (glob(self::repo() . '/modules/*/services/*.php') as $file) { + $hits = self::offences(file_get_contents($file)); + if ($hits) { + $offenders[str_replace(self::repo() . '/', '', $file)] = array_values(array_unique($hits)); + } + } + $this->assertSame( + [], + $offenders, + "A role-string authorization comparison reappeared in an /api service (TIGER-335). " + . "Use \$this->_isAllowed(\$privilege) / \$this->_roleIsAllowed(\$role, \$privilege):\n" + . json_encode($offenders, JSON_PRETTY_PRINT) + ); + } + + #[Test] + public function the_role_compare_detector_actually_catches_the_defect(): void + { + // Mutation guard: the exact defect TIGER-335 removed must be caught... + $this->assertNotEmpty(self::offences("assertNotEmpty(self::offences("inheritsRole(\$r, 'admin');")); + $this->assertNotEmpty(self::offences("_isAdmin()) { return; }")); + $this->assertNotEmpty(self::offences("_isAtLeastAdmin();")); + // ...while the clean primitive + a role name in a comment/string-key do NOT trip it. + $this->assertEmpty(self::offences("_isAllowed('save')) { return; }")); + $this->assertEmpty(self::offences("assertEmpty(self::offences("insert(['role' => 'admin']);"), 'assigning a role as DATA is not a compare'); + } + + #[Test] + public function no_acl_ini_grants_guest_a_blanket_service_resource(): void + { + $offenders = []; + $inis = array_merge( + glob(self::repo() . '/core/configs/acl.ini') ?: [], + glob(self::repo() . '/modules/*/configs/acl.ini') ?: [] + ); + foreach ($inis as $ini) { + foreach (self::guestServiceRules($ini) as $rule) { + if ($rule['privilege'] === null) { + $offenders[] = str_replace(self::repo() . '/', '', $ini) . " → {$rule['resource']} (rule '{$rule['key']}')"; + } + } + } + $this->assertSame( + [], + $offenders, + "A blanket (privilege-less) guest grant on an /api service survives — the F5 shape (TIGER-335). " + . "Enumerate the guest-reachable methods as privilege-scoped rules:\n" . implode("\n", $offenders) + ); + } + + /** Parse an acl.ini's rules; return the ones granting `guest` on a *_Service_* resource. */ + private static function guestServiceRules(string $ini): array + { + $txt = file_get_contents($ini); + $rules = []; + foreach (['role', 'resource', 'privilege', 'permission'] as $field) { + if (preg_match_all('/acl\.rules\.(\w+)\.' . $field . '\s*=\s*"?([^"\n;]+)"?/', $txt, $m, PREG_SET_ORDER)) { + foreach ($m as $row) { + $rules[$row[1]][$field] = trim($row[2]); + } + } + } + $out = []; + foreach ($rules as $key => $r) { + $role = $r['role'] ?? ''; + $res = $r['resource'] ?? ''; + $perm = $r['permission'] ?? 'allow'; + if ($role === 'guest' && $perm === 'allow' && strpos($res, '_Service_') !== false) { + $out[] = ['key' => $key, 'resource' => $res, 'privilege' => $r['privilege'] ?? null]; + } + } + return $out; + } +} From 13fd7cd69bff4706d0e7e087f079cce95632aba2 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Wed, 21 Oct 2026 10:36:24 -0400 Subject: [PATCH 2/3] ci: re-trigger (unit 8.3 runner stall) From cd7990f2a2016021c3daf8f2de7638476fc4f296 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Wed, 21 Oct 2026 10:43:50 -0400 Subject: [PATCH 3/3] ci: re-trigger stuck javascript + smoke(8.1) jobs