Organizaciones con Better Auth: multi-tenant paso a paso

Armá organizaciones multi-tenant con el plugin organization de Better Auth: server, cliente, invitaciones y roles. Y cómo lo hace useDeploy sin el plugin.

MC
Martin Coll

Para volver multi-tenant una app con Better Auth agregás el plugin organization() en el server y organizationClient() en el cliente, corrés la migración del schema y usás authClient.organization.* para crear organizaciones, invitar miembros y chequear permisos. Con eso tenés organizaciones, miembros, invitaciones y tres roles por defecto con muy poco código. Esta guía recorre ese setup con [email protected] y después explica qué hace useDeploy en su lugar: no usa el plugin, tiene su propio módulo de tenancy montado sobre las sesiones de Better Auth.

Todas las APIs de abajo siguen la documentación del plugin organization, y los snippets compilan contra 1.6.9.

1. Agregá el plugin en el server

import { betterAuth } from 'better-auth';
import { organization } from 'better-auth/plugins';

export const auth = betterAuth({
  // database, emailAndPassword, ...
  plugins: [
    organization({
      allowUserToCreateOrganization: true, // por defecto
      creatorRole: 'owner', // por defecto
      membershipLimit: 100, // por defecto
      sendInvitationEmail: async (data) => {
        const link = `https://app.example.com/accept-invitation/${data.id}`;
        await sendEmail(data.email, link); // tu proveedor de email
      },
    }),
  ],
});

Qué hace cada opción, según la doc:

  • allowUserToCreateOrganization puede ser un boolean o una función del usuario, así que podés condicionar la creación de organizaciones a un plan.
  • creatorRole es el rol que recibe quien la crea: owner o admin.
  • membershipLimit limita los miembros por organización.
  • sendInvitationEmail lo implementás vos. El plugin te pasa la invitación, armás el link con data.id y mandás el mail.

2. Creá las tablas

El plugin agrega tablas para organizaciones, miembros e invitaciones, además de campos en la sesión que guardan la organización activa. Se generan o aplican con el CLI de Better Auth:

npx auth migrate     # aplica el schema a tu base
npx auth generate    # escribe el schema para tu ORM en lugar de aplicarlo

Si usás Prisma, corré generate, integrá el resultado en tu schema.prisma y hacé la migración de Prisma de siempre.

3. Agregá el plugin del cliente

import { createAuthClient } from 'better-auth/client';
import { organizationClient } from 'better-auth/client/plugins';

export const authClient = createAuthClient({
  plugins: [organizationClient()],
});

4. Usalo

// crear una organización y pasar a ella
await authClient.organization.create({ name: 'Acme', slug: 'acme' });
await authClient.organization.setActive({ organizationSlug: 'acme' });

// invitar a alguien
await authClient.organization.inviteMember({ email: '[email protected]', role: 'member' });

// quien recibe la invitación la acepta con el id del link del mail
await authClient.organization.acceptInvitation({ invitationId });

// preguntar si el usuario actual puede hacer algo
await authClient.organization.hasPermission({ permissions: { project: ['create'] } });

En el server las mismas operaciones viven bajo auth.api, por ejemplo auth.api.hasPermission({ headers, body: { permissions } }). Cada request se hace como un usuario con sesión, así que Better Auth sabe quién pregunta.

5. Roles propios con access control

Los roles por defecto son owner (control total), admin (todo menos borrar la organización o cambiar al owner) y member (solo lectura). Para modelar tus propios recursos declarás un statement y armás roles a partir de él:

import { createAccessControl } from 'better-auth/plugins/access';
import { defaultStatements, adminAc } from 'better-auth/plugins/organization/access';

const statement = {
  ...defaultStatements,
  project: ['create', 'share', 'update', 'delete'],
} as const;

const ac = createAccessControl(statement);

const member = ac.newRole({ project: ['create'] });
const admin = ac.newRole({ project: ['create', 'update'], ...adminAc.statements });
const owner = ac.newRole({
  project: ['create', 'share', 'update', 'delete'],
  ...adminAc.statements,
});

// pasá los mismos ac y roles a los dos lados
organization({ ac, roles: { owner, admin, member } });
organizationClient({ ac, roles: { owner, admin, member } });

Estos roles están definidos en código, así que cambiar uno implica un deploy. La doc también describe una opción de dynamic access control, respaldada por una tabla organizationRole, para roles creados en runtime.

Cuándo alcanza el plugin

Usalo cuando quieras multi-tenancy rápido y tus necesidades encajen con su modelo: un conjunto chico y fijo de roles, una organización activa por sesión e invitaciones por email. Lo mantiene el mismo equipo de la librería de auth, así que las actualizaciones van en sincronía.

Cómo lo hace useDeploy

La instancia de Better Auth de useDeploy activa tres plugins, magicLink, twoFactor y passkey, en apps/server/src/modules/iam/infrastructure/auth/better-auth.adapter.ts. No activa organization(). Better Auth se ocupa de la identidad: login, sesiones, 2FA. La tenancy es otro bounded context, apps/server/src/modules/tenancy, con sus propios modelos de Prisma: Organization, OrganizationMember, Role e Invitation.

Lo armamos así por requisitos que el modelo del plugin no cubre de entrada:

  • Los roles son datos. Cada organización tiene filas Role con una lista de permisos resource:action. Los owners editan roles en runtime. Los roles de sistema (owner, admin, member) se crean con cada organización y no se pueden editar ni borrar.
  • La organización viaja con cada request. La organización activa no se guarda en la sesión. El cliente manda un header x-organization-id en cada request y un middleware verifica la membresía del usuario en cada una. Scripts y clientes de API pueden apuntar a cualquier organización a la que el usuario pertenezca sin cambiar el estado de la sesión.
  • Las invariantes viven en el dominio. No se puede degradar ni sacar al último owner; el chequeo corre en una transacción serializable para que dos requests concurrentes no ganen las dos. Un rol solo lo puede otorgar quien ya tiene sus permisos.
  • Los tokens de invitación no se guardan. En la base solo está el hash SHA-256 del token.
  • Suspensión. Una organización suspendida o borrada se rechaza en cada request, incluso para miembros válidos.
  • Las API keys quedan atadas a una organización, con permisos propios acotados.

El costo es que ese código lo mantenés vos, por eso es un módulo de un boilerplate y no algo que te sugeriríamos escribir para un proyecto de fin de semana. El flujo del request, los middlewares y el catálogo de permisos están en RBAC en Express con permisos por organización. El lado de las fugas de datos está en Multi-tenancy in Prisma without RLS (en inglés), y el ángulo para agencias en Reusing one multi-tenant base across every client (también en inglés).

Preguntas frecuentes

¿Necesito el plugin organization para tener multi-tenancy con Better Auth? No. Better Auth solo autentica al usuario. Podés modelar las organizaciones en tus propias tablas, como hace useDeploy, y usar el id del usuario de la sesión para buscar sus membresías.

¿El plugin soporta más de una organización por usuario? Sí. setActive existe para pasar de una a otra entre las organizaciones a las que el usuario pertenece; la elegida es la organización activa de la sesión.

¿Dónde vive la organización activa en useDeploy? En el cliente (un store persistido) y en el header x-organization-id. El server lo valida contra la tabla de membresías en cada request y nunca confía en él por sí solo.

¿Para qué versión de Better Auth está escrito esto? 1.6.9, la que usa el package.json de useDeploy. Fijate en la doc por si hay opciones nuevas.


useDeploy trae Better Auth más el módulo de tenancy descripto arriba, con organizaciones, miembros, invitaciones y roles. Mirá la integración con Better Auth y la feature de multi-tenancy.