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.