Tema
Usuarios y permisos
Conectar tu aplicación con el usuario root es como darle a cada empleado de una tienda la llave maestra, la combinación de la caja fuerte y la contraseña del banco. Si alguien encuentra una vulnerabilidad en tu sitio (una inyección SQL, un archivo .env expuesto), con root puede borrar todas las bases de datos del servidor. En esta lección aprenderás a crear usuarios con el privilegio mínimo necesario.
Concepto
En MySQL, un usuario se identifica por dos partes: nombre y host desde donde se conecta.
txt
'tienda_app'@'localhost' ← solo puede conectarse desde el mismo servidor
'tienda_app'@'192.168.1.50' ← solo desde esa IP
'tienda_app'@'%' ← desde cualquier lugar (¡cuidado!)
'reportes'@'10.0.0.%' ← desde cualquier IP de la red 10.0.0.xPara MySQL, 'ana'@'localhost' y 'ana'@'%' son dos usuarios distintos, con contraseñas y permisos independientes.
Los permisos (privilegios) se otorgan en distintos niveles:
| Nivel | Sintaxis | Ejemplo |
|---|---|---|
| Global | ON *.* | Administradores |
| Base de datos | ON tienda.* | Usuario de una app |
| Tabla | ON tienda.productos | Usuario que solo lee el catálogo |
| Columna | SELECT (nombre, precio) ON tienda.productos | Muy restrictivo |
| Rutina | EXECUTE ON PROCEDURE tienda.cancelar_pedido | Solo ejecutar un procedimiento |
Crear usuarios
sql
-- Usuario local para la aplicación
CREATE USER 'tienda_app'@'localhost' IDENTIFIED BY 'Una-Clave-Larga-y-Unica-2024!';
-- Usuario que se conecta desde la IP del servidor web
CREATE USER 'tienda_app'@'10.0.0.15' IDENTIFIED BY 'Otra-Clave-Distinta!';
-- Evitar error si ya existe
CREATE USER IF NOT EXISTS 'reportes'@'localhost' IDENTIFIED BY 'Clave-Reportes!';
-- Ver usuarios existentes
SELECT user, host, plugin, account_locked FROM mysql.user ORDER BY user;| user | host | plugin | account_locked |
|---|---|---|---|
| mysql.sys | localhost | caching_sha2_password | Y |
| reportes | localhost | caching_sha2_password | N |
| root | localhost | auth_socket | N |
| tienda_app | 10.0.0.15 | caching_sha2_password | N |
| tienda_app | localhost | caching_sha2_password | N |
caching_sha2_password
Es el método de autenticación por defecto en MySQL 8. Algunos drivers muy viejos (PHP antes de 7.4, conectores antiguos) no lo soportan. La solución correcta es actualizar el driver. Como último recurso: CREATE USER ... IDENTIFIED WITH mysql_native_password BY '...' (obsoleto y deshabilitado por defecto en 8.4).
Otorgar permisos: GRANT
sql
GRANT privilegios ON nivel TO 'usuario'@'host';Usuario típico de una aplicación web
sql
-- Lo que necesita una app para operar (CRUD), nada más
GRANT SELECT, INSERT, UPDATE, DELETE ON tienda.* TO 'tienda_app'@'localhost';Usuario para migraciones (despliegues)
sql
-- Puede cambiar la estructura de la BD tienda, pero no tocar otras bases
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, DROP, INDEX,
REFERENCES, CREATE VIEW, SHOW VIEW, TRIGGER, EXECUTE,
CREATE ROUTINE, ALTER ROUTINE
ON tienda.* TO 'tienda_migraciones'@'localhost';Usuario de solo lectura (reportes, analistas, BI)
sql
GRANT SELECT ON tienda.* TO 'reportes'@'localhost';Solo algunas tablas o columnas
sql
-- El equipo de marketing ve clientes pero sin email
GRANT SELECT (id, nombre, ciudad, fecha_registro) ON tienda.clientes TO 'marketing'@'localhost';
GRANT SELECT ON tienda.productos TO 'marketing'@'localhost';Usuario para respaldos
sql
CREATE USER 'backup'@'localhost' IDENTIFIED BY 'Clave-Backup-Segura!';
GRANT SELECT, SHOW VIEW, TRIGGER, LOCK TABLES, EVENT, PROCESS ON *.* TO 'backup'@'localhost';
-- En MySQL 8.0.21+ también: GRANT RELOAD ON *.* si usas --flush-logsPrivilegios más comunes
| Privilegio | Permite |
|---|---|
SELECT, INSERT, UPDATE, DELETE | Leer y modificar datos |
CREATE, ALTER, DROP | Crear, modificar y borrar tablas/bases |
INDEX | Crear y borrar índices |
REFERENCES | Crear claves foráneas |
CREATE VIEW, SHOW VIEW | Vistas |
TRIGGER | Triggers |
EXECUTE, CREATE ROUTINE, ALTER ROUTINE | Procedimientos y funciones |
LOCK TABLES | Bloquear tablas (respaldos) |
PROCESS | Ver todas las consultas en ejecución |
ALL PRIVILEGES | Todo en ese nivel (excepto GRANT OPTION) |
GRANT OPTION | Otorgar sus permisos a otros |
Nunca hagas esto para una app
sql
GRANT ALL PRIVILEGES ON *.* TO 'app'@'%' WITH GRANT OPTION;Es equivalente a crear otro root accesible desde cualquier lugar del mundo. Un atacante que obtenga esas credenciales controla todo el servidor.
Ver y quitar permisos
sql
-- Ver permisos de un usuario
SHOW GRANTS FOR 'tienda_app'@'localhost';txt
+--------------------------------------------------------------------------------+
| Grants for tienda_app@localhost |
+--------------------------------------------------------------------------------+
| GRANT USAGE ON *.* TO `tienda_app`@`localhost` |
| GRANT SELECT, INSERT, UPDATE, DELETE ON `tienda`.* TO `tienda_app`@`localhost` |
+--------------------------------------------------------------------------------+USAGE significa "puede conectarse, sin privilegios globales".
sql
-- Mis propios permisos
SHOW GRANTS;
-- Quitar un permiso
REVOKE DELETE ON tienda.* FROM 'tienda_app'@'localhost';
-- Quitar todo
REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'tienda_app'@'localhost';¿Y FLUSH PRIVILEGES?
Con CREATE USER, GRANT y REVOKE los cambios se aplican de inmediato: no necesitas FLUSH PRIVILEGES. Solo hace falta si alguien modificó directamente las tablas de mysql.* (algo que no deberías hacer).
Administrar cuentas
sql
-- Cambiar contraseña
ALTER USER 'tienda_app'@'localhost' IDENTIFIED BY 'Nueva-Clave-2025!';
-- Cambiar tu propia contraseña
ALTER USER USER() IDENTIFIED BY 'Mi-Nueva-Clave!';
-- Bloquear / desbloquear (por ejemplo, un empleado que salió)
ALTER USER 'marketing'@'localhost' ACCOUNT LOCK;
ALTER USER 'marketing'@'localhost' ACCOUNT UNLOCK;
-- Forzar cambio de contraseña cada 90 días
ALTER USER 'reportes'@'localhost' PASSWORD EXPIRE INTERVAL 90 DAY;
-- Bloqueo tras 5 intentos fallidos durante 1 día (MySQL 8.0.19+)
ALTER USER 'tienda_app'@'localhost' FAILED_LOGIN_ATTEMPTS 5 PASSWORD_LOCK_TIME 1;
-- Limitar conexiones simultáneas
ALTER USER 'reportes'@'localhost' WITH MAX_USER_CONNECTIONS 5;
-- Renombrar y eliminar
RENAME USER 'marketing'@'localhost' TO 'mkt'@'localhost';
DROP USER IF EXISTS 'mkt'@'localhost';Roles (MySQL 8+)
Un rol es un conjunto de privilegios con nombre. En lugar de dar permisos uno por uno a cada persona, los das al rol y asignas el rol.
sql
-- 1. Crear roles
CREATE ROLE 'rol_lectura', 'rol_escritura', 'rol_dev';
-- 2. Dar permisos a los roles
GRANT SELECT ON tienda.* TO 'rol_lectura';
GRANT INSERT, UPDATE, DELETE ON tienda.* TO 'rol_escritura';
GRANT ALL PRIVILEGES ON tienda.* TO 'rol_dev';
-- 3. Asignar roles a usuarios
CREATE USER 'laura'@'%' IDENTIFIED BY 'Clave-Laura!' REQUIRE SSL;
GRANT 'rol_lectura', 'rol_escritura' TO 'laura'@'%';
-- 4. Activarlos por defecto al conectarse (¡importante!)
SET DEFAULT ROLE ALL TO 'laura'@'%';Error común con roles
Asignar el rol pero olvidar SET DEFAULT ROLE. El usuario se conecta y "no tiene permisos" porque los roles quedan inactivos hasta que ejecuta SET ROLE ALL;. Comprueba con SELECT CURRENT_ROLE();.
MariaDB también tiene roles (desde 10.0.5) con sintaxis similar.
Acceso remoto seguro
Por defecto MySQL en Ubuntu solo escucha en 127.0.0.1. Para permitir conexiones de otro servidor:
- Editar
bind-addressen/etc/mysql/mysql.conf.d/mysqld.cnf:
ini
[mysqld]
# Solo local (por defecto, lo más seguro):
# bind-address = 127.0.0.1
# IP privada del servidor de BD (red interna):
bind-address = 10.0.0.10- Reiniciar:
sudo systemctl restart mysql. - Crear el usuario solo para la IP del servidor de aplicación (no
%). - Abrir el firewall solo para esa IP:
bash
sudo ufw allow from 10.0.0.15 to any port 3306 proto tcp- Exigir cifrado:
sql
ALTER USER 'tienda_app'@'10.0.0.15' REQUIRE SSL;Mejor aún: túnel SSH
Para conectarte tú (desarrollador) a la BD de producción, no abras el 3306. Usa un túnel SSH: ssh -L 3307:127.0.0.1:3306 usuario@servidor y conecta tu cliente a 127.0.0.1:3307. Workbench y DBeaver lo hacen desde su configuración.
Plantilla: usuarios para un proyecto real
sql
-- =============================================
-- Usuarios del proyecto "tienda"
-- =============================================
CREATE DATABASE IF NOT EXISTS tienda CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- App web (producción): solo CRUD
CREATE USER 'tienda_app'@'localhost' IDENTIFIED BY 'CAMBIAR_app';
GRANT SELECT, INSERT, UPDATE, DELETE, EXECUTE ON tienda.* TO 'tienda_app'@'localhost';
-- Migraciones (se usa solo al desplegar)
CREATE USER 'tienda_migra'@'localhost' IDENTIFIED BY 'CAMBIAR_migra';
GRANT ALL PRIVILEGES ON tienda.* TO 'tienda_migra'@'localhost';
-- Reportes / BI: solo lectura, pocas conexiones
CREATE USER 'tienda_reportes'@'localhost' IDENTIFIED BY 'CAMBIAR_reportes'
WITH MAX_USER_CONNECTIONS 3;
GRANT SELECT ON tienda.* TO 'tienda_reportes'@'localhost';
-- Respaldos
CREATE USER 'tienda_backup'@'localhost' IDENTIFIED BY 'CAMBIAR_backup';
GRANT SELECT, SHOW VIEW, TRIGGER, LOCK TABLES, EVENT ON tienda.* TO 'tienda_backup'@'localhost';
GRANT PROCESS ON *.* TO 'tienda_backup'@'localhost';Buena práctica
- Un usuario por aplicación y por función (app, migraciones, reportes, respaldos).
- Contraseñas largas y únicas, generadas con un gestor; guárdalas en
.env, nunca en el código ni en Git. - Host lo más específico posible:
localhosto una IP, evita%. - Revisa periódicamente con
SELECT user, host FROM mysql.user;y elimina cuentas que ya no se usan.
Ejercicios
- Crea un usuario
catalogo@localhostque solo pueda leer las tablasproductosycategoriasdetienda. - Intenta, conectado como
catalogo, ejecutarDELETE FROM productos WHERE id = 1;. ¿Qué error aparece? - Crea un rol
rol_ventasconSELECTen toda la BD eINSERT/UPDATEenpedidosydetalle_pedido. Asígnalo a un usuario nuevo y actívalo por defecto. - ¿Por qué
'app'@'%'puede no funcionar al conectarte desde el propio servidor conlocalhost?
Ver solución
sql
-- 1
CREATE USER 'catalogo'@'localhost' IDENTIFIED BY 'Clave-Catalogo-1!';
GRANT SELECT ON tienda.productos TO 'catalogo'@'localhost';
GRANT SELECT ON tienda.categorias TO 'catalogo'@'localhost';ERROR 1142 (42000): DELETE command denied to user 'catalogo'@'localhost' for table 'productos'.
sql
-- 3
CREATE ROLE 'rol_ventas';
GRANT SELECT ON tienda.* TO 'rol_ventas';
GRANT INSERT, UPDATE ON tienda.pedidos TO 'rol_ventas';
GRANT INSERT, UPDATE ON tienda.detalle_pedido TO 'rol_ventas';
CREATE USER 'vendedor1'@'localhost' IDENTIFIED BY 'Clave-Vendedor-1!';
GRANT 'rol_ventas' TO 'vendedor1'@'localhost';
SET DEFAULT ROLE 'rol_ventas' TO 'vendedor1'@'localhost';- Porque si existe un usuario anónimo
''@'localhost'o el cliente conecta por socket, MySQL elige la coincidencia de host más específica (localhostgana sobre%). Solución: elimina usuarios anónimos (mysql_secure_installation) o crea también'app'@'localhost'.
Resumen
- Un usuario MySQL es
'nombre'@'host';localhosty%son cuentas distintas. - Aplica el privilegio mínimo: la app solo necesita
SELECT, INSERT, UPDATE, DELETEsobre su base. GRANTotorga,REVOKEquita,SHOW GRANTSrevisa; no hace faltaFLUSH PRIVILEGES.- Los roles agrupan permisos; recuerda
SET DEFAULT ROLE. - Para acceso remoto:
bind-address, usuario por IP, firewall, SSL o, mejor, túnel SSH.