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
12 changes: 7 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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('<method>')`
guard is defense-in-depth (and the only gate for an in-process caller) — it does not replace the
`acl.ini` rule.

## Forms

Expand Down Expand Up @@ -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

Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion WEBSERVICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
16 changes: 13 additions & 3 deletions core/configs/acl.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions library/Tiger/Ajax/ServiceFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion library/Tiger/Controller/Plugin/Authorization.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
29 changes: 0 additions & 29 deletions library/Tiger/Message.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion library/Tiger/Service/Authentication.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 1 addition & 60 deletions library/Tiger/Service/Service.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
* }
Expand Down Expand Up @@ -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. */
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.36.0';
const VERSION = '1.37.0';
}
2 changes: 1 addition & 1 deletion modules/message/services/Message.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions modules/search/configs/acl.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion modules/signup/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).

Expand Down
13 changes: 10 additions & 3 deletions modules/signup/configs/acl.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
3 changes: 2 additions & 1 deletion tests/Integration/Signup/SignupServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading
Loading