← All documentation

Teams and tenancy

An organization is who your application is really for. A person signs in; an organization is what they are signing in to, what a subscription belongs to, and what keeps one customer’s records away from another’s.

There is no control panel for this — organizations are code, not settings. This page is here because signing in and payments both rest on it.


Creating one

Creating an organization and giving it an owner is a single act:

organization = SparrowAuth::Organization.create_with_owner!(
  account: current_account, name: "Acme"
)

An organization with nobody in it cannot invite anyone and cannot be handed on — the only way out of that state is a console. So it never exists, not even briefly.

The organization row is identity only: a name and a slug. Anything else your product knows about a customer belongs in your own table, where you can change your mind about it. Organizations are addressed by slug, so to_param gives you a readable URL.


One person, several teams

Somebody may belong to more than one organization. That is a join table, and it is deliberate.

The alternative — one organization per account, hung off the account itself — is right for plenty of products, and it cannot be undone later without migrating data and rewriting every query that assumed a single customer. An application that wants one team per person enforces that in its own code.

This is also why billing attaches to the organization and never to the person. Somebody can belong to several and can leave, and a subscription attached to a person walks out of the door with them.

account.member_of?(organization)       # => true / false
account.membership_in(organization)    # the membership, or nil
account.membership_in!(organization)   # the membership, or raises NotAMember
account.role_in(organization)          # => "owner", or nil

organization.memberships
organization.membership_for(account)
organization.owners                    # memberships, not accounts

organization.memberships.create!(account: person, role: "reviewer")
SparrowAuth::Membership.with_role("reviewer")

A role is a name

"owner", "inventory_manager", "reviewer" — whatever your application means. SparrowKit stores it and never ranks, orders or interprets it. There is no ladder and no notion of one role outranking another.

This is the part people most often expect to work differently, so it is worth being blunt: there is no permission system here. No policy object, no capability list, nothing to register, and no check that runs on your behalf.

Write the rule where the decision belongs, in ordinary Ruby:

class InvoicesController < ApplicationController
  before_action :require_admin

  private

  def require_admin
    head :forbidden unless current_account&.role_in(current_organization) == "admin"
  end
end

That is the whole story. “Only the author may edit this one” is an if in the action. If you want something more structured, reach for a policy library you already know — SparrowKit has no opinion to conflict with, because it does not know what your roles mean.

One exception worth knowing. organization.owners matches the literal string "owner". It is the only place SparrowKit looks at a role’s value, and payments uses it to work out where a failed-payment notice should go. If your application names its top role something else, owners will be empty — harmless, but do not expect it to find them.


Inviting somebody

Use an invitation for somebody who does not have an account yet. For somebody who does, just create the membership.

invitation, token = SparrowAuth::Invitation.invite!(
  email: "newcomer@example.org",
  invited_by: current_account,
  organization: current_organization,
  role: "reviewer",
  url_builder: ->(t) { "https://example.com/invitations/#{t}" }
)

SparrowAuth::Invitation.redeem!(token: params[:token], account: current_account)

The token comes back once and is never stored — what is kept is a scrambled copy, so a database backup is not a bag of working invitation links. Only somebody who has proved they can read mail at the invited address may accept.

You must set a policy, or every invitation is refused

# config/initializers/sparrow_auth.rb
config.authorize_invitation = lambda do |inviter:, organization:, role:|
  inviter.role_in(organization) == "owner"
end

The default refuses, on purpose, and it is worth understanding why rather than meeting it as a mysterious failure. An invitation grants whatever role it names, and the seat is created on the invitation’s authority. Without a policy, anybody who could reach the method could invite their own address as any role, accept it, and be seated. SparrowKit cannot decide this for you, so it refuses until you do.


Keeping customers apart

Two includes, and rows stop leaking between organizations:

class ApplicationController < ActionController::Base
  include SparrowAuth::Tenancy
end

class Invoice < ApplicationRecord
  include SparrowAuth::Tenanted
end

The first gives you two helpers, in controllers and views:

current_account       # the signed-in account, or nil
current_organization  # the team this request is for, or nil

switch_organization!(organization)   # refuses somebody who is not a member

current_organization is only ever set when a membership came back with it, so a request can never be acting for a team the account does not belong to.

The second is the one that matters. Every query on that model is limited to the organization in scope, and new rows are stamped with it automatically — you never write organization_id yourself. A query made with no organization in scope stops with an error:

SparrowAuth::UnscopedQuery

That is the whole design. The failure lands in development on the first request, rather than in production on the day a second customer signs up.

When you genuinely need to cross between customers, say so out loud:

SparrowAuth.across_all_organizations(reason: "nightly billing rollup") { ... }

Two honest limits

Rails’ unscoped still gets past this, as it gets past any default scope. Nothing can stop that. across_all_organizations is the version that leaves a reason in the code for a reviewer to find.

The error only covers requests. Background jobs, mailers and console work have no request to attach an organization to. Ask the membership directly there — account.membership_in!(organization) — and check the role.


Testing it

Worth testing directly, because the failure mode is silent:

it "does not show another organization's invoices" do
  SparrowAuth::Current.with_organization(other) { Invoice.create!(number: "X") }

  SparrowAuth::Current.with_organization(mine) do
    expect(Invoice.count).to eq(0)
  end
end

it "refuses a query with no organization in scope" do
  expect { Invoice.count }.to raise_error(SparrowAuth::UnscopedQuery)
end

Reset state between examples with SparrowAuth::Current.reset, and set config.authorize_invitation in your test setup or every invitation spec fails.


Errors

Rescue SparrowAuth::AccessError for refusals. Do not rescue SparrowAuth::Error: that also covers UnscopedQuery, and turning a leak between customers into a friendly 403 is the exact failure this design exists to make loud.

Error When
NotAMember membership_in! for a team they do not belong to
InvalidInvitation Malformed, expired, or already accepted
InvitationNotYours The accepting address does not match, or is unproved
InvitationNotAuthorized Your policy refused, or none is set
InvalidCode A wrong or expired sign-in code
UnverifiedAccount The address has not been proved yet
AccessDenied Yours to raise from your own rules; SparrowKit never raises it
UnscopedQuery A scoped query with no organization in scope — a bug

What this means for the other modules

Signing in signs in a person. Which team they are acting in is a separate question, asked on every request.

Payments bills the organization, never the person.

Mail does not care: a message goes to an address.