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, defaultmember),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.