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:
- Deny-by-default. A resource with no explicit allow rule is denied. You grant access; you never have to remember to deny it.
- 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
.inikeys. - 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.