Designing Multi-Tenant IAM Roles and Delegation Like an Enterprise Standard
Jul 2026 ยท 11 min read
Property agents on the platform I architected aren't solo operators. They run teams โ a principal agent, a couple of people who manage listings day to day, someone who just needs read access for reporting. And the same agent can belong to more than one agency. A home-grown isAdmin: booleanon a user record was never going to model that. What it actually needed was a real multi-tenant IAM system: accounts as tenants, agents as identities that can belong to several tenants at once, and a permission model that distinguishes "who owns this workspace" from "what can this specific person do in it."
One identity, many tenants
The core model separates the agent (a person, one identity) from the IAM account (a workspace/tenant, holding properties, billing, and team membership). An agent can be a member of several IAM accounts โ their own agency, plus a second one they consult for โ and membership, role, and permissions all live scoped to the account, not to the agent. This sounds like an obvious modeling choice in hindsight. It's also the choice that a single-tenant-first design almost never makes correctly on the first pass, because "can this person belong to more than one workspace" doesn't show up as a requirement until the second real customer needs it.
Roles answer one question. Permissions answer a different one.
The account has three roles โ owner, delegate, viewer โ and a separate, per-member permissions array that isn't simply derived from the role name:
members: [
{
agentId: "agent_owner",
role: "owner",
permissions: ["*"],
},
{
agentId: "agent_teammate",
role: "delegate",
permissions: ["properties:write", "inquiries:respond"],
},
]The role names the organizational fact that matters for billing and account transfer โ exactly one owner, always. The permissions array names the actual authorization surface, and it's assigned per member at invite time, not hardcoded from whatever role they hold. Two delegates can have meaningfully different permission sets. That separation is the difference between a system that can express "this delegate can manage listings but not respond to inquiries" and one that can only express "this person is a delegate, whatever that bundle happens to include this quarter." Conflating role and permission into one enum is the single most common shortcut home-grown RBAC systems take, and it's the one that most reliably runs out of road the first time a customer needs an access pattern that doesn't match any of your three predefined boxes.
The owner invariant lives in the data layer, not in every call site
Exactly one invariant matters more than any other in a system like this: the owner cannot be removed, and the owner's role cannot be silently changed out from under them. That guarantee isn't enforced by remembering to check for it in every route handler that touches membership โ it's enforced once, inside the model's own mutation methods:
removeMember(agentId) {
const member = findMember(agentId);
if (member.role === "owner") {
throw new Error("Cannot remove the owner from the account");
}
// ...
}Putting the guard here instead of in each API route means there's exactly one place this invariant can be violated: nowhere. A new endpoint that touches membership months from now inherits the protection automatically, instead of depending on whoever writes it remembering to re-check something that's easy to forget under deadline pressure.
An invitation is not a member
Invites live as their own object, with their own lifecycle, entirely separate from the membership list: a target email (which might not correspond to a signed-up agent yet), a proposed role and permission set, a 7-day expiry, and states โ pending, accepted, declined, expired, cancelled. Nothing gets added to the account's member list until the invite is explicitly accepted by an actual agent identity.
Modeling this as a separate lifecycle rather than a "pending" pseudo-member entry on the account itself is what keeps resend, expiry, and cancellation simple instead of turning the membership list into a place where some entries are real access grants and others are provisional maybes that need special-casing everywhere membership gets read.
Authorization as a claim, not a query
Every access token issued carries the account context directly โ the IAM account ID, the role, the resolved permissions array, even the effective subscription plan โ rather than just an agent ID that the server looks up on every request:
{
sub: "agent_teammate",
iamAccountId: "iamacct_bbb",
role: "delegate",
permissions: ["properties:write", "inquiries:respond"],
effectivePlan: "professional",
}Most authorization checks become a claim read against the decoded token, not a database round trip for every authenticated request. The trade-off is real and deliberate: a permission change doesn't take effect until the token is refreshed, which means there's a bounded staleness window between "an owner changed a delegate's permissions" and "that delegate's existing token reflects it." Accepting that small, bounded window in exchange for not hitting the database on every single request is the kind of trade-off that only makes sense once you've actually decided how much staleness your product can tolerate โ treating it as a footgun to avoid at all costs would mean paying for a database lookup on every request, forever, for a staleness window measured in the time between token refreshes.
Switching accounts mints a new token, it doesn't flip a pointer
Because authorization comes from the token's claims, switching between two agencies an agent belongs to can't just be a server-side "current account" pointer update โ the token itself has to change, since it's the token that determines what the next request is allowed to do. Switching accounts re-mints an access token scoped to the newly selected account, and that re-minting step is also exactly where the system re-validates that the agent is still actually a member of the target account with their current permissions. You can't switch into an account you were quietly removed from since your last token refresh โ the re-validation isn't a separate check bolted on afterward, it's a natural consequence of switching being "get a new token," not "update a flag."
Support access as a governed grant, not a bypass
The part of this system I'd point to as the clearest sign of enterprise-standard thinking, rather than just competent RBAC, is how internal support access to a customer's account is handled. The tempting shortcut is a hardcoded backdoor in the auth middleware โ "if this is the support service account, skip permission checks." That's a blanket bypass with no record of who it was used against, when, or with what access.
Instead, support access uses the exact same mechanism a real customer's delegate would: the support agent identity gets added as a delegatemember on specific IAM accounts, with the exact accounts and exact permissions declared explicitly in a config file โ not inferred, not blanket, not "every account, just in case." Granting it runs through an idempotent script that reports what it actually did:
// grant-support-owner-access
for (const grant of SUPPORT_OWNER_ACCESS_GRANTS) {
const result = await upsertIamAccountMember(grant, grant.permissions);
// "created" | "updated" | "unchanged" โ never a silent duplicate
}A companion audit script reads the same config and diffs it against live database state, flagging drift โ a permission that's quietly different from what was declared, an owner ID that no longer matches, a grant that exists in the config but not in the data, or vice versa. The config file is the source of truth for what access shouldexist; the audit script is what catches the gap between that and what actually does. Support access is just as visible, just as revocable, and just as auditable as any customer's own delegate grant โ because it's implemented as literally the same thing, not a parallel system that happens to be harder to see.
What "enterprise-grade" actually means here
None of the individual pieces of this system are exotic โ role-based access, JWT claims, invite tokens, and config-driven scripts are all standard tools. What makes the system feel like an enterprise standard rather than a collection of features bolted together as they came up is the small set of invariants that never get to bend: the owner cannot be removed by anyone, an invitation is not access until it's explicitly accepted, a permission change is visible in the token the moment it's refreshed, and even your own support team's access to a customer's data is a declared, auditable grant instead of a bypass. Those invariants are the actual design work. The role names and the JWT library are just where they happen to live.