Docs · Core Concepts

Accounts and Tenancy

The Account/User boundary, Current.account, and why every table gets account_id.

One rule keeps a multi-tenant app safe: every piece of customer data belongs to an Account, and every query has to go through it. Break that rule once and one customer can see another customer's data. One Shot ships this boundary from day one, even though v1 has exactly one user per account, because modeling the boundary upfront is what lets teams and shared workspaces ship later without retrofitting every table and query in the app.

The rule, in three parts

  • Every model you add gets an account_id. t.references :account, null: false, foreign_key: true in the migration, belongs_to :account in the model. No exceptions for "just this one table."
  • Every query is scoped to the current account. Write Current.account.things, never Thing.all. Current.account is the acting workspace, set from the session cookie the moment a request authenticates.
  • Every controller authorizes with Pundit, and Pundit's default Scope filters any relation with an account_id column down to the acting user's account. See Authorization for how the two layers work together.

Current.user and Current.account are the two values everything else is built on. Billing attaches to the Account (account.subscription), not the User, which is what makes a shared team subscription a natural later addition instead of a rewrite.

Why one account per user, for now

In v1, signing in for the first time is signing up: User.upsert_by_email! is the single chokepoint both the magic-code flow and the OAuth flows route through, and it creates the User and its Account together, in one transaction. There's no separate "create a workspace" step, and no invite flow, because there's nothing yet to invite someone into beyond their own account.

This is a deliberate, cheap-now choice, not a limitation you'll fight later. Every table, every query, and every policy already scopes to Current.account, not to Current.user. Adding multi-user teams later is additive, not a data-model change.

Extending to teams later

When you're ready for more than one user per workspace, the path is:

  1. Add a join model with a role (Membership, joining account, user, and a role column), and let a User belong to many Accounts through it.
  2. Add owner_id to Account and promote the existing implicit owner (today's sole user) to that role explicitly.
  3. Add an account switcher in the UI that sets Current.account to whichever workspace the signed-in user picked.
  4. Build the invite flow. Because every existing table and policy already scopes to Current.account, none of that code has to change; you're adding a new way to select which account is current, not rewriting how data is read.

Next

See how that scoping is enforced at the controller layer: Authorization.