One Login, Many Accounts: Cross-Account Sessions on a Low-Code Platform

April 12, 2022

One Login, Many Accounts: Cross-Account Sessions on a Low-Code Platform

A practical scenario from a real customer.

Anita builds and ships workflows at AcmeCo. AcmeCo runs three platform accounts:

  • acme-dev — where Anita designs and tests new workflows.
  • acme-staging — where her team integration-tests against business data copies.
  • acme-prod — where the workflows actually run for the company.

Anita is a real, human user in all three. Her email — anita.rao@acme.com — is the same in all three. Her roles differ: full admin in dev, designer in staging, restricted approver in prod.

The first version of identity at the platform satisfied none of Anita's reasonable expectations. Each account was its own world: three user records, three passwords, three sessions, three places to log in. SSO worked inside an account; across accounts, you were on your own. This wasn't a SAML problem yet — none of these customers had connected an IdP. It was a platform-native problem: the same human moving between tenants under the same organization, expected to log in only once.

This post is about that in-app design — the org-to-account hierarchy, the cross-account session model, and the account picker that ended up being the visible tip of all of it. The SAML/SCIM federation that sits on top of this is a separate post, because it's a separate story.

Why one customer runs many platform accounts

Enterprise customers run multiple accounts for the same reasons they run multiple environments anywhere else:

  • Environment isolation. Dev, staging, and prod each have their own data, integrations, and backups. Mixing them in one account is operationally unsafe.
  • Organizational separation. A holding company may have several business units, each on its own account, with one corporate procurement contract on top.
  • Mergers and migrations. When two companies combine, two existing accounts coexist for months while migrations happen. Both have to keep working.

In every case, the customer's organization is one thing, and the accounts under it are several. The platform has to honor both halves of that statement: one organization (where the human's identity lives) and several accounts (where the work, the data, and the roles live).

The model: Organization → Account → User

The shape we ended up with has three levels and four collections in Mongo:

flowchart TB ORG["Organization<br/>AcmeCo"] ORG --> ACC1["Account<br/>acme-dev"] ORG --> ACC2["Account<br/>acme-staging"] ORG --> ACC3["Account<br/>acme-prod"] ORG --> OU["organization_users<br/>(one record per human in the org)"] OU --> AU1["account_users<br/>(acme-dev · admin)"] OU --> AU2["account_users<br/>(acme-staging · designer)"] OU --> AU3["account_users<br/>(acme-prod · approver)"]

The two important shifts from the old model:

  • An organization_user is the canonical identity for a human inside an organization. It owns the email, the credentials, the org-level metadata. It is not scoped to any account.
  • An account_user is the projection of that human into a specific account. It owns the role, the workspace memberships, the per-account preferences. It points back to its organization_user via org_user_id.

The Mongo shape, simplified:

// db.organization_users { _id: ObjectId("..."), organization_id: "acme", email: "anita.rao@acme.com", email_verified: true, password_hash: "...", // optional; not needed when SAML is on created_at: ISODate("...") } // db.account_users { _id: ObjectId("..."), account_id: "acme-dev", org_user_id: ObjectId("..."), // → organization_users display_name: "Anita Rao", role: "admin", status: "active", // ... } // One human in three accounts → one organization_users doc + three account_users docs. db.account_users.createIndex({ org_user_id: 1 }); db.account_users.createIndex({ account_id: 1, org_user_id: 1 }, { unique: true });

The unique compound index is the invariant that holds the model together: a single human can have at most one account_user per account. That's the thing that makes "switching to a different account" a deterministic operation later.

What "one login" actually does

When Anita signs in, this is the sequence:

flowchart TB LOGIN["Anita signs in<br/>(password / magic link / SSO)"] LOGIN --> ORGAUTH["Authenticated as<br/>organization_user"] ORGAUTH --> SESSION["Org-level session<br/>(short-lived JWT)"] SESSION --> LOOKUP["Find every account_user<br/>where org_user_id matches"] LOOKUP --> PICKER["Account picker:<br/>acme-dev · acme-staging · acme-prod"] PICKER --> PICK["Anita picks acme-dev"] PICK --> ACCSESSION["Account-scoped session<br/>(acme-dev · admin role)"]

Two distinct sessions, deliberately:

  • Org session — established by the auth event itself. Says "this browser belongs to organization_user X." No account scope; can't do anything inside an account on its own.
  • Account session — minted from the org session, scoped to a specific account_user. Carries the role, the workspace memberships, the audit identity for that account.

The Python code that produces the picker and mints the account session:

def login(credentials: Credentials) -> LoginResult: # 1. Authenticate at the organization level. org_user = auth.verify(credentials) if org_user is None: return LoginResult.denied("invalid_credentials") # 2. Find every account this human is a member of. account_users = list( db.account_users.find( {"org_user_id": org_user["_id"], "status": "active"}, projection={"account_id": 1, "role": 1, "display_name": 1}, ) ) # 3. Mint an org-level session. The picker uses this to know # who Anita is without re-asking for credentials. org_session = sessions.mint_org_session(org_user) return LoginResult.ok(org_session, account_users) def switch_account(org_session: OrgSession, account_id: str) -> AccountSession: # The account session is derived from the org session — no re-auth. account_user = db.account_users.find_one({ "org_user_id": org_session.org_user_id, "account_id": account_id, "status": "active", }) if account_user is None: raise PermissionDenied("not a member of this account") return sessions.mint_account_session(org_session, account_user)

The single most important property of this flow: switching accounts does not re-authenticate. The org session is the proof that Anita is who she says she is. The account session is just "and right now, she's acting in acme-dev as an admin." Switching is one Mongo lookup and one signed cookie swap.

The account picker is the visible tip

The picker is the most-used screen in the identity layer and the easiest one to get wrong. The version that survived several rounds of operator feedback does three things deliberately:

  • Lists every account the human is in, in a stable order. Customers expect "the list looks the same every time I log in". We sort by account_user.last_active_at descending, with ties broken alphabetically. Operators who alternate between two accounts always see them in the same two positions.
  • Carries the role visibly. Each row shows "acme-prod — Approver", not just "acme-prod". Operators with different roles in different accounts use this to remember where they are before they click.
  • Honors deep links. A direct link like https://platform.example.com/acme-dev/process/12345 skips the picker, mints the account session for acme-dev, and lands the user on the process. The picker is for browsing; the deep link is for getting work done.

The deep-link flow uses the same switch_account primitive — the org session is checked, the requested account is verified as a membership, and the account session is minted. There is no separate "auto-login" code path. One primitive, two entry points.

What stays per-account (and why)

The federation across accounts deliberately stops at identity. Three things stay strictly per-account:

  • Roles. The admin of acme-prod doesn't want IT changing what "Approver" means. Every account owns its role definitions; the membership table just points at them.
  • Workspaces and data. An account is a tenant boundary. Crossing it is a deliberate action (account switch), not a side effect.
  • Audit log writes. Every action in acme-dev writes to acme-dev's audit log, scoped by account_id. Cross-account audit queries are read-side joins; they don't require the writes to be merged.

Holding this line was a frequent point of negotiation. "Can we make Anita an admin everywhere with one toggle?" No — because the next customer asks the opposite, and the boundary that protects the second customer is the one that lets the first customer down. The platform's value is in the boundary; the federation's value is in not making the human pay for it three times.

The audit angle: actions across accounts

Anita's actions live in three audit logs (one per account). When the auditor asks "what did Anita do today?", the question crosses accounts but the data doesn't have to.

The query joins on org_user_id:

def actions_by_human(org_user_id: ObjectId, day: date) -> list[dict]: start = datetime.combine(day, time.min) end = start + timedelta(days=1) return list(db.audit_events.find({ "actor.org_user_id": org_user_id, "ts": {"$gte": start, "$lt": end}, }).sort("ts", 1))

Every audit event records both the account_id (for tenant scoping and per-account queries) and the actor.org_user_id (for cross-account queries). The compound index { "actor.org_user_id": 1, ts: 1 } on the audit collection makes the cross-account lookup fast even on long histories.

This is the simplest version of what the federation buys you. The data is account-scoped where it has to be, and human-scoped where the question is human-shaped. Both queries are first-class.

What the customer experiences

The shape of the experience after this layer landed:

  • Anita signs in once at AcmeCo and picks her account. Switching between dev, staging, and prod is a click, not a re-authentication.
  • Adding a new account is a one-time membership grant, not an identity event. The org-level identity already exists.
  • Removing Anita from one account doesn't touch the other two. Per-account autonomy is preserved.
  • Cross-account audit queries are first-class. "What did this human do today?" and "Who acted in this account today?" are both single indexed lookups.

The thing that made all of this work is one shape repeated everywhere: org_user_id is the cross-account join key. Sign-in joins by it. Audit cross-correlation joins by it. Account switching is a re-scope around it. Once that one identifier became the trustworthy bridge between the organization and the per-account user records, every other property — fast switching, correct audit, clean revocation — fell out as a consequence.

The companion to this post is about the next layer up: how a customer's enterprise IdP plugs into this model, what NameID means once it gets here, and how the SAML and SCIM machinery preserves all of these properties without making any of them weaker.

GitHub
LinkedIn
X