Saltar al contenido

Patrones de autorización de APIs: RBAC, ABAC y políticas como código

Autorización robusta de APIs con RBAC, ABAC y políticas como código: protege recursos en cada capa y mantén el sistema mantenible mientras crece.

5 min de lectura
Arquitectura de autorización en capas que muestra la petición fluyendo a través de la autenticación, las verificaciones de rol, las políticas de atributos y los permisos a nivel de recurso

La autenticación responde a "¿quién eres?". La autorización responde a "¿qué puedes hacer?". La mayoría de las APIs resuelven bien la autenticación con JWT u OAuth, pero implementan la autorización como sentencias if dispersas por todo el código. Cuando un nuevo rol necesita acceso a un endpoint existente, los desarrolladores acaban apagando fuegos en decenas de archivos. Centralizar la lógica de autorización en un motor de políticas coherente evita este deterioro.

El problema de la autorización en línea

La lógica de autorización tiende a empezar de forma sencilla y a convertirse en un caos imposible de mantener.

tstypescript
// ❌ Authorization scattered across handlers
async function deleteProject(req: Request, res: Response) {
  const user = req.user;
  const project = await db.projects.findById(req.params.id);
 
  // First check: is this their project?
  if (project.ownerId !== user.id) {
    // Wait, admins can delete anything
    if (user.role !== "admin") {
      // Actually, org admins can delete within their org
      if (
        user.role !== "org_admin" ||
        project.orgId !== user.orgId
      ) {
        // Team leads can delete in their team's projects
        if (
          user.role !== "team_lead" ||
          !user.teamIds.includes(project.teamId)
        ) {
          return res.status(403).json({ error: "Forbidden" });
        }
      }
    }
  }
 
  // Duplicate logic exists in updateProject, archiveProject,
  // transferProject, and every new endpoint touching projects
  await db.projects.delete(project.id);
  return res.json({ success: true });
}
tstypescript
// ✅ Centralized authorization with clear policy
async function deleteProject(req: Request, res: Response) {
  const project = await db.projects.findById(req.params.id);
 
  const allowed = await authorize({
    subject: req.user,
    action: "delete",
    resource: project,
    resourceType: "project",
  });
 
  if (!allowed) {
    return res.status(403).json({ error: "Forbidden" });
  }
 
  await db.projects.delete(project.id);
  return res.json({ success: true });
}

La versión centralizada es legible, testeable y mantenible. La definición de la política vive en un solo lugar y cada handler le delega la decisión.

Role-Based Access Control (RBAC)

RBAC asigna permisos a roles y luego asigna roles a usuarios. Funciona bien en sistemas con categorías de usuarios claramente definidas, pero tiene problemas con los permisos contextuales.

tstypescript
interface Role {
  name: string;
  permissions: Permission[];
}
 
interface Permission {
  resource: string;
  actions: string[];
}
 
const roles: Role[] = [
  {
    name: "viewer",
    permissions: [
      { resource: "project", actions: ["read", "list"] },
      { resource: "document", actions: ["read", "list"] },
    ],
  },
  {
    name: "editor",
    permissions: [
      { resource: "project", actions: ["read", "list", "update"] },
      { resource: "document", actions: ["read", "list", "create", "update"] },
    ],
  },
  {
    name: "admin",
    permissions: [
      { resource: "project", actions: ["read", "list", "create", "update", "delete"] },
      { resource: "document", actions: ["read", "list", "create", "update", "delete"] },
      { resource: "user", actions: ["read", "list", "create", "update", "delete"] },
    ],
  },
];
 
class RBACEngine {
  private roleMap: Map<string, Role>;
 
  constructor(roles: Role[]) {
    this.roleMap = new Map(roles.map(r => [r.name, r]));
  }
 
  check(
    userRoles: string[],
    resource: string,
    action: string
  ): boolean {
    for (const roleName of userRoles) {
      const role = this.roleMap.get(roleName);
      if (!role) continue;
 
      const permission = role.permissions.find(
        p => p.resource === resource
      );
      if (permission && permission.actions.includes(action)) {
        return true;
      }
    }
    return false;
  }
}
 
const rbac = new RBACEngine(roles);
rbac.check(["editor"], "document", "create");  // true
rbac.check(["viewer"], "document", "delete");   // false

RBAC es sencillo, pero llega a su límite cuando los permisos dependen de la propiedad del recurso, la pertenencia a un equipo u otros atributos contextuales.

Attribute-Based Access Control (ABAC)

ABAC evalúa políticas contra los atributos del sujeto, el recurso, la acción y el entorno. Esto permite manejar permisos contextuales que RBAC no puede expresar.

tstypescript
interface PolicyRule {
  id: string;
  description: string;
  effect: "allow" | "deny";
  condition: (ctx: AuthorizationContext) => boolean;
}
 
interface AuthorizationContext {
  subject: {
    id: string;
    roles: string[];
    orgId: string;
    teamIds: string[];
    department: string;
  };
  action: string;
  resource: {
    type: string;
    ownerId: string;
    orgId: string;
    teamId: string;
    sensitivity: "public" | "internal" | "confidential";
    status: string;
  };
  environment: {
    ipAddress: string;
    time: Date;
    mfaVerified: boolean;
  };
}
 
const policies: PolicyRule[] = [
  {
    id: "owner-full-access",
    description: "Resource owners can do anything with their resources",
    effect: "allow",
    condition: (ctx) =>
      ctx.subject.id === ctx.resource.ownerId,
  },
  {
    id: "team-member-read-write",
    description: "Team members can read and update team resources",
    effect: "allow",
    condition: (ctx) =>
      ctx.subject.teamIds.includes(ctx.resource.teamId) &&
      ["read", "update", "list"].includes(ctx.action),
  },
  {
    id: "confidential-requires-mfa",
    description: "Confidential resources require MFA verification",
    effect: "deny",
    condition: (ctx) =>
      ctx.resource.sensitivity === "confidential" &&
      !ctx.environment.mfaVerified,
  },
  {
    id: "no-delete-archived",
    description: "Cannot delete archived resources",
    effect: "deny",
    condition: (ctx) =>
      ctx.action === "delete" &&
      ctx.resource.status === "archived",
  },
];
 
function evaluatePolicies(
  policies: PolicyRule[],
  ctx: AuthorizationContext
): boolean {
  // Deny rules take priority
  for (const policy of policies) {
    if (policy.effect === "deny" && policy.condition(ctx)) {
      return false;
    }
  }
 
  // Check for at least one allow
  for (const policy of policies) {
    if (policy.effect === "allow" && policy.condition(ctx)) {
      return true;
    }
  }
 
  // Default deny
  return false;
}

La estrategia de evaluación deny-overrides implica que las reglas de protección siempre ganan, independientemente del orden. Esto evita concesiones de acceso accidentales cuando se añaden nuevas políticas de tipo allow.

Aplicación basada en middleware

La autorización debería aplicarse en la capa de middleware, de modo que ningún handler individual pueda saltarse las comprobaciones de permisos por accidente.

tstypescript
interface ResourceResolver {
  resolve: (req: Request) => Promise<AuthorizableResource>;
}
 
interface AuthorizableResource {
  type: string;
  ownerId: string;
  orgId: string;
  teamId: string;
  sensitivity: string;
  status: string;
}
 
function requirePermission(
  action: string,
  resourceResolver: ResourceResolver
) {
  return async (
    req: Request,
    res: Response,
    next: NextFunction
  ) => {
    const resource = await resourceResolver.resolve(req);
 
    const ctx: AuthorizationContext = {
      subject: {
        id: req.user.id,
        roles: req.user.roles,
        orgId: req.user.orgId,
        teamIds: req.user.teamIds,
        department: req.user.department,
      },
      action,
      resource: {
        type: resource.type,
        ownerId: resource.ownerId,
        orgId: resource.orgId,
        teamId: resource.teamId,
        sensitivity: resource.sensitivity,
        status: resource.status,
      },
      environment: {
        ipAddress: req.ip ?? "unknown",
        time: new Date(),
        mfaVerified: req.user.mfaVerified ?? false,
      },
    };
 
    const allowed = evaluatePolicies(policies, ctx);
 
    if (!allowed) {
      return res.status(403).json({
        error: "Insufficient permissions",
        action,
        resource: resource.type,
      });
    }
 
    next();
  };
}
 
// Usage in routes
const projectResolver: ResourceResolver = {
  resolve: async (req) => {
    const project = await db.projects.findById(req.params.id);
    return {
      type: "project",
      ownerId: project.ownerId,
      orgId: project.orgId,
      teamId: project.teamId,
      sensitivity: project.sensitivity,
      status: project.status,
    };
  },
};
 
router.delete(
  "/projects/:id",
  requirePermission("delete", projectResolver),
  deleteProjectHandler
);

Pruebas de las políticas de autorización

La lógica de autorización es lo bastante crítica como para merecer pruebas exhaustivas. Las pruebas de políticas deberían cubrir cada combinación de rol-recurso-acción y los casos límite.

tstypescript
describe("Authorization Policies", () => {
  const baseCtx: AuthorizationContext = {
    subject: {
      id: "user-1",
      roles: ["editor"],
      orgId: "org-1",
      teamIds: ["team-1"],
      department: "engineering",
    },
    action: "read",
    resource: {
      type: "project",
      ownerId: "user-2",
      orgId: "org-1",
      teamId: "team-1",
      sensitivity: "internal",
      status: "active",
    },
    environment: {
      ipAddress: "10.0.0.1",
      time: new Date("2023-04-26T10:00:00Z"),
      mfaVerified: true,
    },
  };
 
  test("team members can read team resources", () => {
    expect(evaluatePolicies(policies, baseCtx)).toBe(true);
  });
 
  test("confidential resources denied without MFA", () => {
    const ctx = {
      ...baseCtx,
      resource: { ...baseCtx.resource, sensitivity: "confidential" },
      environment: { ...baseCtx.environment, mfaVerified: false },
    };
    expect(evaluatePolicies(policies, ctx)).toBe(false);
  });
 
  test("cannot delete archived resources even as owner", () => {
    const ctx = {
      ...baseCtx,
      action: "delete",
      subject: { ...baseCtx.subject, id: "user-2" }, // is owner
      resource: { ...baseCtx.resource, ownerId: "user-2", status: "archived" },
    };
    expect(evaluatePolicies(policies, ctx)).toBe(false);
  });
});

Conclusiones clave

La autorización es demasiado importante para dispersarla entre funciones handler y demasiado compleja para modelarla solo con simples comprobaciones de rol. Empieza con RBAC para delimitar los roles claramente y luego añade capas de políticas ABAC para las reglas contextuales como la propiedad, la pertenencia a equipos, los niveles de sensibilidad y los requisitos de MFA. Usa la evaluación deny-overrides para que las reglas de protección siempre tengan prioridad. Aplica la autorización en la capa de middleware para que los handlers no puedan saltarse las comprobaciones por accidente. Prueba las políticas tan a fondo como la lógica de negocio: cada combinación de rol-acción-recurso debería tener su caso de prueba correspondiente. El objetivo es un sistema en el que añadir una nueva regla de permisos signifique editar un solo archivo de políticas, no rebuscar entre decenas de route handlers.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX