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, in one transaction via createTeamWithOwner(), the same helper POST /api/teams uses.

This keeps one invariant true everywhere else: every user belongs to at least one team. The active-team fallback (below) relies on that: when nothing else is selected, the first team is used.

Deleting your only team is blocked

The DELETE /api/teams/[teamId] route refuses to remove a user's last remaining team, and leaving your only team is refused the same way. Both checks lock the affected membership rows inside the delete transaction, so concurrent requests can't race past them. Nothing in the app is built to handle a teamless user.

Deleting an account

Account deletion (self-serve or from the admin panel) drops every team the user solely owns (deleteSolelyOwnedTeams). Keel has no ownership transfer, so if one of those teams still has other members, deletion is refused with a message naming the team (getAccountDeletionBlocker in lib/teams.ts). The owner removes the other members or deletes the team first. Teams with another owner are left alone.

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.

The active team

The sidebar team switcher decides which team the dashboard, Team/Billing/API keys settings, AI chat, and the credit balance in the shell all operate on.

  • Picking a team calls POST /api/teams/active with { teamId }. The route checks membership (a non-member gets 404) and stores the id in an httpOnly keel-active-team cookie, then the switcher calls router.refresh().
  • Server code resolves it with getActiveTeam(userId) in lib/dal/teams.ts, which returns { teams, activeTeam }. The cookie is only a preference: it's matched against the user's current memberships on every request, and falls back to the first team (the personal one) when it's missing, points at a team the user left or was removed from, or names a deleted team. The pure fallback rule is pickActiveTeam() in lib/active-team.ts.
  • Client components read the same resolution from GET /api/teams/active ({ active: { team, role } | null }) instead of picking an entry out of GET /api/teams themselves.
  • app/app/layout.tsx keys <main> by the active team id, so a switch remounts the current page and client orchestrators refetch for the new team.

The cookie never grants access. Every team-scoped API route still takes an explicit teamId and goes through authorizeTeamAccess, so a tampered cookie can at most select a team the user already belongs to.

If you add a team-scoped page, read the team from getActiveTeam (server) or /api/teams/active (client), never teams[0].

The schema

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

  • id, name, slug (unique), logo (nullable), createdAt, updatedAt
  • logo only accepts an Uploadthing-hosted URL (isUploadthingUrl in lib/uploadthing-url.ts), so a member can't point it at an arbitrary third-party image. Update that allowlist if you switch storage providers.

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.