tutoriales.com

Asegurando tus APIs GraphQL: Guía Completa de Autorización Basada en Roles (RBAC)

Este tutorial te guiará a través de la implementación de la autorización basada en roles (RBAC) en tus APIs GraphQL. Aprenderás a definir roles, aplicar políticas de acceso a tipos y campos, y proteger tu API de manera efectiva utilizando directivas personalizadas y middleware.

Intermedio15 min de lectura6 views
Reportar error

La seguridad es un pilar fundamental en el desarrollo de cualquier API, y GraphQL no es una excepción. Más allá de la autenticación, la autorización es crucial para determinar qué usuarios tienen permiso para acceder a qué datos y realizar qué operaciones. En este tutorial, nos sumergiremos en la implementación de la Autorización Basada en Roles (RBAC) en una API GraphQL.

RBAC simplifica la gestión de permisos al asignar roles a los usuarios (por ejemplo, administrador, editor, lector) y luego definir permisos para esos roles. Esto contrasta con la asignación directa de permisos a usuarios individuales, lo que puede volverse inmanejable en sistemas grandes.

🚀 ¿Por Qué RBAC en GraphQL?

GraphQL ofrece una gran flexibilidad en la forma en que los clientes consultan y mutan los datos. Esta flexibilidad, sin una autorización adecuada, podría convertirse en un riesgo de seguridad. RBAC nos permite:

  • Granularidad: Controlar el acceso a nivel de tipo, campo e incluso argumento.
  • Mantenibilidad: Gestionar permisos de forma centralizada mediante roles, reduciendo la complejidad.
  • Escalabilidad: Añadir nuevos usuarios y funcionalidades sin reescribir lógicas de autorización individuales.
  • Transparencia: Hacer que las reglas de acceso sean claras y comprensibles.
🔥 Importante: Este tutorial asume que ya tienes un sistema de autenticación configurado y que el contexto de tu servidor GraphQL incluye la información del usuario autenticado, incluidos sus roles.

🛠️ Herramientas y Conceptos Clave

Para este tutorial, utilizaremos los siguientes conceptos y herramientas:

  • Directivas GraphQL: Una característica poderosa que nos permite extender el lenguaje GraphQL con lógica personalizada, ideal para la autorización.
  • Contexto de GraphQL: El objeto que se pasa a todos los resolvers y que contendrá la información del usuario autenticado y sus roles.
  • Resolvers Personalizados: Funciones que se ejecutan antes o después del resolver real para aplicar lógica de autorización.
  • Esquema: Nuestro diseño de tipos, campos y operaciones GraphQL.

📌 Configuración Inicial del Proyecto

Comenzaremos con un proyecto GraphQL básico. Si ya tienes uno, puedes adaptarlo. Usaremos apollo-server como nuestro servidor GraphQL, pero los principios son aplicables a cualquier implementación.

📦 Instalación de Dependencias

npm init -y
npm install apollo-server graphql

📄 server.js Básico

Crearemos un archivo server.js con un esquema y resolvers mínimos para demostrar la funcionalidad.

// server.js
const { ApolloServer, gql } = require('apollo-server');

// Definimos nuestros roles simulados
const ROLES = {
  ADMIN: 'ADMIN',
  EDITOR: 'EDITOR',
  VIEWER: 'VIEWER'
};

const typeDefs = gql`
  type User {
    id: ID!
    name: String!
    email: String!
    role: String!
  }

  type Post {
    id: ID!
    title: String!
    content: String!
    author: User!
  }

  type Query {
    me: User
    users: [User!]
    posts: [Post!]
    post(id: ID!): Post
  }

  type Mutation {
    createPost(title: String!, content: String!): Post!
    updatePost(id: ID!, title: String, content: String): Post
    deletePost(id: ID!): Boolean
  }
`;

const users = [
  { id: '1', name: 'Alice', email: 'alice@example.com', role: ROLES.ADMIN },
  { id: '2', name: 'Bob', email: 'bob@example.com', role: ROLES.EDITOR },
  { id: '3', name: 'Charlie', email: 'charlie@example.com', role: ROLES.VIEWER }
];

const posts = [
  { id: '101', title: 'Mi Primer Post', content: 'Contenido del primer post.', authorId: '1' },
  { id: '102', title: 'GraphQL Avanzado', content: 'Aprendiendo más sobre GraphQL.', authorId: '2' }
];

const resolvers = {
  Query: {
    me: (parent, args, context) => {
      // Simulación de usuario autenticado
      return context.user;
    },
    users: (parent, args, context) => users,
    posts: () => posts,
    post: (parent, { id }) => posts.find(post => post.id === id)
  },
  Mutation: {
    createPost: (parent, { title, content }, context) => {
      const newPost = { id: String(posts.length + 101), title, content, authorId: context.user.id };
      posts.push(newPost);
      return newPost;
    },
    updatePost: (parent, { id, title, content }, context) => {
      const postIndex = posts.findIndex(p => p.id === id);
      if (postIndex === -1) return null;
      posts[postIndex] = { ...posts[postIndex], title: title || posts[postIndex].title, content: content || posts[postIndex].content };
      return posts[postIndex];
    },
    deletePost: (parent, { id }, context) => {
      const initialLength = posts.length;
      posts = posts.filter(post => post.id !== id);
      return posts.length < initialLength;
    }
  },
  User: {
    // Aquí podemos añadir lógica para no exponer ciertos campos a todos los roles
  },
  Post: {
    author: (parent) => users.find(user => user.id === parent.authorId)
  }
};

const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: ({ req }) => {
    // Simula la autenticación: el rol del usuario se pasa en el header 'x-user-role'
    // y el ID en 'x-user-id'. En un entorno real, esto vendría de un JWT.
    const role = req.headers['x-user-role'] || ROLES.VIEWER; // Rol por defecto si no se especifica
    const userId = req.headers['x-user-id'] || '3'; // Usuario por defecto
    const user = users.find(u => u.id === userId && u.role === role) || users.find(u => u.id === '3');
    
    return { user, ROLES };
  },
});

server.listen().then(({ url }) => {
  console.log(`🚀 Servidor listo en ${url}`);
});

Para probar, puedes usar herramientas como Postman o GraphQL Playground, añadiendo los headers x-user-role y x-user-id.

🛡️ Implementando Directivas de Autorización

Las directivas son la forma más elegante de aplicar la lógica de autorización en GraphQL. Crearemos una directiva @hasRole que verificará si el usuario autenticado tiene el rol requerido.

📝 Definiendo la Directiva @hasRole

Primero, definimos la directiva en nuestro typeDefs.

// ... (código anterior)

const typeDefs = gql`
  enum Role { /* Aquí definimos los roles disponibles */
    ADMIN
    EDITOR
    VIEWER
  }

  directive @hasRole(roles: [Role!]!) on FIELD_DEFINITION | OBJECT

  type User {
    id: ID!
    name: String!
    email: String! @hasRole(roles: [ADMIN, EDITOR]) # Solo admins y editores pueden ver el email
    role: String!
  }

  type Post @hasRole(roles: [ADMIN, EDITOR, VIEWER]) {
    id: ID!
    title: String!
    content: String!
    author: User!
  }

  type Query {
    me: User
    users: [User!] @hasRole(roles: [ADMIN]) # Solo administradores pueden listar todos los usuarios
    posts: [Post!]
    post(id: ID!): Post
  }

  type Mutation {
    createPost(title: String!, content: String!): Post! @hasRole(roles: [ADMIN, EDITOR])
    updatePost(id: ID!, title: String, content: String): Post @hasRole(roles: [ADMIN, EDITOR])
    deletePost(id: ID!): Boolean @hasRole(roles: [ADMIN])
  }
`;

// ... (resto del código)

Aquí, @hasRole toma un argumento roles que es un array de Role. La directiva puede aplicarse a FIELD_DEFINITION (campos) y OBJECT (tipos).

⚙️ Implementando la Lógica de la Directiva

Para que la directiva funcione, necesitamos implementarla como una SchemaDirectiveVisitor en Apollo Server. Esto nos permitirá interceptar la resolución de campos o tipos y aplicar nuestra lógica de autorización.

// server.js (continuación)
const { ApolloServer, gql, SchemaDirectiveVisitor } = require('apollo-server');
const { defaultFieldResolver } = require('graphql');

// ... (ROLES y typeDefs anteriores)

class HasRoleDirective extends SchemaDirectiveVisitor {
  visitFieldDefinition(field) {
    const { resolve = defaultFieldResolver } = field;
    const { roles } = this.args;

    field.resolve = async function (...args) {
      const [, , context] = args;
      const userRole = context.user ? context.user.role : null;

      if (!userRole || !roles.includes(userRole)) {
        throw new Error(`Acceso denegado. Se requiere uno de los roles: ${roles.join(', ')}.`);
      }

      return resolve.apply(this, args);
    };
  }

  visitObject(object) {
    const { roles } = this.args;

    // Esto se aplicará a todos los campos del tipo, a menos que un campo tenga su propia directiva
    Object.values(object.getFields()).forEach(field => {
      const { resolve = defaultFieldResolver } = field;

      field.resolve = async function (...args) {
        const [, , context] = args;
        const userRole = context.user ? context.user.role : null;

        if (!userRole || !roles.includes(userRole)) {
          throw new Error(`Acceso denegado al tipo ${object.name}. Se requiere uno de los roles: ${roles.join(', ')}.`);
        }

        return resolve.apply(this, args);
      };
    });
  }
}

const server = new ApolloServer({
  typeDefs,
  resolvers,
  schemaDirectives: {
    hasRole: HasRoleDirective
  },
  context: ({ req }) => {
    // ... (contexto anterior)
    const role = req.headers['x-user-role'] || ROLES.VIEWER;
    const userId = req.headers['x-user-id'] || '3';
    const user = users.find(u => u.id === userId && u.role === role) || users.find(u => u.id === '3');
    
    return { user, ROLES };
  },
});

server.listen().then(({ url }) => {
  console.log(`🚀 Servidor listo en ${url}`);
});
📌 Nota: En `visitObject`, la lógica de autorización se aplica a *todos* los campos del tipo. Si un campo específico dentro de ese tipo también tiene la directiva `@hasRole`, la directiva del campo tendrá prioridad.

🤯 Explicación de la Lógica de la Directiva

  1. SchemaDirectiveVisitor: Esta clase base de graphql-tools (incluida en apollo-server) nos permite modificar el esquema GraphQL durante su construcción.
  2. visitFieldDefinition(field): Se ejecuta cuando la directiva @hasRole se aplica a un campo. Interceptamos el resolver original (field.resolve) y lo envolvemos con nuestra lógica de verificación de rol.
  3. visitObject(object): Se ejecuta cuando la directiva @hasRole se aplica a un tipo. Iteramos sobre todos los campos de ese tipo y aplicamos la misma lógica de verificación de rol.
  4. context.user: Accedemos al usuario actual y sus roles desde el objeto context (que establecimos en la configuración del servidor). Si no hay usuario o el usuario no tiene ninguno de los roles requeridos, lanzamos un Error.

🧪 Probando la Autorización RBAC

Ahora que tenemos la directiva implementada, vamos a probarla con diferentes usuarios.

➡️ Usuario con Rol VIEWER (ID: 3)

Query:

query GetPostsAndMe {
  me {
    id
    name
    role
    email
  }
  posts {
    id
    title
  }
  users {
    id
    name
  }
}

Headers (Postman/GraphQL Playground):

Key: x-user-role, Value: VIEWER
Key: x-user-id, Value: 3

Resultado Esperado:

  • me: Debería funcionar, pero el campo email en me debería dar un error de acceso denegado. El usuario VIEWER no tiene permiso para ver el email de nadie.
  • posts: Debería funcionar ya que el tipo Post permite VIEWER.
  • users: Debería dar un error de acceso denegado, ya que solo ADMIN puede listar todos los usuarios.
{
  "data": {
    "me": {
      "id": "3",
      "name": "Charlie",
      "role": "VIEWER",
      "email": null
    },
    "posts": [
      {
        "id": "101",
        "title": "Mi Primer Post"
      },
      {
        "id": "102",
        "title": "GraphQL Avanzado"
      }
    ],
    "users": null
  },
  "errors": [
    {
      "message": "Acceso denegado. Se requiere uno de los roles: ADMIN, EDITOR.",
      "locations": [
        { "line": 5, "column": 7 }
      ],
      "path": ["me", "email"]
    },
    {
      "message": "Acceso denegado. Se requiere uno de los roles: ADMIN.",
      "locations": [
        { "line": 9, "column": 5 }
      ],
      "path": ["users"]
    }
  ]
}

➡️ Usuario con Rol EDITOR (ID: 2)

Query: (misma que antes)

Headers:

Key: x-user-role, Value: EDITOR
Key: x-user-id, Value: 2

Resultado Esperado:

  • me: Debería funcionar, incluyendo el email.
  • posts: Debería funcionar.
  • users: Debería dar error, ya que EDITOR no puede listar todos los usuarios.

Mutation: Crear un post

mutation CreateNewPost {
  createPost(title: "Nuevo Post Editor", content: "Contenido del nuevo post.") {
    id
    title
    author {
      name
    }
  }
}

Resultado Esperado: Debería crear el post exitosamente, ya que EDITOR tiene permiso para createPost.

➡️ Usuario con Rol ADMIN (ID: 1)

Query: (misma que antes)

Headers:

Key: x-user-role, Value: ADMIN
Key: x-user-id, Value: 1

Resultado Esperado:

  • me, posts, users: Todas las consultas deberían funcionar sin errores, ya que ADMIN tiene acceso a todo.

Mutation: Eliminar un post

mutation DeleteExistingPost {
  deletePost(id: "101")
}

Resultado Esperado: Debería eliminar el post exitosamente, ya que ADMIN tiene permiso para deletePost.

💡 Consejo: Considera crear una herramienta de desarrollo o un script de cURL para simular rápidamente peticiones con diferentes roles y IDs de usuario.

✨ Casos Avanzados y Consideraciones

🎯 Lógica de Autorización a Nivel de Argumento

A veces, la autorización no solo depende del campo o tipo, sino también de los argumentos de una consulta. Por ejemplo, quizás un usuario solo pueda actualizar sus propios posts. Para esto, la directiva no es suficiente y necesitaríamos lógica de autorización directamente en el resolver.

// En el resolver de updatePost
updatePost: (parent, { id, title, content }, context) => {
  const post = posts.find(p => p.id === id);
  if (!post) return null;

  // Lógica adicional: Solo el autor o un ADMIN puede actualizar el post
  if (context.user.role !== ROLES.ADMIN && post.authorId !== context.user.id) {
    throw new Error('No tienes permiso para actualizar este post.');
  }
  
  const postIndex = posts.findIndex(p => p.id === id);
  posts[postIndex] = { ...posts[postIndex], title: title || posts[postIndex].title, content: content || posts[postIndex].content };
  return posts[postIndex];
},

📈 Rendimiento y Caché

La aplicación de directivas y lógica de autorización en cada resolver puede tener un impacto en el rendimiento. Considera técnicas de caching para resultados de autorización si tu lógica es compleja o si los roles de los usuarios cambian con poca frecuencia.

🌐 Integración con Servidor de Autenticación Externo

En un entorno de producción, la información del usuario (roles, ID, etc.) típicamente vendrá de un token JWT emitido por un servidor de autenticación (ej. Auth0, Keycloak, o tu propio servicio de identidad). El middleware de tu servidor GraphQL (o Apollo Server context function) sería responsable de validar este token y extraer la información del usuario.

Flujo de Interacción GraphQL Cliente GraphQL Server Autorización (RBAC) Servidor de Autenticación Base de Datos 1. Petición + Token 2. Validar 3. User Info 4. Consulta 5. Datos 6. Respuesta

⚠️ Manejo de Errores Mejorado

En lugar de simplemente lanzar un Error, considera devolver errores GraphQL estandarizados con códigos de error personalizados para que los clientes puedan manejarlos de forma programática. Apollo Server permite extender el formato de los errores.

// Ejemplo de error personalizado
const { GraphQLError } = require('graphql');

// Dentro de la directiva o resolver
throw new GraphQLError('Acceso denegado. No tienes los roles necesarios.', {
  extensions: {
    code: 'FORBIDDEN_ACCESS',
    requiredRoles: roles,
    actualRole: userRole
  }
});
⚠️ Advertencia: Evita exponer demasiada información sensible en los mensajes de error de producción, como los roles exactos necesarios, a menos que sea un requisito explícito.

🔄 RBAC Dinámico vs. Estático

En este tutorial, hemos utilizado roles estáticos definidos en un enum. Para sistemas más complejos, podrías tener roles que se gestionan dinámicamente en una base de datos. En ese caso, la lógica de tu directiva necesitaría consultar la base de datos o un servicio de roles para verificar la existencia y validez de los roles, y el enum Role podría generarse dinámicamente o usarse un String en su lugar.

Pro Implementación con Middleware (Schema Transforms)

Para escenarios muy complejos o cuando se necesita una mayor personalización que las SchemaDirectiveVisitor no pueden ofrecer directamente, se pueden usar middleware que transformen el esquema. Librerías como graphql-middleware o graphql-shield ofrecen una capa de abstracción muy potente para manejar la autorización de forma declarativa y modular. Estas herramientas pueden ser muy útiles para centralizar la lógica de seguridad y mantener el código de los resolvers limpio.

Ejemplo básico con `graphql-shield`
// Instalación: npm install graphql-shield graphql

const { ApolloServer, gql } = require('apollo-server');
const { applyMiddleware } = require('graphql-middleware');
const { rule, shield } = require('graphql-shield');

// ... typeDefs y resolvers (sin directivas en los campos a proteger)

const isAuthenticated = rule()(async (parent, args, ctx) => {
  return ctx.user !== null;
});

const isAdmin = rule()(async (parent, args, ctx) => {
  return ctx.user && ctx.user.role === 'ADMIN';
});

const isEditor = rule()(async (parent, args, ctx) => {
  return ctx.user && (ctx.user.role === 'ADMIN' || ctx.user.role === 'EDITOR');
});

const permissions = shield({
  Query: {
    users: isAdmin,
    me: isAuthenticated,
    posts: isAuthenticated
  },
  Mutation: {
    createPost: isEditor,
    updatePost: isEditor,
    deletePost: isAdmin,
  },
  User: {
    email: isEditor // Proteger campos específicos dentro de un tipo
  }
});

const server = new ApolloServer({
  typeDefs,
  resolvers: applyMiddleware(resolvers, permissions),
  context: ({ req }) => {
    // ... (contexto de usuario)
  },
});

server.listen().then(({ url }) => {
  console.log(`🚀 Servidor listo en ${url}`);
});

💡 Resumen y Mejores Prácticas

La implementación de RBAC en GraphQL es fundamental para construir APIs seguras y robustas. Aquí hay un resumen de las mejores prácticas:

Define Roles Claros: Establece un conjunto de roles bien definidos y sus responsabilidades.
Usa Directivas: Aprovecha las directivas GraphQL para aplicar políticas de autorización de manera declarativa y reutilizable.
Lógica en el Contexto: Asegúrate de que la información del usuario y sus roles estén disponibles en el `context` de GraphQL.
Autorización en Resolvers para Granularidad: Para lógica de autorización más compleja (ej. basada en datos, a nivel de argumento), usa el *resolver* directamente.
Errores Claros: Proporciona mensajes de error informativos pero seguros para el cliente.
Considera Librerías de Middleware: Para proyectos grandes, herramientas como `graphql-shield` pueden simplificar enormemente la gestión de permisos.

Al seguir estos principios, puedes construir APIs GraphQL que no solo son flexibles y eficientes, sino también intrínsecamente seguras.

Tutoriales relacionados

Comentarios (0)

Aún no hay comentarios. ¡Sé el primero!