soul v4.2.0

@jun/soul/tenancy

Tenancy

Organizaciones, roles, permisos y aislamiento real entre inquilinos.

El modelo#

La unidad que paga y que posee los datos es la organizacion, no el usuario. Un usuario puede pertenecer a varias, con un rol en cada una.

RolPara que
ownerDueño. Manda en facturacion y no puede quedar ninguna org sin al menos uno.
adminGestiona el equipo y el contenido, pero no la facturacion.
memberTrabaja con los datos del dia a dia.

Declarar los permisos#

Los permisos son un mapa declarativo de accion a roles. Vive en src/config.ts:

TypeScript
import type { PermissionMap } from "@jun/soul";

export const PERMISSIONS: PermissionMap = {
  "facturas.read":   ["owner", "admin", "member"],
  "facturas.write":  ["owner", "admin", "member"],
  "facturas.delete": ["owner", "admin"],
  "members.manage":  ["owner", "admin"],
  "billing.manage":  ["owner"],
};

Crear la instancia#

TypeScript
import { createTenancy } from "@jun/soul/tenancy";
import { PERMISSIONS } from "./config";

export const tenancy = createTenancy({ errorPage, permissions: PERMISSIONS });

Los guards#

tenancy.requireSessionWithOrg(getOrgSlug?)
El recomendado. Sesion + usuario + organizacion activa en un solo JOIN. Reemplaza al par requireSession + requireOrg y usa la mitad de lecturas.
tenancy.requireOrg(getOrgSlug?)
Solo la organizacion, asumiendo que ya hay sesion. Sin argumento toma la mas antigua del usuario (en B2C, la personal).
tenancy.requirePermission(accion)
Corta con 403 si el rol no tiene la accion. Va despues del guard de organizacion.
tenancy.can(c, accion)
Devuelve booleano sin cortar. Para decidir si pintas un boton.
tenancy.requireTenantResource(c, tabla, id)
Trae una fila comprobando que sea de la organizacion activa. Devuelve la fila o una Response 404.

Tras el guard, la organizacion queda en el contexto:

TypeScript
c.var.org;   // { id, slug, name, kind, plan, role }

Aislamiento entre organizaciones#

Es la parte que hay que hacer bien: que un cliente no pueda ver los datos de otro cambiando un id en la URL. La regla es que todo acceso por id pasa por requireTenantResource.

TypeScript
facturas.get("/:id", tenancy.requirePermission("facturas.read"), async (c) => {
  const row = await tenancy.requireTenantResource(c, "facturas", c.req.param("id"));
  if (row instanceof Response) return row;   // 404 estilizado
  return c.html(shell({ title: row.title, body: detalle(row) }));
});

Modo JSON para APIs#

Por defecto los errores son HTML. Un modulo que sirve JSON necesita su propia instancia:

TypeScript
export const apiTenancy = createTenancy({
  errorPage,
  permissions: PERMISSIONS,
  mode: "json",   // { error: "..." } en vez de una pagina
});