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.
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.
🛠️ 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}`);
});
🤯 Explicación de la Lógica de la Directiva
SchemaDirectiveVisitor: Esta clase base degraphql-tools(incluida enapollo-server) nos permite modificar el esquema GraphQL durante su construcción.visitFieldDefinition(field): Se ejecuta cuando la directiva@hasRolese aplica a un campo. Interceptamos el resolver original (field.resolve) y lo envolvemos con nuestra lógica de verificación de rol.visitObject(object): Se ejecuta cuando la directiva@hasRolese aplica a un tipo. Iteramos sobre todos los campos de ese tipo y aplicamos la misma lógica de verificación de rol.context.user: Accedemos al usuario actual y sus roles desde el objetocontext(que establecimos en la configuración del servidor). Si no hay usuario o el usuario no tiene ninguno de los roles requeridos, lanzamos unError.
🧪 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 campoemailenmedebería dar un error de acceso denegado. El usuarioVIEWERno tiene permiso para ver elemailde nadie.posts: Debería funcionar ya que el tipoPostpermiteVIEWER.users: Debería dar un error de acceso denegado, ya que soloADMINpuede 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 elemail.posts: Debería funcionar.users: Debería dar error, ya queEDITORno 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 queADMINtiene 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.
✨ 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.
⚠️ 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
}
});
🔄 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:
Al seguir estos principios, puedes construir APIs GraphQL que no solo son flexibles y eficientes, sino también intrínsecamente seguras.
Tutoriales relacionados
- Gestionando Sesiones y Autenticación en GraphQL con JWT y Cookies Segurasintermediate20 min
- Diseñando APIs GraphQL Robustas con Directivas Personalizadas: Más Allá de lo Básicointermediate20 min
- Optimización de GraphQL con Dataloader: Estrategias para Evitar el Problema N+1intermediate15 min
- Federación GraphQL: Construyendo APIs Distribuidas y Escalables con Apollo Federationintermediate20 min
- Explorando la Introspección GraphQL: Descubriendo Esquemas y Metadatos de tus APIsintermediate18 min
Comentarios (0)
Aún no hay comentarios. ¡Sé el primero!