Keel

Docs / Teams & RBAC / Team model

Team model

Keel is multi-tenant from the first migration. A team is the unit everything hangs off, users, billing, credits, API keys, and every domain table are scoped to a team, never to a bare user id.

Every user gets a personal team

When an account is created, createPersonalTeam() in lib/teams.ts runs, called from the databaseHooks.user.create.after hook in lib/auth.ts so it covers email/password sign-up, magic-link, and OAuth without each path duplicating the logic. It inserts a teams row ({name}'s Team), a team_members row making the user its owner, and a zero-balance credits row.

This keeps one invariant true everywhere else: every user belongs to at least one team. The team switcher, dashboard, and settings pages all read teams[0] and assume it exists.

Deleting your only team is blocked

The DELETE /api/teams/[teamId] route refuses to remove a user's last remaining team, and account deletion only drops teams the departing user solely owns (deleteSolelyOwnedTeams). Nothing in the app is built to handle a teamless user.

Belonging to multiple teams

A user can own or join any number of teams. POST /api/teams lets a signed-in user spin up an additional team (they become its owner), separate from their personal one, for keeping client and personal work apart. Membership is the team_members join table, so the same user id appears once per team with a role on each. There is no persisted active-team selection yet, switching in the UI is local-view-only.

The schema

teams (lib/db/schema/team.ts):

  • id, name, slug (unique), logo (nullable), createdAt, updatedAt

team_members:

  • id, teamId -> teams.id (cascade), userId -> user.id (cascade), role (owner | admin | member, default member), createdAt
  • unique index on (teamId, userId), a user is a member of a team at most once

team_id everywhere, on purpose

Every custom table under lib/db/schema/* (except Better Auth's own user/session/account/verification) carries a team_id foreign key, including credits and usage_events, not just the obvious ones like subscriptions. This is deliberate: teams are core v1, not a bolt-on, and retrofitting scoping onto a table later means an invasive migration plus an audit of every query touching it. The rule, documented in memory/architecture.md, is: new table, add team_id, read it through lib/dal/*.ts, no exceptions.

One gate: authorizeTeamAccess

Every team-scoped API route calls authorizeTeamAccess(teamId, permission) (in lib/dal/teams.ts) before it touches data, instead of re-deriving the session or re-querying membership inline. It confirms a session exists, the team exists, the caller is a member, and their role holds the requested permission, returning 401, 404, or 403 otherwise. A non-member gets 404, never 403, so the route never confirms a team's existence to an outsider. For how permission is resolved against a role, see Permissions.

Edit this page on GitHub →Last updated Jul 22, 2026