@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.
| Rol | Para que |
|---|---|
owner | Dueño. Manda en facturacion y no puede quedar ninguna org sin al menos uno. |
admin | Gestiona el equipo y el contenido, pero no la facturacion. |
member | Trabaja 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:
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#
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+requireOrgy 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
Response404.
Tras el guard, la organizacion queda en el contexto:
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.
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:
export const apiTenancy = createTenancy({
errorPage,
permissions: PERMISSIONS,
mode: "json", // { error: "..." } en vez de una pagina
});
Modulo de negocio completo#
import { Hono } from "hono";
import type { SoulEnv } from "@jun/soul";
import type { TenancyVariables } from "@jun/soul/tenancy";
import { escapeHtml } from "@jun/soul/lib";
import { shell, tenancy, ui } from "../core";
const facturas = new Hono<{ Bindings: SoulEnv; Variables: TenancyVariables }>();
facturas.use("*", tenancy.requireSessionWithOrg());
facturas.get("/", tenancy.requirePermission("facturas.read"), async (c) => {
const { results } = await c.env.DB.prepare(
"SELECT id, title, total FROM facturas WHERE org_id = ? ORDER BY id DESC",
).bind(c.var.org.id).all<{ id: number; title: string; total: number }>();
// can() decide la UI sin cortar la request.
const puedeBorrar = tenancy.can(c, "facturas.delete");
const filas = results.map((f) => `<tr>
<td>${escapeHtml(f.title)}</td>
<td>${f.total}</td>
<td>${puedeBorrar ? borrarForm(f.id) : ""}</td>
</tr>`).join("");
return c.html(shell({ title: "Facturas", body: `<table>${filas}</table>` }));
});
facturas.post("/:id/delete", tenancy.requirePermission("facturas.delete"), async (c) => {
const row = await tenancy.requireTenantResource<{ id: number; org_id: number }>(
c, "facturas", c.req.param("id"),
);
if (row instanceof Response) return row;
await c.env.DB.prepare("DELETE FROM facturas WHERE id = ?").bind(row.id).run();
return c.redirect("/facturas");
});
export default facturas;
B2B: elegir organizacion por la URL#
En B2C basta la organizacion personal. En B2B el usuario cambia de una a otra, normalmente por un parametro o un subdominio:
// /facturas?org=acme -> verifica la membresia y responde 404 si no es miembro
app.use("*", tenancy.requireSessionWithOrg((c) => c.req.query("org")));
// o por subdominio: acme.mi-saas.com
app.use("*", tenancy.requireSessionWithOrg((c) => {
const host = c.req.header("host") ?? "";
const sub = host.split(".")[0];
return sub === "www" || sub === "mi-saas" ? undefined : sub;
}));
Gestion de miembros#
Son funciones sueltas, sin interfaz: la UI la pones tu.
import {
listMembers, addMemberByEmail, changeMemberRole, removeMember,
} from "@jun/soul/tenancy";
const miembros = await listMembers(c.env.DB, c.var.org.id);
// Solo suma usuarios YA registrados (las invitaciones por token son v2).
const r = await addMemberByEmail(c.env.DB, c.var.org.id, "nuevo@empresa.com", "member");
// r: "added" | "not-found" | "already-member"
// Estas dos protegen al ultimo owner: no dejan que la org se quede sin dueño.
await changeMemberRole(c.env.DB, c.var.org.id, userId, "admin"); // "changed" | "last-owner"
await removeMember(c.env.DB, c.var.org.id, userId); // "removed" | "last-owner"
Modulo API con errores en JSON#
const api = new Hono<{ Bindings: SoulEnv; Variables: TenancyVariables }>();
api.use("*", apiTenancy.requireSessionWithOrg((c) => c.req.query("org")));
api.get("/facturas/:id", apiTenancy.requirePermission("facturas.read"), async (c) => {
const row = await apiTenancy.requireTenantResource(c, "facturas", c.req.param("id"));
if (row instanceof Response) return row; // { error: "..." } con 404
return c.json({ id: row.id, title: row.title });
});
Verificar el aislamiento en tests#
El check de IDOR crea un recurso en una organizacion e intenta leerlo desde otra. Debe dar 404.
import { runSecurityChecks } from "@jun/soul/testing";
const checks = await runSecurityChecks({
fetch: SELF.fetch.bind(SELF),
db: env.DB,
sessionSecret: env.SESSION_SECRET,
protectedPath: "/facturas",
adminPaths: ["/admin/users"],
createResource: async (db, orgId) => {
const r = await db.prepare(
"INSERT INTO facturas (org_id, title) VALUES (?, 'x') RETURNING id",
).bind(orgId).first<{ id: number }>();
return `/facturas/${r!.id}`;
},
});
for (const check of checks) expect(check.ok, check.detail).toBe(true);
La tabla de un modulo#
CREATE TABLE IF NOT EXISTS facturas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
org_id INTEGER NOT NULL REFERENCES orgs(id), -- obligatorio
title TEXT NOT NULL,
total INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
-- Sin este indice, cada listado hace un scan completo.
CREATE INDEX IF NOT EXISTS idx_facturas_org ON facturas(org_id);