Orgs, users & membership
Multi-tenancy is the thing every SaaS needs and almost everyone gets subtly wrong. Tiger's model is three tables and one big idea.
The three pieces
| Entity | What it is | Notes |
|---|---|---|
| Org | the tenant (a workspace/account) | self-referential parent_org_id → sub-tenants/hierarchies |
| User | a person (identity only) | deliberately thin — no app data piled on |
org_user |
a membership | the join row that is both the tenancy boundary and the role carrier |
That middle row is the whole trick. A user isn't "in" an org by having an org_id column — they're in it because an org_user row exists linking them.
Role lives on the membership, not the user
This is the part single-tenant thinking gets wrong. The role isn't on the user — it's on the membership:
user: thundarr@example.com
├── org_user → org "Acme" role = admin
└── org_user → org "Wumpus" role = viewer
Same person, admin in one workspace, viewer in another. Because the role rides on org_user, this is free — no per-app gymnastics, no "global role" that can't express reality.
Cross-tenant denial is structural
Here's the security property that matters: what stops a user acting in another tenant is the absence of an org_user row.
It's not a code check you write (and might forget) like if ($doc->org_id === $user->currentOrg). It's the shape of the data. No membership row → no access, everywhere, by construction. You can't forget to write a check that the schema enforces for you.
Extend users & orgs — never widen the core tables
Your app needs more fields on a user (avatar, bio, billing plan…). The rule:
Never add columns to
userororg. Extend them from a module, with your own FK-linked table.
// A billing module's own table — links to the core org, doesn't touch it.
class Billing_Model_Account extends Tiger_Model_Table
{
protected $_name = 'billing_account'; // org_id FK → org.org_id, plan, seats, …
}
Why so strict? Because user and org are Tiger-owned — the platform updates them. If you'd bolted columns onto user, a composer update (or the next Account module) could collide with you. Keep the core tables pure; hang your data off the side. (The first-party Account module is exactly this pattern — it extends identity without editing it.)
Reading the current tenant
The authenticated identity carries the active org + role, resolved live each request (a changed membership takes effect on the next request — no stale sessions):
$auth = Zend_Auth::getInstance();
if ($auth->hasIdentity()) {
$id = $auth->getIdentity();
$orgId = $id->org_id; // the active tenant
$role = $id->role; // resolved from org_user, this request
}
Every query you write for tenant data filters by that org_id, and every permission check flows through the ACL — which reads the same membership role. Tenancy and authorization are the same substrate, seen from two angles.