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 user or org. 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.