Authorization (ACL)

Authentication answers "who are you?" Authorization answers "are you allowed to do this?" — and in Tiger the answer is always data, never a hardcoded role check.

Deny-by-default, and you can't forget it

Two rules do the heavy lifting:

  1. Deny-by-default. A resource with no explicit allow rule is denied. You grant access; you never have to remember to deny it.
  2. Unbypassable enforcement. Checks run in a front-controller plugin, not a base-controller method you might forget to inherit. Every request passes through it before dispatch. There's no "oops, this one controller didn't check."
request → [ACL plugin: isAllowed(role, resource, privilege)?] → dispatch  (or 403)

Never compare role strings

This is the anti-pattern Tiger designs out:

if ($user->role === 'admin') { … }        // ❌ never do this

Instead, every decision goes through Zend_Acl::isAllowed, resolved from data:

// Inside a service, the base gives you the gate — resource + privilege come from the ACL data.
if (!$this->_isAdmin()) { $this->_error('core.api.error.not_allowed'); return; }

"God mode," "can create admins," "can see billing" — these are ACL grants, not if branches. The engine hardcodes nothing.

Roles live on the membership, resolved live

The role comes from the user's org_user membership (see Multi-tenancy) and is resolved fresh every request. Revoke or change a membership and it takes effect on the next request — no stale sessions, no forced re-login.

The default role graph (apps add/re-parent their own):

guest → user → manager → supermanager → admin → superadmin → developer

Roles inherit — grant something to manager and everyone above it has it too.

Grants are config + data

A module declares its own permissions in configs/acl.ini. Resources are class names; privileges are actions:

; billing/configs/acl.ini — resources are class names, deny-by-default
acl.resources.billing_invoice_ctrl.resource = "Billing_InvoiceController"
acl.resources.billing_invoice_svc.resource  = "Billing_Service_Invoice"

; allow admins to the controller + its /api service
acl.rules.billing_admin.role       = "admin"
acl.rules.billing_admin.resource   = "Billing_InvoiceController"
acl.rules.billing_admin.permission = "allow"

acl.rules.billing_svc.role       = "admin"
acl.rules.billing_svc.resource   = "Billing_Service_Invoice"
acl.rules.billing_svc.permission = "allow"

Ships in the module, merges into the global ACL on activation, touches no core file. Beyond the .ini base, the acl_* tables are the runtime override tier — same live-override pattern as config and translations (last wins, no deploy).

The mental model

  • Subject-agnostic: the engine asks can(subject, permission, context) — the subject is usually a user, but could be an org or a token.
  • Data, not code: roles, resources, and rules are rows and .ini keys.
  • One chokepoint: the front-controller plugin. Every request, every time.

"Why am I locked out?" — the ACL Simulator

Deny-by-default is safe but silent — when a request is refused you want to know exactly why. The ACL Simulator (Admin → ACL Simulator, superadmin) answers it: pick a role × resource × privilege and it runs the live ACL and shows the verdict plus the reasoning — the deciding rule (inheritance-aware) or deny-by-default, and the role's full inheritance chain.

So "would a manager reach Billing_Service_Invoice · refund?" becomes a question you ask the ACL, not one you reverse-engineer from .ini files. It reads only — it never changes policy.

Deny-by-default + unbypassable + data-driven is the combination that makes "did we secure that endpoint?" a question you answer by reading a config file, not by auditing every controller.