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: truein the migration,belongs_to :accountin the model. No exceptions for "just this one table." - Every query is scoped to the current account. Write
Current.account.things, neverThing.all.Current.accountis the acting workspace, set from the session cookie the moment a request authenticates. - Every controller authorizes with Pundit, and Pundit's default
Scopefilters any relation with anaccount_idcolumn 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:
- Add a join model with a role (
Membership, joiningaccount,user, and arolecolumn), and let aUserbelong to manyAccounts through it. - Add
owner_idtoAccountand promote the existing implicit owner (today's sole user) to that role explicitly. - Add an account switcher in the UI that sets
Current.accountto whichever workspace the signed-in user picked. - 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.