From a857ea4d40715584ccac63cc0a359720396976ed Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Wed, 21 Oct 2026 10:14:30 -0400 Subject: [PATCH] TIGER-335 batch 5: migrate comment + newsletter off _isAtLeastAdmin() (v1.36.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The worst-defect form. Comment's "is a moderator" checks (edit/delete another's, edit-window bypass, moderate + datatable gates) now ask the moderation capability _isAllowed('moderate') — same role set as the old inheritsRole('admin') since moderate is admin-granted (superadmin/developer inherit it), so behavior-preserving. Newsletter's admin gates → _isAllowed(''). Fixed the acl.ini comments that cited the removed helper. Tests green: 87 (incl. the mutation-tested policy tests). Closes TIGER-343, TIGER-344. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ASauLLscjqdsNqBNsx2Typ --- CHANGELOG.md | 11 +++++++++++ library/Tiger/Version.php | 2 +- modules/comment/configs/acl.ini | 4 ++-- modules/comment/services/Comment.php | 10 +++++----- modules/comment/services/Settings.php | 2 +- modules/newsletter/configs/acl.ini | 4 ++-- modules/newsletter/services/Audience.php | 4 ++-- modules/newsletter/services/Subscribe.php | 2 +- 8 files changed, 25 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19f530e4..ad274dda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ All notable changes to **Tiger Core** (`webtigers/tiger-core`). Format follows ## [Unreleased] +## [1.36.0] — 2026-10-21 + +### Changed +- **TIGER-335 migration (batch 5)** — migrated the `_isAtLeastAdmin()` call sites (the worst-defect form) + in **comment** (TIGER-343) and **newsletter** (TIGER-344). Comment's "is a moderator" checks (edit/ + delete-another's, the edit-window bypass, the moderate + datatable gates) now ask the **moderation + capability** `_isAllowed('moderate')` instead of `inheritsRole('admin')`; newsletter's admin gates ask + `_isAllowed('')`. Behavior-preserving — `moderate`/`datatable`/the Audience grants resolve to + the same admin-and-inheriting-roles set the old helper matched. Updated the acl.ini comments that cited + the removed helper. After this, no non-test code calls `_isAtLeastAdmin()`. + ## [1.35.0] — 2026-10-21 ### Changed diff --git a/library/Tiger/Version.php b/library/Tiger/Version.php index d476547d..d0942fc5 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.35.0'; + const VERSION = '1.36.0'; } diff --git a/modules/comment/configs/acl.ini b/modules/comment/configs/acl.ini index 0ba8c5f1..2e3c106b 100644 --- a/modules/comment/configs/acl.ini +++ b/modules/comment/configs/acl.ini @@ -15,7 +15,7 @@ ; This matters for DISCOVERY as much as dispatch: the /api catalog + MCP `tools/list` reflect ; exactly the methods a role is ALLOWED, so a blanket guest allow advertised the admin ops ; (comment__comment__{moderate,datatable,delete}) to anonymous callers. Execution was already -; gated in-service by _isAtLeastAdmin()/ownership (defense-in-depth) — this stops ADVERTISING +; gated in-service by _isAllowed('moderate')/ownership (defense-in-depth) — this stops ADVERTISING ; them. Every call still re-checks the SUBJECT's own ACL resource, so a thread never exposes ; content the caller can't see. [production] @@ -44,7 +44,7 @@ acl.rules.comment_delete.resource = "Comment_Service_Comment" acl.rules.comment_delete.privilege = "delete" acl.rules.comment_delete.permission = "allow" -; Admin-only: the moderation queue's data + actions (also enforced in-service by _isAtLeastAdmin()). +; Admin-only: the moderation queue's data + actions (also enforced in-service by _isAllowed('moderate')). acl.rules.comment_moderate.role = "admin" acl.rules.comment_moderate.resource = "Comment_Service_Comment" acl.rules.comment_moderate.privilege = "moderate" diff --git a/modules/comment/services/Comment.php b/modules/comment/services/Comment.php index d6249c2f..c38bedab 100644 --- a/modules/comment/services/Comment.php +++ b/modules/comment/services/Comment.php @@ -179,9 +179,9 @@ public function edit(array $params): void $userId = (string) ($this->_user_id ?? ''); $mine = $userId !== '' && (string) $row->user_id === $userId; - if (!$mine && !$this->_isAtLeastAdmin()) { $this->_error('core.api.error.not_allowed'); return; } + if (!$mine && !$this->_isAllowed('moderate')) { $this->_error('core.api.error.not_allowed'); return; } - if ($mine && !$this->_isAtLeastAdmin() && (time() - strtotime((string) $row->created_at)) > Tiger_Comment::editWindow()) { + if ($mine && !$this->_isAllowed('moderate') && (time() - strtotime((string) $row->created_at)) > Tiger_Comment::editWindow()) { $this->_error('comment.error.edit_window'); return; } @@ -220,7 +220,7 @@ public function edit(array $params): void public function moderate(array $params): void { // Authorization BEFORE the feature flag: "not allowed" must never be masked by "not enabled". - if (!$this->_isAtLeastAdmin()) { $this->_error('core.api.error.not_allowed'); return; } + if (!$this->_isAllowed('moderate')) { $this->_error('core.api.error.not_allowed'); return; } if (!$this->_enabled()) { return; } $status = (string) ($params['status'] ?? ''); @@ -257,7 +257,7 @@ public function delete(array $params): void $userId = (string) ($this->_user_id ?? ''); $mine = $userId !== '' && (string) $row->user_id === $userId; - if (!$mine && !$this->_isAtLeastAdmin()) { $this->_error('core.api.error.not_allowed'); return; } + if (!$mine && !$this->_isAllowed('moderate')) { $this->_error('core.api.error.not_allowed'); return; } try { $this->_transaction(function () use ($model, $row) { @@ -279,7 +279,7 @@ public function delete(array $params): void */ public function datatable(array $params): void { - if (!$this->_isAtLeastAdmin()) { $this->_error('core.api.error.not_allowed'); return; } + if (!$this->_isAllowed('moderate')) { $this->_error('core.api.error.not_allowed'); return; } if (!$this->_enabled()) { return; } $dt = $this->_dtParams(); diff --git a/modules/comment/services/Settings.php b/modules/comment/services/Settings.php index 269127e4..ce520826 100644 --- a/modules/comment/services/Settings.php +++ b/modules/comment/services/Settings.php @@ -18,7 +18,7 @@ class Comment_Service_Settings extends Tiger_Service_Service */ public function save(array $params): void { - if (!$this->_isAdmin()) { $this->_error('core.api.error.not_allowed'); return; } + if (!$this->_isAllowed('save')) { $this->_error('core.api.error.not_allowed'); return; } $wanted = !empty($params['spam_agent']); diff --git a/modules/newsletter/configs/acl.ini b/modules/newsletter/configs/acl.ini index b1688a68..cedc1902 100644 --- a/modules/newsletter/configs/acl.ini +++ b/modules/newsletter/configs/acl.ini @@ -14,7 +14,7 @@ ; the admin method (datatable) and the audience service (segments/members) are granted only to ; admin+. This governs DISCOVERY as well as dispatch: the /api catalog + MCP tools/list reflect the ; methods a role is ALLOWED, so a blanket guest allow would advertise the admin ops to anonymous -; callers. Execution is also re-checked in-service by _isAtLeastAdmin() (defense-in-depth). +; callers. Execution is also re-checked in-service by _isAllowed() (defense-in-depth). [production] acl.resources.newsletter_subscribe_svc.resource = "Newsletter_Service_Subscribe" @@ -41,7 +41,7 @@ acl.rules.newsletter_index_ctrl.role = "guest" acl.rules.newsletter_index_ctrl.resource = "Newsletter_IndexController" acl.rules.newsletter_index_ctrl.permission = "allow" -; Admin-only: the subscriber grid's data (also re-checked in-service by _isAtLeastAdmin()). +; Admin-only: the subscriber grid's data (also re-checked in-service by _isAllowed('datatable')). acl.rules.newsletter_datatable.role = "admin" acl.rules.newsletter_datatable.resource = "Newsletter_Service_Subscribe" acl.rules.newsletter_datatable.privilege = "datatable" diff --git a/modules/newsletter/services/Audience.php b/modules/newsletter/services/Audience.php index 76854997..f750d2c1 100644 --- a/modules/newsletter/services/Audience.php +++ b/modules/newsletter/services/Audience.php @@ -23,14 +23,14 @@ class Newsletter_Service_Audience extends Tiger_Service_Service /** /api (admin): the newsletter segments an email tool / export can target. */ public function segments(array $params): void { - if (!$this->_isAtLeastAdmin()) { $this->_error('core.api.error.not_allowed'); return; } + if (!$this->_isAllowed('segments')) { $this->_error('core.api.error.not_allowed'); return; } $this->_success(['segments' => self::listSegments((string) ($this->_org_id ?? ''))]); } /** /api (admin): resolve a segment to its contacts. @param array $params segment */ public function members(array $params): void { - if (!$this->_isAtLeastAdmin()) { $this->_error('core.api.error.not_allowed'); return; } + if (!$this->_isAllowed('members')) { $this->_error('core.api.error.not_allowed'); return; } $this->_success(['members' => self::resolveMembers((string) ($this->_org_id ?? ''), (string) ($params['segment'] ?? ''))]); } diff --git a/modules/newsletter/services/Subscribe.php b/modules/newsletter/services/Subscribe.php index d1c9add0..175c2421 100644 --- a/modules/newsletter/services/Subscribe.php +++ b/modules/newsletter/services/Subscribe.php @@ -162,7 +162,7 @@ public function unsubscribe(array $params): void */ public function datatable(array $params): void { - if (!$this->_isAtLeastAdmin()) { $this->_error('core.api.error.not_allowed'); return; } + if (!$this->_isAllowed('datatable')) { $this->_error('core.api.error.not_allowed'); return; } $dt = $this->_dtParams($params); $status = (string) ($params['status'] ?? '');