Keelv0.86.0
Docs / Teams

Multi-tenancy, membership, roles, and invitations — where a row belongs to a team, and one team can never see another's.

// bootstrap/providers.ts
import { TeamsServiceProvider } from "@shaferllc/keel/teams";

app.register(TeamsServiceProvider);
// app/Http/Kernel.ts
import { teamContext } from "@shaferllc/keel/teams";

protected middleware = [sessionMiddleware(), teamContext()];

Then a tenant-owned model is one word:

import { TenantModel } from "@shaferllc/keel/teams";

class Post extends TenantModel {
  static table = "posts";
}

await Post.all();                     // only the current team's posts
await Post.create({ title: "Hi" });   // stamped with the current team

Isolation is the default, not a habit

Two halves, and both matter.

Reads are constrained by a global scope on TenantModel, so every query the model builds carries the team — including find(). Naming another team's row by its id returns null, not that row. This is the difference between tenancy and a list filter: a filter you forget on one endpoint is a leak; a scope you never write can't be forgotten.

Writes are stamped by a creating hook, so a row cannot be born ownerless and end up visible to everyone (or to no one).

No team means an error, not "everything"

A queued job, a console command, a webhook, a seeder — none of them run inside a request, so none of them have a team. A tenant query there throws.

await Post.all();
// Error: No team in context, so a tenant-scoped query can't be built safely.
//   Inside a request, add teamContext() to your middleware.
//   In a job, command, or seeder, wrap the work: runForTeam(team, () => …).
//   If it genuinely spans every team, say so: withoutTenant(() => …).

This is the security model, and the alternatives are worse:

If no team meant… Then
unscoped every background job sees every tenant's rows — this is how customer A's invoice reaches customer B
teamId = NULL jobs match nothing, "work" fine, and quietly do nothing for a month
an error a job that forgot crashes in development instead of leaking in production

So a job says which team it's for:

await runForTeam(team, () => sendInvoices());

...or says, out loud, that it isn't for one:

await withoutTenant(() => Post.withoutGlobalScope(TENANT_SCOPE).get());

Both are named calls you can grep for at audit time. That's the point: crossing a tenant boundary should be something you typed, never something you arrived at by forgetting a where.

Your jobs will crash until each one is wrapped. That friction is the feature — it is a loud failure in development in exchange for not having a silent one in production.

The context lives in AsyncLocalStorage, not a module global, so two concurrent requests can't see each other's team.

Teams and membership

const team = await createTeam("Acme", user.id);   // creator becomes the owner

await teamsFor(user.id);            // the teams a user is in
await roleOf(user.id, team.id);     // "owner" | "admin" | "member" | null
await memberOf(user.id, team.id, "admin");
await switchTeam(user.id, team.id); // false if they aren't a member

A user is in a team if and only if a membership row says so. teams.owner_id is a convenience, not an authorization source.

switchTeam() verifies membership, and so does teamContext() on every request — users.current_team_id is just a number on a row the user can influence, so it is checked, never trusted. Without that, switching teams would be a matter of writing someone else's id onto your own row.

Team and Membership are deliberately not tenant-scoped: "which teams am I in?" is a question you have to answer before you know which team you're in.

Roles

owner > admin > member, ordered — an owner can do anything an admin can.

router.delete("/posts/:post", …).middleware(requireRole("admin"));
roleAtLeast("owner", "admin");   // true
roleAtLeast("member", "admin");  // false

Invitations

const { token } = await invite(team.id, "grace@example.com", "admin");
await acceptInvitation(token, user.id, user.email);

await pendingInvitations(team.id);
await revokeInvitation(id);

Unlike a password-reset link, an invitation is a database row — it has to be listable ("3 pending") and revocable, and you can't revoke a stateless token. Only the token's hash is stored, so a database leak doesn't open every pending team.

The invited address is re-checked on accept, so a forwarded link doesn't let someone else join in the invitee's place — which is the interesting attack on an invitation system. Invitations are single-use, expire (72h by default), and re-inviting the same address replaces the outstanding invitation rather than stacking duplicates.

Personal teams

On by default: every new user gets a team of their own, and a solo user is simply a team of one.

Worth leaving on even for an app that feels single-user. Tenancy is not a feature you can add later — bolting a team_id onto a schema that already has customer data means a backfill, a migration on every table, and rewriting every query. Ignoring a team you have costs one unused row. Needing a team you don't have costs a weekend.

Configuration

keel vendor:publish --tag teams-config
export default {
  userTable: "users",
  personalTeams: true,
  invitations: { expiresInHours: 72, url: "/invitations/:token" },
};

The schema

Table
teams name, slug, owner_id
team_memberships team_id, user_id, role — unique per (team, user), enforced by the database
team_invitations team_id, email, role, token hash, expires_at

Plus current_team_id on your users table.