RBAC con Claude Code: autenticacion, autorizacion, tenants, pruebas y auditoria
Implementa RBAC seguro con Claude Code: permisos, tenants, middleware Express, pruebas negativas, logs de auditoria y limite hacia ABAC.
RBAC no es seguro solo porque una aplicacion tenga roles llamados admin, editor y viewer. La mayoria de fallos aparecen en detalles menos visibles: tratar a cualquier usuario autenticado como autorizado, olvidar el filtro de tenant en una consulta, ocultar un boton en la interfaz pero dejar abierto el endpoint, o permitir que un editor modifique un objeto ajeno con un ID adivinado.
Esta guia convierte RBAC en una tarea revisable para Claude Code. Separaremos autenticacion y autorizacion, modelaremos role / permission / resource / action, aplicaremos deny by default, protegeremos limites de tenant, agregaremos autorizacion por objeto, middleware de Express, schema de base de datos, pruebas, logs de auditoria y el punto donde conviene pasar a ABAC. Para la capa de login, revisa la guia de autenticacion JWT con Claude Code. Para controles operativos, complementa con buenas practicas de seguridad en Claude Code.
Las referencias externas usadas como base son OWASP Authorization Cheat Sheet, OWASP Access Control, Casbin RBAC y Auth0 Core RBAC. OWASP insiste en minimo privilegio, rechazo por defecto, validacion en cada request, logging y pruebas de autorizacion.
Autenticacion no es autorizacion
La autenticacion responde “quien es este usuario”. Password, SSO, JWT, cookie de sesion y MFA pertenecen a esta capa. La autorizacion responde “puede este actor ejecutar esta accion sobre este recurso”. Que alguien haya iniciado sesion no significa que pueda borrar facturas, invitar administradores o leer datos de otro cliente.
En RBAC conviene mantener cuatro conceptos separados:
| Elemento | Ejemplo | Pregunta de revision |
|---|---|---|
| role | viewer, editor, billing_admin, owner | Esta basado en responsabilidad real |
| permission | article:update, invoice:read | Esta definido en un unico lugar |
| resource | article, invoice, user | Que objeto se protege |
| action | read, create, update, delete | Es mas preciso que el metodo HTTP |
El error tipico es crear un admin enorme y luego sumar excepciones. En revision ya no se sabe si una excepcion es requisito de negocio o un atajo temporal. Es mas mantenible definir permissions primero y tratar cada role como un paquete de permissions.
flowchart TD
A["Authenticated actor"] --> B["Tenant boundary"]
B --> C["Role to permission map"]
C --> D["Resource and action check"]
D --> E["Object-level rule"]
E --> F["Allow"]
B --> X["Deny and audit"]
C --> X
D --> X
E --> X
Empezar con deny by default
Deny by default significa que toda operacion se rechaza salvo que el codigo la permita de forma explicita. Al pedir a Claude Code que toque autorizacion, escribe primero las reglas de rechazo: permission desconocido, lista de roles vacia, tenant distinto, recurso inexistente u objeto que no pertenece al actor deben fallar de forma cerrada.
El siguiente ejemplo de Express se puede copiar en un proyecto nuevo. Para que las pruebas sean claras, la demo lee identidad desde headers. En produccion debes sustituir esa parte por JWT o sesion ya verificados.
{
"name": "rbac-express-demo",
"type": "module",
"scripts": {
"dev": "tsx src/server.ts",
"test": "vitest run"
},
"dependencies": {
"express": "^5.1.0"
},
"devDependencies": {
"@types/express": "^5.0.3",
"@types/node": "^22.15.0",
"@types/supertest": "^6.0.3",
"supertest": "^7.1.1",
"tsx": "^4.19.4",
"typescript": "^5.8.3",
"vitest": "^3.1.4"
}
}
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}
Nucleo RBAC en TypeScript
No disperses condiciones de role por todos los controllers. Centraliza la decision en authorize y deja que las rutas pasen el nombre del permission. Asi la superficie de revision es pequena y Claude Code sabe donde tocar cuando aparece un nuevo recurso.
// src/rbac.ts
export const permissions = [
"project:read",
"article:read",
"article:create",
"article:update",
"article:delete",
"invoice:read",
"invoice:refund",
"user:manage"
] as const;
export type Permission = (typeof permissions)[number];
export type Role = "viewer" | "editor" | "billing_admin" | "owner";
export type Actor = {
id: string;
tenantId: string;
roles: Role[];
};
export type ResourceRecord = {
id: string;
tenantId: string;
ownerId?: string;
};
export type AuthorizationDecision = {
allow: boolean;
reason: string;
};
export const rolePermissions = {
viewer: ["project:read", "article:read", "invoice:read"],
editor: ["project:read", "article:read", "article:create", "article:update"],
billing_admin: ["project:read", "invoice:read", "invoice:refund"],
owner: [...permissions]
} as const satisfies Record<Role, readonly Permission[]>;
const knownPermissions = new Set<Permission>(permissions);
export function authorize(
actor: Actor,
permission: Permission,
record?: ResourceRecord
): AuthorizationDecision {
if (!knownPermissions.has(permission)) {
return { allow: false, reason: "unknown_permission" };
}
if (actor.roles.length === 0) {
return { allow: false, reason: "no_role" };
}
if (record && record.tenantId !== actor.tenantId) {
return { allow: false, reason: "tenant_mismatch" };
}
const roleAllows = actor.roles.some((role) =>
rolePermissions[role].includes(permission)
);
if (!roleAllows) {
return { allow: false, reason: "role_missing_permission" };
}
if (
permission === "article:update" &&
!actor.roles.includes("owner") &&
record?.ownerId !== actor.id
) {
return { allow: false, reason: "not_resource_owner" };
}
return { allow: true, reason: "allowed" };
}
export function auditAuthorization(input: {
actor?: Actor;
permission: Permission;
resourceId?: string;
decision: AuthorizationDecision;
}) {
console.info(
JSON.stringify({
type: "authorization",
actorId: input.actor?.id ?? "anonymous",
tenantId: input.actor?.tenantId ?? "unknown",
permission: input.permission,
resourceId: input.resourceId ?? null,
allow: input.decision.allow,
reason: input.decision.reason,
at: new Date().toISOString()
})
);
}
La ruta no pregunta “es editor”. Pide article:update. El role solo resuelve permissions; luego siguen el limite de tenant y la regla de propietario del objeto.
Middleware Express para cada request
La autorizacion debe ejecutarse antes de que el handler cambie datos. La recomendacion practica es que cada ruta protegida pase por middleware, porque asi se ve rapidamente que endpoints quedaron fuera.
// src/server.ts
import express, { type NextFunction, type Request, type Response } from "express";
import {
type Actor,
type Permission,
type ResourceRecord,
type Role,
auditAuthorization,
authorize,
rolePermissions
} from "./rbac.js";
declare global {
namespace Express {
interface Request {
actor?: Actor;
}
}
}
type Article = ResourceRecord & {
title: string;
body: string;
};
const articles: Article[] = [
{ id: "a1", tenantId: "tenant-a", ownerId: "user-1", title: "Roadmap", body: "Draft" },
{ id: "a2", tenantId: "tenant-a", ownerId: "user-2", title: "Release", body: "Ready" },
{ id: "b1", tenantId: "tenant-b", ownerId: "user-9", title: "Private", body: "Secret" }
];
function parseRoles(value: string | undefined): Role[] {
return (value ?? "")
.split(",")
.map((role) => role.trim())
.filter((role): role is Role => role in rolePermissions);
}
function authenticateForDemo(req: Request, _res: Response, next: NextFunction) {
const userId = req.header("x-user-id");
const tenantId = req.header("x-tenant-id");
if (userId && tenantId) {
req.actor = {
id: userId,
tenantId,
roles: parseRoles(req.header("x-roles"))
};
}
next();
}
function findArticle(req: Request): Article | undefined {
return articles.find((article) => article.id === req.params.articleId);
}
function requirePermission(
permission: Permission,
loadResource?: (req: Request) => ResourceRecord | undefined
) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.actor) {
return res.status(401).json({ error: "unauthenticated" });
}
const record = loadResource?.(req);
if (loadResource && !record) {
return res.status(404).json({ error: "not_found" });
}
const decision = authorize(req.actor, permission, record);
auditAuthorization({
actor: req.actor,
permission,
resourceId: record?.id,
decision
});
if (!decision.allow) {
return res.status(403).json({ error: "forbidden", reason: decision.reason });
}
return next();
};
}
export const app = express();
app.use(express.json());
app.use(authenticateForDemo);
app.get("/health", (_req, res) => {
res.json({ ok: true });
});
app.get(
"/articles/:articleId",
requirePermission("article:read", findArticle),
(req, res) => {
res.json(findArticle(req));
}
);
app.patch(
"/articles/:articleId",
requirePermission("article:update", findArticle),
(req, res) => {
const article = findArticle(req);
if (!article) return res.status(404).json({ error: "not_found" });
article.title = String(req.body.title ?? article.title);
article.body = String(req.body.body ?? article.body);
return res.json(article);
}
);
app.delete(
"/articles/:articleId",
requirePermission("article:delete", findArticle),
(req, res) => {
const index = articles.findIndex((article) => article.id === req.params.articleId);
if (index >= 0) articles.splice(index, 1);
return res.status(204).send();
}
);
app.get("/admin/users", requirePermission("user:manage"), (_req, res) => {
res.json([{ id: "user-1" }, { id: "user-2" }]);
});
if (process.env.NODE_ENV !== "test") {
app.listen(3000, () => {
console.log("RBAC demo listening on http://localhost:3000");
});
}
En produccion, authenticateForDemo debe convertirse en una fuente de identidad verificada. Aunque un proveedor de identidad envie roles o permissions, el API debe volver a validar tenant y reglas de objeto.
Schema de base de datos con limite de tenant
Muchos fallos ocurren antes de llegar al middleware: una consulta trae la fila equivocada y el handler la usa como si fuera segura. Por eso el schema tambien debe mostrar el limite de tenant y la auditoria.
CREATE TABLE tenants (
id TEXT PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE users (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id),
email TEXT NOT NULL,
UNIQUE (tenant_id, email)
);
CREATE TABLE roles (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id),
name TEXT NOT NULL,
UNIQUE (tenant_id, name)
);
CREATE TABLE permissions (
id TEXT PRIMARY KEY,
resource TEXT NOT NULL,
action TEXT NOT NULL,
UNIQUE (resource, action)
);
CREATE TABLE user_roles (
user_id TEXT NOT NULL REFERENCES users(id),
role_id TEXT NOT NULL REFERENCES roles(id),
tenant_id TEXT NOT NULL REFERENCES tenants(id),
PRIMARY KEY (user_id, role_id)
);
CREATE TABLE role_permissions (
role_id TEXT NOT NULL REFERENCES roles(id),
permission_id TEXT NOT NULL REFERENCES permissions(id),
PRIMARY KEY (role_id, permission_id)
);
CREATE TABLE articles (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id),
owner_id TEXT NOT NULL REFERENCES users(id),
title TEXT NOT NULL,
body TEXT NOT NULL
);
CREATE TABLE authorization_audit_logs (
id TEXT PRIMARY KEY,
tenant_id TEXT,
actor_id TEXT,
permission TEXT NOT NULL,
resource_id TEXT,
allowed BOOLEAN NOT NULL,
reason TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_articles_tenant_owner ON articles(tenant_id, owner_id);
CREATE INDEX idx_audit_tenant_created ON authorization_audit_logs(tenant_id, created_at);
En SaaS B2B es normal permitir roles personalizados por tenant, por eso tenant_id + name suele ser unico. En entornos regulados puede ser mejor fijar roles globales y permitir solo asignaciones locales, porque la auditoria es mas simple.
Probar los rechazos
Las pruebas deben confirmar los casos permitidos, pero deben dedicar mas espacio a los rechazos: mismo role en otro tenant, mismo tenant pero otro owner, usuario autenticado sin permission, recurso inexistente y request sin autenticar.
// test/rbac.test.ts
import request from "supertest";
import { describe, expect, it } from "vitest";
import { app } from "../src/server.js";
const editorUser1 = {
"x-user-id": "user-1",
"x-tenant-id": "tenant-a",
"x-roles": "editor"
};
const editorTenantB = {
"x-user-id": "user-9",
"x-tenant-id": "tenant-b",
"x-roles": "editor"
};
const owner = {
"x-user-id": "owner-1",
"x-tenant-id": "tenant-a",
"x-roles": "owner"
};
describe("RBAC middleware", () => {
it("rejects unauthenticated requests", async () => {
const res = await request(app).get("/articles/a1");
expect(res.status).toBe(401);
});
it("allows an editor to update an owned article in the same tenant", async () => {
const res = await request(app)
.patch("/articles/a1")
.set(editorUser1)
.send({ title: "Updated roadmap" });
expect(res.status).toBe(200);
expect(res.body.title).toBe("Updated roadmap");
});
it("blocks cross-tenant access even when the role matches", async () => {
const res = await request(app).get("/articles/a1").set(editorTenantB);
expect(res.status).toBe(403);
expect(res.body.reason).toBe("tenant_mismatch");
});
it("blocks editors from managing users", async () => {
const res = await request(app).get("/admin/users").set(editorUser1);
expect(res.status).toBe(403);
expect(res.body.reason).toBe("role_missing_permission");
});
it("allows owners to delete tenant resources", async () => {
const res = await request(app).delete("/articles/a2").set(owner);
expect(res.status).toBe(204);
});
});
npm install
npm test
npm run dev
Cuando pidas a Claude Code que agregue pruebas, nombra tenant_mismatch, not_resource_owner, role_missing_permission y unauthenticated. El reason devuelto al cliente debe coincidir con el del log de auditoria.
Cuatro casos de uso reales
El primer caso es una plataforma B2B de gestion de proyectos. El owner invita miembros y cambia facturacion; el editor solo modifica contenido. El limite de tenant es mas importante que el nombre del role: un projectId adivinado debe fallar en la consulta y en el middleware.
El segundo caso es un CMS interno. Un editor actualiza sus borradores, pero borrar un articulo publicado o editar el articulo de otro autor requiere un role mayor. Aqui RBAC necesita una regla por objeto basada en ownerId y estado.
El tercer caso es facturacion y reembolsos. billing_admin puede leer facturas, pero un reembolso puede depender de importe, aprobacion o doble control. RBAC abre la puerta; los atributos de negocio deciden si esa accion concreta es segura.
El cuarto caso es soporte con impersonacion. Soporte puede ver un workspace para diagnosticar, pero no debe cambiar correo, metodo de pago ni owner role. Cada accion impersonada debe registrarse separada de la accion del cliente.
Fallos concretos y correcciones
| Fallo | Riesgo | Correccion |
|---|---|---|
| Devolver todos los registros a cualquier usuario logueado | Escalada horizontal | Filtrar por tenant_id y resource ID |
| Ocultar botones solo en UI | El API directo sigue funcionando | Rechazar en middleware del servidor |
Un admin gigante | La revision pierde valor | Dividir permissions y redisenar roles |
| Registrar solo acciones permitidas | No ves intentos de abuso | Loguear allow y deny con reason |
| Probar solo casos exitosos | Las regresiones pasan ocultas | Automatizar matriz de denegaciones |
La UI no es una frontera de seguridad. Ocultar un boton mejora la experiencia, pero la proteccion real debe estar en el servidor.
Que delegar a Claude Code
Claude Code es util para convertir una politica clara en codigo, pruebas y diffs revisables. No debe inventar la politica. Dale archivos, reglas de rechazo y criterios de revision.
Help implement RBAC.
Only change src/rbac.ts, src/server.ts, and test/rbac.test.ts.
Requirements:
- An authenticated actor has userId, tenantId, and roles
- Permissions use the resource:action format
- Unknown permission, empty role list, and tenant mismatch deny by default
- article:update is allowed only for owner role or the article owner
- 403 responses include a reason, and audit logs use the same reason
- Add more denial tests than success tests
Do not:
- Add an admin bypass
- Treat UI visibility as authorization
- Modify the existing authentication flow
Revisa si authorize es el unico punto de decision, si cada ruta protegida usa middleware, si las consultas incluyen tenant, si los logs no exponen tokens ni datos sensibles y si las pruebas cubren rechazos. Si Claude Code genera un diff grande, separa autorizacion de refactors no relacionados.
Cuando pasar de RBAC a ABAC
RBAC funciona bien para responsabilidades de trabajo. Se vuelve torpe cuando las reglas dependen de atributos: reembolsos sobre cierto importe requieren doble aprobacion, datos personales de la UE solo se ven desde una region europea, exportar auditoria solo esta permitido para plan Enterprise, o ciertos permisos solo valen en horario laboral.
ABAC significa Attribute-Based Access Control, control de acceso basado en atributos. Evalua atributos del actor, recurso, entorno y request. Una ruta practica es usar RBAC como puerta gruesa y agregar reglas ABAC donde el negocio lo exige. Incluso con motores de politicas, primero conviene fijar permission names, limites de tenant, auditoria y pruebas.
Resumen
Un buen RBAC se define por rechazos claros. Separa autenticacion de autorizacion, usa permissions resource:action, aplica deny by default, valida tenants, agrega reglas por objeto y verifica todo con pruebas y logs. Esa estructura tambien da a Claude Code un alcance seguro: implementar la politica, agregar pruebas negativas y mantener el cambio revisable.
Para un flujo completo, combina esta guia con desarrollo de APIs con Claude Code, autenticacion JWT y buenas practicas de seguridad. Si necesitas consultoria, capacitacion o ayuda de implementacion, trae la tabla actual de roles, la lista de APIs protegidas y las operaciones mas peligrosas si quedaran expuestas.
Al probar este enfoque, verifica cuatro puntos: las pruebas negativas pasan, los IDs de otro tenant devuelven 403, los logs registran allow y deny, y Claude Code no agrego un atajo que evite authorize. Empieza con una operacion peligrosa y escribe role, permission, resource, action, tenantId y ownerId antes de llevarlo a codigo.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Permission receipt para Claude Code: alcance, prueba y rollback
Patrón de permission receipt para Claude Code: acciones permitidas, aprobación, pruebas, rollback y CTA de ingresos.
Agent Harness seguro para Claude Code y Codex: permisos, verificacion y rollback
Diseña un Agent Harness seguro para Claude Code y Codex con permisos, plan, verificaciones y rollback.
Subagentes de Claude Code: guía práctica para delegar trabajo de forma segura
Guía práctica de subagentes en Claude Code para dividir artículos y código: reglas, prompts, riesgos y checklist.