Quedaban dos preguntas de la concejalía de Vallmar sin responder desde el primer día de este módulo, y ninguna de las dos se arregla con un índice.
La primera la hizo la concejala de Cultura en la reunión de seguimiento: "¿quién puede consultar los teléfonos y los correos de los doce mil socios?". Nadie supo responder, que es la peor respuesta posible. La segunda la hizo el interventor: "¿qué pasaría si el disco del servidor muriera esta noche?". Alguien dijo "tenemos copias", y al preguntar cuándo se había probado la última restauración, se hizo el silencio.
Esta lección responde a las dos, y cierra el módulo y el bloque teórico del curso.
Hay una idea que conviene poner por delante, porque ordena todo lo que sigue: la base de datos es la última frontera. Si alguien atraviesa el cortafuegos, engaña a la aplicación o encuentra una credencial en un repositorio, lo único que queda entre esa persona y los datos de doce mil vecinos de Vallmar son los permisos de la base de datos. Y si el edificio se incendia, lo único que queda entre la biblioteca y empezar de cero son las copias de seguridad —probadas, no simplemente existentes—.
Veremos autenticación y autorización con roles y GRANT; el principio de mínimo privilegio aplicado a tres roles concretos de BiblioRed; vistas y seguridad a nivel de fila para que cada sucursal vea solo lo suyo; la inyección SQL y por qué las consultas parametrizadas son la única defensa que funciona; el cifrado en tránsito y en reposo; la auditoría; el tratamiento de los datos personales; y el bloque completo de copias de seguridad, donde el registro WAL de 06-01 reaparece convertido en la herramienta que permite recuperar la base de datos en el instante anterior al desastre.
Contenido
- Por qué la seguridad de la base de datos es distinta de la de la aplicación
- Autenticación: roles, contraseñas y
pg_hba.conf - Autorización:
GRANT,REVOKEy el catálogo de privilegios - Mínimo privilegio: los tres roles de BiblioRed
- El peligro de conectarse como superusuario
- Vistas para ocultar columnas sensibles
- Seguridad a nivel de fila: cada sucursal ve lo suyo
- Inyección SQL: cómo se produce y cómo se impide
- Cifrado en tránsito, en reposo y el caso especial de las contraseñas
- Auditoría: registro de accesos y tabla de cambios
- Datos personales: minimización, seudonimización y retención
- Copias de seguridad: lógicas y físicas
- Completa, diferencial e incremental
- Archivado del WAL y recuperación a un instante concreto
- La regla 3-2-1 y la verificación de las copias
- RPO y RTO aplicados a BiblioRed
- El plan de copias de BiblioRed, comentado
- Los primeros minutos tras un borrado accidental
- El equivalente mínimo en SQLite
- Por qué la seguridad de la base de datos es distinta de la de la aplicación
Es habitual pensar que la seguridad se resuelve en la capa de aplicación: si la pantalla de listado de socios solo la ve el personal autorizado, los datos están protegidos. Es una idea peligrosa, y basta enumerar los caminos que llegan a la base de datos sin pasar por la aplicación para verlo:
| Camino | ¿Pasa por la lógica de la aplicación? |
|---|---|
Un desarrollador con psql desde su portátil |
No |
| Un script de mantenimiento nocturno | No |
| Una herramienta de informes conectada por ODBC | No |
| Una copia de seguridad copiada a un portátil | No |
| Una inyección SQL a través del buscador del catálogo | Sí, pero saltándose la lógica |
| Un becario con las credenciales del fichero de configuración | No |
Seis caminos, y cinco no ven una sola línea del código de la aplicación. De ahí el principio:
Los controles de acceso deben estar donde están los datos. La aplicación puede añadir comodidad y contexto, pero la garantía tiene que vivir en la base de datos.
Es exactamente el mismo argumento que hemos usado en 04-04 para las restricciones y en 06-02 para el aforo del club de lectura: una regla que no puede romperse debe estar donde no se pueda saltar. Aquí la regla es "el personal de mostrador no puede exportar los correos de los socios".
Las capas de seguridad de un sistema, de fuera adentro:
graph LR
A[Red y cortafuegos] --> B[TLS en tránsito]
B --> C[Autenticación:<br/>¿quién eres?]
C --> D[Autorización:<br/>¿qué puedes hacer?]
D --> E[Seguridad de fila:<br/>¿sobre qué filas?]
E --> F[Cifrado en reposo]
F --> G[(Datos)]
D --> H[Auditoría:<br/>¿qué has hecho?]
Las dos preguntas centrales de esta lección son la tercera y la cuarta caja: autenticación (¿quién eres?) y autorización (¿qué puedes hacer?). Se confunden constantemente y son cosas distintas.
- Autenticación: roles, contraseñas y
pg_hba.conf
pg_hba.confRoles: una sola entidad para usuarios y grupos
PostgreSQL no distingue entre "usuario" y "grupo": tiene roles. Un rol con el atributo LOGIN puede conectarse; un rol sin él sirve como grupo de permisos. CREATE USER es simplemente un atajo de CREATE ROLE ... LOGIN.
-- Rol de grupo: agrupa permisos, no se conecta
CREATE ROLE bibliored_mostrador NOLOGIN;
-- Rol de conexión: una persona concreta
CREATE ROLE u_alsina LOGIN PASSWORD 'contraseña-larga-y-única'
VALID UNTIL '2027-01-01';
-- Pertenencia al grupo
GRANT bibliored_mostrador TO u_alsina;Atributos que conviene conocer:
| Atributo | Qué concede |
|---|---|
LOGIN |
Puede conectarse |
SUPERUSER |
Se salta todas las comprobaciones de permisos |
CREATEDB |
Puede crear bases de datos |
CREATEROLE |
Puede crear y modificar otros roles |
INHERIT (por omisión) |
Hereda automáticamente los permisos de los roles a los que pertenece |
NOINHERIT |
Debe activarlos explícitamente con SET ROLE |
CONNECTION LIMIT n |
Máximo de conexiones simultáneas |
VALID UNTIL |
Fecha de caducidad de la contraseña |
Inspeccionar lo que hay:
Lista de roles
Nombre de rol | Atributos | Miembro de
----------------------+----------------------------------+----------------------
app_biblioredes | | {bibliored_app}
bibliored_app | No puede conectarse | {}
bibliored_direccion | No puede conectarse | {}
bibliored_mostrador | No puede conectarse | {}
postgres | Superusuario, Crear rol, Crear BD| {}
u_alsina | Contraseña válida hasta 2027-01-01 | {bibliored_mostrador}pg_hba.conf: quién puede conectarse desde dónde y cómo
Antes de comprobar la contraseña, PostgreSQL consulta el fichero pg_hba.conf (host-based authentication). Es una lista de reglas que se evalúa de arriba abajo, y se aplica la primera que encaja. Si ninguna encaja, la conexión se rechaza.
# TIPO BASE USUARIO DIRECCIÓN MÉTODO local all postgres peer host biblioredes bibliored_mostrador 10.20.0.0/16 scram-sha-256 host biblioredes app_biblioredes 10.20.5.11/32 scram-sha-256 hostssl biblioredes bibliored_direccion 0.0.0.0/0 scram-sha-256 host all all 0.0.0.0/0 reject
Los métodos, con su valoración:
| Método | Qué hace | ¿Usarlo? |
|---|---|---|
scram-sha-256 |
Contraseña con desafío-respuesta; la contraseña nunca viaja por la red | Sí. Es el método por omisión desde PostgreSQL 10 y el recomendado |
md5 |
Método antiguo, con debilidades conocidas | Solo por compatibilidad con clientes viejos; migrar |
peer |
Comprueba el usuario del sistema operativo (solo conexiones locales por socket) | Sí, para tareas de administración en el propio servidor |
cert |
Certificado de cliente TLS | Sí, en entornos con gestión de certificados |
ldap, gss |
Delegación en el directorio corporativo | Sí, en organizaciones con directorio |
trust |
Acepta a cualquiera sin comprobar nada | NO |
reject |
Deniega siempre | Sí, como regla final |
Sobre
trust. Significa literalmente "cualquiera que llegue por esta vía entra como el usuario que diga ser, sin contraseña". Su único uso legítimo es una instancia local de desarrollo en tu propio equipo, sin datos reales y sin puerto abierto al exterior. Una líneahost all all 0.0.0.0/0 trusten un servidor es equivalente a publicar la base de datos en internet sin puerta. Aparece con más frecuencia de la que nadie querría admitir, casi siempre porque alguien la puso "un momento, para probar" y nadie la quitó.
Nótese la línea hostssl para dirección: obliga a que la conexión venga cifrada. Y la última línea, reject, convierte la lista en una política de denegar por omisión, que es como deben escribirse todas las listas de control de acceso.
Tras editar el fichero:
sudo systemctl reload postgresql
# o, sin permisos de sistema, desde psql como superusuario:
# SELECT pg_reload_conf();Comprobar qué reglas están activas:
line_number | type | database | user_name | address | auth_method
-------------+---------+----------------+----------------------+-------------+---------------
80 | local | {all} | {postgres} | | peer
81 | host | {biblioredes} | {bibliored_mostrador}| 10.20.0.0 | scram-sha-256
82 | host | {biblioredes} | {app_biblioredes} | 10.20.5.11 | scram-sha-256
83 | hostssl | {biblioredes} | {bibliored_direccion}| 0.0.0.0 | scram-sha-256
84 | host | {all} | {all} | 0.0.0.0 | reject
- Autorización:
GRANT, REVOKE y el catálogo de privilegios
GRANT, REVOKE y el catálogo de privilegiosAutenticado el rol, la siguiente pregunta es qué puede hacer. El modelo de PostgreSQL es jerárquico: para llegar a una tabla hay que atravesar la base de datos y el esquema.
graph TD
A["Base de datos<br/>privilegio CONNECT"] --> B["Esquema<br/>privilegio USAGE"]
B --> C["Tabla<br/>SELECT / INSERT / UPDATE / DELETE"]
B --> D["Secuencia<br/>USAGE / SELECT"]
B --> E["Función<br/>EXECUTE"]
C --> F["Columna<br/>SELECT (col) / UPDATE (col)"]
Es el error número uno al configurar permisos: dar SELECT sobre las tablas y olvidar el USAGE sobre el esquema. Sin USAGE, el rol no puede ni ver que la tabla existe.
Catálogo de privilegios
| Privilegio | Se aplica a | Permite |
|---|---|---|
CONNECT |
Base de datos | Conectarse a ella |
CREATE |
Base de datos, esquema | Crear esquemas / objetos |
USAGE |
Esquema, secuencia, tipo | Acceder a los objetos del esquema; usar la secuencia |
SELECT |
Tabla, vista, columna | Leer |
INSERT |
Tabla, columna | Insertar |
UPDATE |
Tabla, columna | Modificar |
DELETE |
Tabla | Borrar filas |
TRUNCATE |
Tabla | Vaciar la tabla |
REFERENCES |
Tabla, columna | Crear claves ajenas que la apunten |
TRIGGER |
Tabla | Crear disparadores |
EXECUTE |
Función, procedimiento | Ejecutarla |
Sintaxis
GRANT SELECT, INSERT ON prestamos TO bibliored_mostrador;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO bibliored_direccion;
GRANT USAGE ON SCHEMA public TO bibliored_mostrador;
REVOKE DELETE ON prestamos FROM bibliored_mostrador;Dos formas avanzadas imprescindibles:
Privilegios a nivel de columna. Se puede autorizar la lectura de unas columnas y no de otras:
GRANT SELECT (socio_id, nombre, apellidos, fecha_alta, sucursal_id, activo)
ON socios TO bibliored_mostrador;Ese rol podrá leer el nombre de un socio pero no su email. Si lo intenta:
Privilegios por omisión para objetos futuros. Un GRANT ON ALL TABLES afecta a las tablas que existen hoy. La tabla que alguien cree mañana no estará incluida, y ese es un fallo silencioso clásico:
El problema del rol PUBLIC
PostgreSQL tiene un pseudo-rol llamado PUBLIC al que pertenecen todos. Históricamente, PUBLIC tenía CREATE sobre el esquema public, lo que permitía a cualquier usuario crear tablas ahí. Desde PostgreSQL 15 ya no es así, pero en instalaciones más antiguas —o migradas— conviene comprobarlo y corregirlo:
Inspeccionar los permisos concedidos:
Esquema | Nombre | Tipo | Privilegios de acceso
---------+--------+--------+--------------------------------------------------------
public | socios | tabla | bibliored_app=arwd/postgres +
| | | bibliored_direccion=r/postgres +
| | | bibliored_mostrador=r(socio_id,nombre,apellidos)/postgresLas letras: r = SELECT, a = INSERT, w = UPDATE, d = DELETE, x = REFERENCES, U = USAGE.
- Mínimo privilegio: los tres roles de BiblioRed
Principio de mínimo privilegio. Cada rol recibe exactamente los permisos que necesita para su función, ni uno más, y solo mientras los necesita.
BiblioRed necesita tres perfiles. Los definimos primero en lenguaje llano, porque un permiso que no se puede explicar en una frase suele estar mal pensado:
| Rol | Quién es | Qué necesita | Qué NO debe poder hacer |
|---|---|---|---|
bibliored_mostrador |
Personal de las cuatro sucursales | Prestar, devolver, dar de alta socios, cobrar multas, inscribir en eventos | Leer correos y teléfonos de socios; borrar nada; ver otras sucursales |
bibliored_direccion |
Dirección y concejalía, para informes | Leer todo lo agregado y estadístico | Escribir absolutamente nada; leer datos de contacto |
bibliored_app |
La aplicación web pública | Consultar el catálogo, gestionar reservas e inscripciones del socio autenticado | Tocar multas, pagos, socios completos, ni ningún dato de otro socio |
El script completo
-- =========================================================
-- 1) Cierre por omisión: nadie tiene nada que no se le dé
-- =========================================================
REVOKE ALL ON DATABASE biblioredes FROM PUBLIC;
REVOKE ALL ON SCHEMA public FROM PUBLIC;
REVOKE ALL ON ALL TABLES IN SCHEMA public FROM PUBLIC;
-- =========================================================
-- 2) Roles de grupo (sin LOGIN: son contenedores de permisos)
-- =========================================================
CREATE ROLE bibliored_mostrador NOLOGIN;
CREATE ROLE bibliored_direccion NOLOGIN;
CREATE ROLE bibliored_app NOLOGIN;
GRANT CONNECT ON DATABASE biblioredes
TO bibliored_mostrador, bibliored_direccion, bibliored_app;
GRANT USAGE ON SCHEMA public
TO bibliored_mostrador, bibliored_direccion, bibliored_app;
-- =========================================================
-- 3) MOSTRADOR: opera el día a día, sin datos de contacto
-- =========================================================
GRANT SELECT, INSERT, UPDATE ON prestamos, reservas, inscripciones
TO bibliored_mostrador;
GRANT SELECT, UPDATE (estado, sucursal_id) ON ejemplares
TO bibliored_mostrador;
GRANT SELECT ON materiales, materiales_libro, materiales_dvd,
materiales_revista, materiales_audiolibro, subtitulos_dvd,
autores, sucursales, salas, tipos_evento, eventos, libros
TO bibliored_mostrador;
-- Socios: alta y modificación, pero SIN leer email
GRANT SELECT (socio_id, nombre, apellidos, fecha_alta, sucursal_id, activo)
ON socios TO bibliored_mostrador;
GRANT INSERT ON socios TO bibliored_mostrador;
GRANT UPDATE (nombre, apellidos, email, sucursal_id, activo)
ON socios TO bibliored_mostrador;
-- Multas y pagos: emitir y cobrar, nunca borrar
GRANT SELECT, INSERT, UPDATE ON multas TO bibliored_mostrador;
GRANT SELECT, INSERT ON pagos TO bibliored_mostrador;
-- Secuencias: sin USAGE no se puede insertar en tablas con identidad
GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO bibliored_mostrador;
-- =========================================================
-- 4) DIRECCIÓN: solo lectura, y sin datos de contacto
-- =========================================================
GRANT SELECT ON ALL TABLES IN SCHEMA public TO bibliored_direccion;
-- Se retira el acceso a lo sensible y se sustituye por la vista del apartado 6
REVOKE SELECT ON socios, telefonos_socio FROM bibliored_direccion;
GRANT SELECT ON v_socios_publico TO bibliored_direccion;
-- Objetos futuros: que no se escapen por olvido
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT ON TABLES TO bibliored_direccion;
-- =========================================================
-- 5) APLICACIÓN WEB: superficie mínima
-- =========================================================
GRANT SELECT ON materiales, materiales_libro, materiales_dvd,
materiales_revista, materiales_audiolibro, subtitulos_dvd,
autores, sucursales, ejemplares, salas, tipos_evento, libros
TO bibliored_app;
GRANT SELECT ON v_eventos_publicados TO bibliored_app;
GRANT SELECT, INSERT, UPDATE ON reservas, inscripciones TO bibliored_app;
GRANT SELECT ON prestamos TO bibliored_app;
GRANT SELECT (socio_id, nombre, apellidos, email, sucursal_id, activo)
ON socios TO bibliored_app;
GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO bibliored_app;
-- Lo que la aplicación NO puede tocar, dicho explícitamente
REVOKE ALL ON multas, pagos, informes_evento FROM bibliored_app;
-- =========================================================
-- 6) Roles de conexión reales
-- =========================================================
CREATE ROLE u_alsina LOGIN PASSWORD 'xxxxxxxxxxxx' VALID UNTIL '2027-01-01';
CREATE ROLE u_pereda LOGIN PASSWORD 'xxxxxxxxxxxx' VALID UNTIL '2027-01-01';
CREATE ROLE u_direccion LOGIN PASSWORD 'xxxxxxxxxxxx' VALID UNTIL '2027-01-01';
CREATE ROLE app_biblioredes LOGIN PASSWORD 'xxxxxxxxxxxx' CONNECTION LIMIT 40;
GRANT bibliored_mostrador TO u_alsina, u_pereda;
GRANT bibliored_direccion TO u_direccion;
GRANT bibliored_app TO app_biblioredes;Comprobarlo
Un permiso que no se ha probado es una suposición. La comprobación se hace suplantando el rol:
SET ROLE bibliored_mostrador;
SELECT nombre, apellidos FROM socios WHERE socio_id = 14; -- debe funcionar
SELECT email FROM socios WHERE socio_id = 14; -- debe fallar
DELETE FROM prestamos WHERE prestamo_id = 88301; -- debe fallar
RESET ROLE;nombre | apellidos ---------+------------ Marta | Alsina ERROR: permission denied for table socios ERROR: permission denied for table prestamos
Dos errores esperados y una consulta correcta: los permisos hacen lo que dice el papel.
Existe además una función para consultarlo sin ejecutar nada:
SELECT has_table_privilege('bibliored_mostrador', 'multas', 'DELETE') AS puede_borrar_multas,
has_column_privilege('bibliored_mostrador', 'socios', 'email', 'SELECT') AS puede_leer_email;
- El peligro de conectarse como superusuario
Es la mala práctica más extendida y la más fácil de corregir.
Cuando una aplicación se conecta con un rol superusuario —postgres en la mayoría de instalaciones—, todo el trabajo de los apartados anteriores queda anulado. Un superusuario se salta todas las comprobaciones de permisos, incluida la seguridad a nivel de fila del apartado 7.
Qué puede hacer un atacante que consiga la conexión de la aplicación:
Con bibliored_app |
Con postgres (superusuario) |
|---|---|
| Leer el catálogo y las reservas | Leer todo, incluidas multas y pagos |
| No puede borrar tablas | DROP TABLE prestamos; |
No puede tocar pg_hba.conf |
Puede crearse usuarios y darse acceso permanente |
| No puede leer ficheros del servidor | COPY ... FROM PROGRAM ejecuta órdenes del sistema operativo |
Esa última fila convierte una inyección SQL de gravedad media en un compromiso total del servidor.
La lista de comprobación:
- La cadena de conexión de la aplicación nunca usa
postgresni ningún rol conSUPERUSER. - El propietario de las tablas es un rol de administración distinto del que usa la aplicación.
- Las contraseñas viven en un gestor de secretos o en variables de entorno del servicio, jamás en el repositorio de código.
- Cada persona tiene su propio rol de conexión. Una cuenta compartida hace imposible la auditoría del apartado 10.
Verificar que no hay superusuarios de más:
Uno. Si aparecen tres o cuatro, hay trabajo que hacer.
- Vistas para ocultar columnas sensibles
Los privilegios de columna del apartado 3 funcionan bien, pero tienen un inconveniente práctico: SELECT * falla, y muchas herramientas de informes lo usan. La alternativa elegante es una vista.
Una vista se ejecuta con los permisos de quien la creó, no de quien la consulta. Eso permite dar acceso a un subconjunto de datos sin dar acceso a la tabla subyacente.
CREATE VIEW v_socios_publico AS
SELECT socio_id,
nombre,
apellidos,
fecha_alta,
sucursal_id,
activo
FROM socios;
-- El rol NO tiene permiso sobre socios, pero sí sobre la vista
REVOKE ALL ON socios FROM bibliored_direccion;
GRANT SELECT ON v_socios_publico TO bibliored_direccion;Comprobación:
socio_id | nombre | apellidos | fecha_alta | sucursal_id | activo
----------+--------+-------------+------------+-------------+--------
14 | Marta | Alsina | 2019-03-11 | 1 | t
15 | Iván | Pereda | 2021-09-02 | 2 | t
16 | Nuria | Bastos | 2023-01-24 | 3 | tSELECT * funciona, y los correos y teléfonos son inalcanzables.
Otras vistas útiles del mismo tipo en BiblioRed:
-- Catálogo público de eventos: sin notas internas ni informes
CREATE VIEW v_eventos_publicados AS
SELECT e.evento_id, e.titulo, t.nombre AS tipo, s.nombre AS sala,
e.inicio, e.fin, e.plazas_ofertadas
FROM eventos e
JOIN tipos_evento t ON t.tipo_evento_id = e.tipo_evento_id
JOIN salas s ON s.sala_id = e.sala_id
WHERE e.publicado AND e.estado = 'programado';
-- Datos de contacto enmascarados, para soporte técnico
CREATE VIEW v_socios_contacto_enmascarado AS
SELECT socio_id,
nombre,
apellidos,
regexp_replace(email, '(.).*(@.*)', '\1***\2') AS email_enmascarado,
sucursal_id
FROM socios; socio_id | nombre | apellidos | email_enmascarado | sucursal_id
----------+--------+-----------+--------------------+-------------
14 | Marta | Alsina | m***@example.org | 1Una advertencia sobre el enmascaramiento: sirve para que el personal de soporte pueda verificar un correo que el socio dicta por teléfono, no para anonimizar. Un enmascaramiento no es una anonimización, y en el apartado 11 veremos la diferencia.
Nota técnica: por omisión las vistas son SECURITY INVOKER en cuanto a la seguridad a nivel de fila del apartado siguiente, pero se ejecutan con los permisos del propietario respecto a las tablas. Si necesitas que la vista aplique las políticas de fila del usuario que consulta, decláralas con WITH (security_invoker = true), disponible desde PostgreSQL 15.
- Seguridad a nivel de fila: cada sucursal ve lo suyo
Las vistas ocultan columnas. Para ocultar filas —que el personal de la sucursal Norte no vea los préstamos de la sucursal Sur— PostgreSQL ofrece la seguridad a nivel de fila (row level security, RLS).
Con RLS, cada tabla puede llevar políticas que actúan como un
WHEREimplícito y obligatorio, aplicado por el gestor a toda consulta de los roles afectados.
Paso 1: activar RLS
Atención: con RLS activada y sin ninguna política, nadie ve nada. El comportamiento por omisión es denegar, que es el correcto.
Paso 2: establecer el contexto de la sesión
La política necesita saber en qué sucursal está el usuario. La aplicación lo comunica con un parámetro de sesión al abrir la conexión:
Paso 3: crear las políticas
-- El personal de mostrador solo ve los préstamos de su sucursal
CREATE POLICY pol_prestamos_sucursal ON prestamos
FOR ALL
TO bibliored_mostrador
USING (
EXISTS (
SELECT 1 FROM ejemplares e
WHERE e.ejemplar_id = prestamos.ejemplar_id
AND e.sucursal_id = current_setting('app.sucursal_actual')::int
)
);
-- Dirección ve todos los préstamos, sin restricción de fila
CREATE POLICY pol_prestamos_direccion ON prestamos
FOR SELECT
TO bibliored_direccion
USING (true);Las cláusulas de una política:
| Cláusula | Qué controla |
|---|---|
USING (expr) |
Qué filas son visibles (SELECT, UPDATE, DELETE) |
WITH CHECK (expr) |
Qué filas se pueden crear o dejar (INSERT, UPDATE) |
FOR |
A qué operaciones se aplica (ALL, SELECT, INSERT, UPDATE, DELETE) |
TO |
A qué roles |
Sin WITH CHECK, un rol podría insertar filas que después no puede ver, lo cual suele ser un error. La política completa para socios:
ALTER TABLE socios ENABLE ROW LEVEL SECURITY;
CREATE POLICY pol_socios_sucursal ON socios
FOR ALL
TO bibliored_mostrador
USING (sucursal_id = current_setting('app.sucursal_actual')::int)
WITH CHECK (sucursal_id = current_setting('app.sucursal_actual')::int);Comprobación
La misma consulta, el mismo rol, resultados distintos: el gestor está aplicando el filtro por su cuenta. Y es imposible saltárselo desde SQL:
Las tres advertencias sobre RLS
- Los superusuarios y los propietarios de la tabla se saltan RLS. Si la aplicación se conecta como propietaria de las tablas, las políticas no se aplican. Hay que forzarlo con
ALTER TABLE socios FORCE ROW LEVEL SECURITY;. - Tiene coste de rendimiento. La política se convierte en una condición añadida a cada consulta, y las políticas con subconsultas como la de
prestamospueden cambiar el plan de ejecución. Compruébalo conEXPLAIN ANALYZE(06-03). - El parámetro de sesión debe fijarlo la aplicación de forma fiable y no debe poder alterarlo el usuario final. Si el usuario puede influir en
app.sucursal_actual, la política no protege de nada.
- Inyección SQL: cómo se produce y cómo se impide
Es la vulnerabilidad más antigua y más conocida de las aplicaciones con base de datos, y sigue apareciendo cada año en las listas de incidentes. Su causa es siempre la misma: mezclar código y datos en la misma cadena de texto.
Cómo se produce
El buscador del catálogo de BiblioRed construye la consulta concatenando lo que el usuario escribe:
# CÓDIGO VULNERABLE — no escribas esto nunca
termino = request.args.get("q")
sql = "SELECT material_id, titulo FROM materiales WHERE titulo LIKE '%" + termino + "%'"
cursor.execute(sql)Con una búsqueda normal, q = mapa, la consulta resultante es correcta:
Ahora un visitante escribe en el buscador:
La consulta que llega al servidor es:
SELECT material_id, titulo FROM materiales WHERE titulo LIKE '%' UNION SELECT socio_id, email FROM socios --%' material_id | titulo
-------------+------------------------------
907 | El mapa del tiempo
14 | [email protected]
15 | [email protected]
16 | [email protected]
...Los doce mil correos de los socios de Vallmar, en el buscador público del catálogo. Sin contraseña, sin herramientas y sin dejar más rastro que una línea en el registro de consultas.
Y esto es solo la lectura. Lo que permite una inyección, en general:
| Objetivo | Ejemplo |
|---|---|
| Leer cualquier tabla accesible | UNION SELECT sobre socios, multas, pagos |
| Modificar o borrar datos | '; UPDATE multas SET estado='pagada'; -- |
| Eludir la autenticación | ' OR '1'='1 en un formulario de acceso |
| Extraer el esquema | Consultas a information_schema |
| Denegar el servicio | pg_sleep(60) en cada petición |
| Ejecutar órdenes del sistema | COPY ... FROM PROGRAM, solo si la conexión es superusuario |
Esa última fila explica por qué los apartados 4 y 5 son parte de la defensa contra la inyección: con bibliored_app bien limitado, la misma inyección no puede leer multas, ni borrar nada, ni tocar el sistema operativo. El daño de una inyección es exactamente igual a los permisos de la conexión.
La defensa principal: consultas parametrizadas
Una consulta parametrizada envía el texto SQL y los valores por caminos separados. El gestor recibe primero la estructura de la consulta y después los datos, así que es imposible que un dato se interprete como código. No hay nada que escapar porque no hay nada que mezclar.
# CÓDIGO CORRECTO
termino = request.args.get("q")
sql = "SELECT material_id, titulo FROM materiales WHERE titulo ILIKE %s"
cursor.execute(sql, ('%' + termino + '%',))Con el mismo ataque, el resultado es:
La base de datos ha buscado, literalmente, materiales cuyo título contenga la cadena ' UNION SELECT socio_id, email FROM socios --. No hay ninguno. El ataque se ha convertido en una búsqueda sin resultados, que es exactamente lo que debe ser.
En SQL directo, dentro de psql o de una función, el equivalente es PREPARE:
PREPARE buscar_material (text) AS
SELECT material_id, titulo FROM materiales WHERE titulo ILIKE '%' || $1 || '%';
EXECUTE buscar_material ('mapa');Por qué escapar a mano no basta
Es tentador pensar que basta con "quitar las comillas". No basta, por cinco razones:
| Problema | Explicación |
|---|---|
| Hay que acordarse siempre | Basta un sitio olvidado de doscientos para que la defensa no exista |
| Depende de la codificación | Ciertas codificaciones multibyte permiten construir secuencias que sobreviven al escapado |
| Los números no llevan comillas | WHERE socio_id = + entrada no se protege escapando comillas |
| No cubre los identificadores | Un nombre de columna o de tabla dinámico necesita otro tratamiento (quote_ident) |
| Es código propio en el camino crítico | Cualquier error en la función de escapado abre el agujero entero |
Una consulta parametrizada no tiene ninguno de estos problemas porque no escapa nada: separa los caminos.
Cuando el SQL debe ser dinámico
A veces la parte variable es el nombre de una columna de ordenación, y eso no se puede parametrizar. La solución no es escapar, es validar contra una lista blanca:
COLUMNAS_ORDEN = {"titulo": "titulo", "anio": "anio_publicacion", "autor": "autor_id"}
col = COLUMNAS_ORDEN.get(request.args.get("orden"), "titulo") # si no está, valor por omisión
sql = f"SELECT material_id, titulo FROM materiales ORDER BY {col} LIMIT %s"
cursor.execute(sql, (20,))La entrada del usuario nunca llega al SQL: solo se usa como clave para elegir entre valores fijos escritos por ti.
Defensas complementarias
Ninguna sustituye a la parametrización; todas reducen el daño:
- Mínimo privilegio (apartado 4): que la conexión no pueda leer lo que no le toca.
- Validación de entrada: comprobar que un identificador es un entero, que una fecha es una fecha.
- Nunca mostrar el mensaje de error de la base de datos al usuario final. Un
ERROR: column "socios.email" does not existes un mapa del esquema regalado. - Registrar los errores de SQL: una ráfaga de errores de sintaxis desde una misma dirección es un ataque en curso.
- Cifrado en tránsito, en reposo y el caso especial de las contraseñas
En tránsito: TLS
Sin cifrado, las consultas y sus resultados viajan en claro por la red. Cualquiera con acceso al tramo puede leer los correos de los socios según se transmiten.
# postgresql.conf ssl = on ssl_cert_file = '/etc/ssl/certs/biblioredes.crt' ssl_key_file = '/etc/ssl/private/biblioredes.key'
Y en pg_hba.conf, hostssl en lugar de host para obligar a que la conexión venga cifrada. Del lado del cliente:
Los modos de sslmode, que importan más de lo que parece:
| Modo | Cifra | Verifica el certificado | Verifica el nombre del servidor |
|---|---|---|---|
disable |
No | — | — |
require |
Sí | No | No |
verify-ca |
Sí | Sí | No |
verify-full |
Sí | Sí | Sí |
require cifra pero no comprueba con quién habla, así que no protege de un intermediario. La configuración correcta en producción es verify-full.
Comprobar el estado de una conexión:
ssl | version | cipher -----+---------+------------------------- t | TLSv1.3 | TLS_AES_256_GCM_SHA384
En reposo
Dos niveles, con propósitos distintos:
| Nivel | Cómo | Protege de | No protege de |
|---|---|---|---|
| Disco / volumen (LUKS, cifrado del proveedor) | Transparente para PostgreSQL | Robo físico del disco, retirada de hardware, copias en soportes extraviados | Nada de lo que ocurra con el servidor encendido |
Columna (pgcrypto) |
Cifrado explícito de valores concretos | Lectura directa de los ficheros o de una copia | Requiere gestionar claves, e impide indexar y buscar por esa columna |
El cifrado de disco es barato, transparente y debe estar activado siempre. El cifrado por columna es una herramienta quirúrgica: en BiblioRed no hay nada que lo justifique —no se guardan números de tarjeta ni datos de salud—, y aplicarlo a email haría imposible buscar por correo y obligaría a custodiar una clave cuya pérdida equivaldría a perder los datos.
Regla: cifra el disco siempre; cifra columnas solo cuando puedas nombrar el dato exacto, el riesgo exacto y quién custodia la clave.
Contraseñas: la excepción que no se cifra
Las contraseñas de los socios para el portal web merecen un apartado propio porque el error aquí es grave y frecuente.
| Práctica | Veredicto |
|---|---|
| Guardar la contraseña en claro | Inaceptable |
| Guardarla cifrada de forma reversible | Inaceptable: quien tenga la clave las tiene todas |
Guardar un MD5 o SHA-1 de la contraseña |
Inaceptable: se rompen con tablas precalculadas |
Guardar un SHA-256 sin sal |
Insuficiente: rápido de probar por fuerza bruta |
| Guardar el resultado de una función de derivación de clave con sal (bcrypt, scrypt, Argon2) | Correcto |
Una contraseña no se cifra: se transforma con una función de un solo sentido, deliberadamente lenta y con sal. No hace falta poder recuperarla; solo hace falta poder comprobarla. Por eso los sistemas serios ofrecen "restablecer la contraseña" y nunca "recordarle su contraseña".
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- Al dar de alta o cambiar la contraseña
UPDATE socios
SET clave_hash = crypt('la-contraseña-del-socio', gen_salt('bf', 12))
WHERE socio_id = 14;
-- Al comprobarla
SELECT socio_id
FROM socios
WHERE socio_id = 14
AND clave_hash = crypt('la-contraseña-tecleada', clave_hash);Si la contraseña es incorrecta, la consulta devuelve cero filas. El parámetro 12 de gen_salt('bf', 12) es el coste: cada unidad duplica el tiempo de cálculo, lo que ralentiza los ataques por fuerza bruta sin molestar al usuario legítimo.
Nota importante: lo anterior vale para las contraseñas de los socios en la aplicación. Las contraseñas de los roles de PostgreSQL las gestiona el propio servidor con SCRAM-SHA-256, y no hay que hacer nada especial más allá de usar ese método en pg_hba.conf.
- Auditoría: registro de accesos y tabla de cambios
Autenticación, autorización y cifrado responden a "¿quién puede?". La auditoría responde a "¿quién ha hecho qué, y cuándo?", que es la pregunta que se hace después de un incidente, y también la que exige cualquier revisión seria de tratamiento de datos personales.
Registro del servidor
# postgresql.conf log_connections = on log_disconnections = on log_statement = 'ddl' # 'none' | 'ddl' | 'mod' | 'all' log_min_duration_statement = 1000 log_line_prefix = '%m [%p] %u@%d de %h '
| Parámetro | Qué registra | Coste |
|---|---|---|
log_connections / log_disconnections |
Quién se conecta y desde dónde | Muy bajo |
log_statement = 'ddl' |
Cambios de esquema | Bajo. Mínimo recomendable |
log_statement = 'mod' |
Además, todas las escrituras | Medio |
log_statement = 'all' |
Absolutamente todo | Alto, y registra datos personales en texto plano |
Ese último punto merece atención: activar log_statement = 'all' en una base con datos personales significa que los correos y teléfonos de los socios acaban escritos en los ficheros de registro, que a menudo tienen menos protección y más copias que la propia base de datos. Es un caso claro de una medida de seguridad que crea un problema de privacidad.
Tabla de auditoría con disparadores
Para saber quién modificó qué fila y cuándo, el patrón estándar es una tabla de auditoría alimentada por un disparador —los TRIGGER que presentamos en 05-04—.
CREATE TABLE auditoria_socios (
auditoria_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
socio_id INTEGER NOT NULL,
operacion TEXT NOT NULL CHECK (operacion IN ('INSERT','UPDATE','DELETE')),
momento TIMESTAMPTZ NOT NULL DEFAULT now(),
usuario_bd TEXT NOT NULL DEFAULT current_user,
direccion_ip INET,
datos_antes JSONB,
datos_despues JSONB
);
CREATE INDEX idx_auditoria_socios_socio ON auditoria_socios (socio_id, momento DESC);
CREATE FUNCTION fn_auditar_socios() RETURNS TRIGGER AS $$
BEGIN
INSERT INTO auditoria_socios (socio_id, operacion, direccion_ip, datos_antes, datos_despues)
VALUES (
coalesce(NEW.socio_id, OLD.socio_id),
TG_OP,
inet_client_addr(),
CASE WHEN TG_OP IN ('UPDATE','DELETE') THEN to_jsonb(OLD) END,
CASE WHEN TG_OP IN ('INSERT','UPDATE') THEN to_jsonb(NEW) END
);
RETURN NULL; -- disparador AFTER: el valor devuelto se ignora
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER trg_auditar_socios
AFTER INSERT OR UPDATE OR DELETE ON socios
FOR EACH ROW EXECUTE FUNCTION fn_auditar_socios();Probémoslo:
UPDATE socios SET email = '[email protected]' WHERE socio_id = 14;
SELECT operacion, momento, usuario_bd,
datos_antes ->> 'email' AS email_antes,
datos_despues ->> 'email' AS email_despues
FROM auditoria_socios
WHERE socio_id = 14
ORDER BY momento DESC LIMIT 1;operacion | momento | usuario_bd | email_antes | email_despues -----------+-------------------------------+------------+--------------------------+------------------------------- UPDATE | 2026-08-02 13:14:52.118+02 | u_alsina | [email protected] | [email protected]
Cuatro decisiones de diseño que conviene entender:
SECURITY DEFINERhace que la función se ejecute con los permisos de quien la creó, de modo que el rol de mostrador puede generar registros de auditoría sin tener permiso de escritura sobre la tabla de auditoría. Es justo lo que se quiere: poder escribir en el registro pero no poder borrarlo.current_useridentifica al rol de la base de datos. Por eso importa que cada persona tenga su propio rol de conexión (apartado 5): con una cuenta compartida, esta columna no dice nada útil.JSONBpara el antes y el después evita tener que rehacer la tabla de auditoría cada vez que cambia el esquema desocios. Es un uso legítimo dejsonbde los que 04-04 llamaba "escapes controlados".- El coste: la auditoría duplica las escrituras y hace crecer una tabla que nadie consulta a diario. Se aplica a las tablas con datos sensibles —
socios,multas,pagos—, no a todas.
Y una advertencia final: la tabla de auditoría contiene también datos personales, incluidos los que se hayan borrado de la tabla original. Necesita los mismos permisos restrictivos y la misma política de retención que los datos que audita.
- Datos personales: minimización, seudonimización y retención
⚠️ Advertencia importante. Lo que sigue describe mecanismos técnicos para tratar datos personales en una base de datos. No es asesoramiento legal. El cumplimiento del Reglamento General de Protección de Datos (RGPD) y de la legislación aplicable en cada jurisdicción —incluidas las obligaciones específicas de una administración pública como el ayuntamiento de Vallmar— debe revisarlo un profesional de compliance o el delegado de protección de datos de la organización. Las decisiones sobre qué datos se pueden recoger, con qué base jurídica, durante cuánto tiempo y con qué medidas, son decisiones jurídicas y organizativas, no técnicas. Esta lección enseña cómo implementar lo que se decida, no qué debe decidirse.
Dicho eso, hay cuatro técnicas que todo profesional de bases de datos debería conocer.
Minimización
La medida más eficaz es no tener el dato.
Repasando el esquema de BiblioRed con esa pregunta:
| Dato | ¿Para qué se usa? | Decisión |
|---|---|---|
socios.email |
Avisos de vencimiento y reservas disponibles | Se conserva: hay una función clara |
telefonos_socio |
Llamadas por retrasos graves | Se conserva, revisando si hacen falta varios por socio |
| Fecha de nacimiento completa | Solo para saber si es carné infantil o adulto | Sustituible por el año, o por una bandera es_menor |
| Dirección postal completa | Nada en el sistema actual | Eliminar |
| DNI | Verificación en el alta presencial | Verificar y no almacenar, o almacenar solo una comprobación |
Cada dato que no está no puede filtrarse, no hay que cifrarlo, no hay que auditarlo y no hay que borrarlo.
Seudonimización
Seudonimizar es sustituir los identificadores directos por una referencia que no identifica por sí sola, conservando en otro lugar y con otras protecciones la correspondencia. Es reversible con la información adicional.
En BiblioRed, la tabla de estadísticas de uso no necesita saber quién es cada socio:
CREATE TABLE estadisticas_uso (
seudonimo TEXT NOT NULL,
fecha DATE NOT NULL,
sucursal_id INTEGER NOT NULL REFERENCES sucursales(sucursal_id),
tipo_material TEXT NOT NULL,
prestamos INTEGER NOT NULL
);
-- El seudónimo es estable (permite series temporales) y no invertible sin la sal
INSERT INTO estadisticas_uso (seudonimo, fecha, sucursal_id, tipo_material, prestamos)
SELECT encode(digest(p.socio_id::text || current_setting('app.sal_estadistica'), 'sha256'), 'hex'),
p.fecha_prestamo, e.sucursal_id, m.tipo, count(*)
FROM prestamos p
JOIN ejemplares e ON e.ejemplar_id = p.ejemplar_id
JOIN materiales m ON m.material_id = e.material_id
GROUP BY 1, 2, 3, 4;La clave está en la sal (app.sal_estadistica): sin ella, un sha256 de un identificador numérico se invierte probando los 12.000 valores posibles en menos de un segundo. Un seudónimo sin sal no es un seudónimo.
Anonimización para entornos de prueba
Anonimizar es transformar los datos de modo que ya no sea posible identificar a la persona, ni siquiera con información adicional. A diferencia de la seudonimización, es irreversible.
El caso práctico es el entorno de desarrollo. Copiar la base de producción al portátil de un desarrollador —que es lo que se hace en la mitad de las organizaciones— significa distribuir los datos de doce mil vecinos por equipos sin control, sin cifrado de disco garantizado y sin auditoría.
Un guion de anonimización para el entorno de pruebas de BiblioRed:
-- EJECUTAR SOLO SOBRE LA COPIA DE PRUEBAS. Nunca sobre producción.
BEGIN;
UPDATE socios SET
nombre = 'Socio' || socio_id,
apellidos = 'Apellido' || socio_id,
email = 'socio' || socio_id || '@example.org';
UPDATE telefonos_socio SET
numero = '600' || lpad((100000 + socio_id)::text, 6, '0');
-- Los importes y las fechas se conservan: hacen falta para probar de verdad
-- Las tablas de auditoría contienen datos antiguos: se vacían
TRUNCATE auditoria_socios;
COMMIT;Tres reglas para que una anonimización sirva de algo:
- Se ejecuta como parte del proceso de restauración, automáticamente, nunca a mano. Un paso manual se olvidará.
- Se conserva la forma de los datos —longitudes, distribuciones, volumen— o el entorno de pruebas dejará de parecerse a producción y los planes de ejecución de 06-03 no serán comparables.
- Se comprueba que no queda ningún dato identificable en tablas secundarias: auditoría, registros,
informes_evento, campos de texto libre donde alguien anotó "llamar al 6XX del hijo".
Ese último punto es el que más falla. Los campos de observaciones en texto libre son un pozo de datos personales que ninguna anonimización por columnas detecta.
Retención
Un dato guardado para siempre es un riesgo guardado para siempre.
-- Ejemplo de política técnica: histórico de préstamos con más de 5 años
-- (el plazo concreto es una decisión jurídica, no técnica)
UPDATE prestamos
SET socio_id = NULL
WHERE fecha_devolucion < CURRENT_DATE - INTERVAL '5 years'
AND socio_id IS NOT NULL;Fíjate en que no se borra el préstamo: se desvincula del socio. La biblioteca conserva la estadística de circulación —cuántas veces se prestó cada material, en qué sucursal, en qué mes—, que es lo que necesita para su gestión, y deja de conservar quién leyó qué. Es el tipo de solución que la técnica sí puede aportar a una decisión de retención.
Esto exige, claro, que prestamos.socio_id admita nulos y que las claves ajenas lo permitan, lo cual es una decisión de diseño que conviene tomar antes, en el módulo 4, y no cuando llega la política de retención.
- Copias de seguridad: lógicas y físicas
Pasamos a la segunda pregunta de la concejalía. Empecemos por la afirmación que ordena todo el bloque:
Una copia de seguridad no probada no es una copia de seguridad: es una esperanza.
PostgreSQL ofrece dos familias de copia, y no compiten: se complementan.
Copia lógica: pg_dump y pg_restore
Genera un fichero con las instrucciones necesarias para reconstruir los datos: CREATE TABLE, COPY, CREATE INDEX.
# Formato personalizado (comprimido, restauración selectiva) — el recomendado
pg_dump -h localhost -U postgres -d biblioredes \
-F c -Z 6 -f /copias/biblioredes_2026-08-02.dump
# Solo el esquema, para control de versiones
pg_dump -d biblioredes --schema-only -f /copias/esquema_2026-08-02.sql
# Solo unas tablas
pg_dump -d biblioredes -t socios -t prestamos -F c -f /copias/parcial.dumpEn caso de éxito, pg_dump no imprime nada: solo habla si hay problemas. Para ver el progreso en bases grandes se usa la opción -v.
Restaurar:
# Base completa en una base nueva
createdb -U postgres biblioredes_restaurada
pg_restore -U postgres -d biblioredes_restaurada -j 4 /copias/biblioredes_2026-08-02.dump
# Una sola tabla, que es donde brilla el formato personalizado
pg_restore -U postgres -d biblioredes -t socios /copias/biblioredes_2026-08-02.dump
# Ver qué contiene sin restaurar nada
pg_restore -l /copias/biblioredes_2026-08-02.dump | head -20; ; Archive created at 2026-08-02 03:00:14 CEST ; dbname: biblioredes ; TOC Entries: 214 ; Compression: 6 ; Format: CUSTOM ; 215; 1259 16482 TABLE public socios postgres 216; 1259 16490 TABLE public prestamos postgres ...
Importante: pg_dump no copia los roles ni las contraseñas, que son globales del servidor. Hacen falta aparte:
Olvidar este fichero es un clásico: se restauran los datos y no se puede entrar porque no existe ningún rol.
Copia física: pg_basebackup
Copia los ficheros del directorio de datos tal cual, a nivel de bloque.
Copia lógica (pg_dump) |
Copia física (pg_basebackup) |
|
|---|---|---|
| Qué copia | Instrucciones SQL para reconstruir | Los ficheros del clúster |
| Granularidad | Una tabla, un esquema o toda la base | Todo el clúster, sin excepción |
| Portabilidad | Entre versiones y arquitecturas distintas | Solo la misma versión mayor y arquitectura |
| Tamaño | Menor (sin índices, comprimido) | Mayor (incluye índices) |
| Velocidad de copia | Lenta en bases grandes | Rápida |
| Velocidad de restauración | Lenta: reejecuta todo y reconstruye índices | Rápida: es copiar ficheros |
| ¿Permite recuperar a un instante concreto? | No | Sí, con archivado del WAL |
| Uso típico | Migraciones, copias por tabla, cambio de versión | Recuperación ante desastre |
La respuesta correcta para BiblioRed es "las dos", y el apartado 17 concreta el plan.
- Completa, diferencial e incremental
La clasificación clásica, aplicable a cualquier sistema de copias:
| Tipo | Qué copia | Espacio | Tiempo de copia | Tiempo de restauración |
|---|---|---|---|---|
| Completa | Todo | Máximo | Máximo | Mínimo: un solo fichero |
| Diferencial | Lo cambiado desde la última completa | Medio, creciente | Medio | Media: completa + última diferencial |
| Incremental | Lo cambiado desde la última copia de cualquier tipo | Mínimo | Mínimo | Máxima: completa + todas las incrementales |
El compromiso es siempre el mismo: espacio y tiempo de copia frente a tiempo y complejidad de restauración. Y hay un factor que decide más que los números:
Cuantos más ficheros necesite una restauración, más probable es que uno falle. Una cadena incremental de treinta eslabones se rompe si falta el número diecisiete.
En PostgreSQL, la traducción práctica de este esquema es:
- La completa es el
pg_basebackup. - El papel de las incrementales lo cumple el archivado del WAL, que es continuo en lugar de periódico. Y es una solución mejor que las incrementales clásicas, porque permite restaurar no solo al momento de una copia, sino a cualquier instante.
(PostgreSQL 17 añadió además copias incrementales nativas con pg_basebackup --incremental, útiles en bases muy grandes; el archivado del WAL sigue siendo la base del esquema.)
- Archivado del WAL y recuperación a un instante concreto
Aquí reaparece el registro de escritura anticipada de la lección 06-01, y su segunda vida es tan importante como la primera.
Recordarás el mecanismo: antes de modificar una página de datos, PostgreSQL escribe en el WAL una anotación que describe el cambio. Ese registro contiene, por tanto, la historia completa de todas las modificaciones desde el momento en que se hizo la copia base.
Archivado del WAL. Si conservamos una copia base y todos los segmentos de WAL generados desde entonces, podemos reconstruir la base de datos en cualquier instante posterior a la copia base, aplicando el registro hasta el punto deseado. Es la recuperación a un instante concreto (point-in-time recovery, PITR).
graph LR
A["03:00<br/>Copia base completa<br/>(pg_basebackup)"] --> B["03:00 → 11:47<br/>Segmentos WAL<br/>archivados sin parar"]
B --> C["11:47:03<br/>DELETE FROM socios<br/>sin WHERE"]
C --> D["11:52<br/>Se detecta<br/>el problema"]
D --> E["Restauración:<br/>copia base + WAL<br/>hasta las 11:46:59"]
Configuración
# postgresql.conf wal_level = replica archive_mode = on archive_command = 'test ! -f /archivo_wal/%f && cp %p /archivo_wal/%f' archive_timeout = 300
| Parámetro | Qué hace |
|---|---|
wal_level = replica |
Genera suficiente información en el WAL para recuperación y réplicas |
archive_mode = on |
Activa el archivado |
archive_command |
Orden que copia cada segmento a un lugar seguro |
archive_timeout = 300 |
Fuerza el cierre de un segmento cada 5 minutos aunque no esté lleno. Acota la pérdida máxima |
Ese archive_timeout merece atención porque es el que fija el peor caso: sin él, un segmento de 16 MB a medio llenar podría no archivarse en horas, y ese trabajo se perdería. Con 300 segundos, la pérdida máxima está acotada a cinco minutos.
Y un aviso operativo importante: si archive_command falla, PostgreSQL conserva los segmentos y el disco se llena. Un archive_command que apunta a un destino inaccesible es una forma segura de detener el servidor en unas horas. Vigílalo:
SELECT archived_count, last_archived_wal, last_archived_time,
failed_count, last_failed_time
FROM pg_stat_archiver; archived_count | last_archived_wal | last_archived_time | failed_count | last_failed_time
----------------+--------------------------+-----------------------------+--------------+------------------
88412 | 000000010000000000000023 | 2026-08-02 13:15:02.118+02 | 0 |failed_count = 0 y last_archived_time reciente: el archivado funciona. Estas dos columnas deberían estar en el panel de supervisión.
La restauración a un instante concreto
Escenario real: a las 11:47 alguien ejecuta en producción, creyendo estar en pruebas:
A las 11:52 se detecta. Los pasos:
# 1) Parar el servidor. No hay ninguna prisa que justifique saltarse esto.
sudo systemctl stop postgresql
# 2) Apartar el directorio de datos actual. NO borrarlo: puede contener
# lo escrito entre las 11:47 y las 11:52, y hará falta para reconciliar.
sudo mv /var/lib/postgresql/17/main /var/lib/postgresql/17/main_incidente
# 3) Restaurar la copia base
sudo -u postgres mkdir -p /var/lib/postgresql/17/main
sudo -u postgres tar -xzf /copias/base_2026-08-02/base.tar.gz \
-C /var/lib/postgresql/17/main
# 4) Indicar hasta dónde aplicar el registro
sudo -u postgres tee -a /var/lib/postgresql/17/main/postgresql.auto.conf <<'EOF'
restore_command = 'cp /archivo_wal/%f %p'
recovery_target_time = '2026-08-02 11:46:59+02'
recovery_target_action = 'promote'
EOF
sudo -u postgres touch /var/lib/postgresql/17/main/recovery.signal
# 5) Arrancar: PostgreSQL aplicará el WAL hasta el instante indicado
sudo systemctl start postgresqlEn el registro del servidor:
LOG: starting point-in-time recovery to 2026-08-02 11:46:59+02 LOG: restored log file "000000010000000000000021" from archive LOG: restored log file "000000010000000000000022" from archive LOG: recovery stopping before commit of transaction 90412, time 2026-08-02 11:47:03.882+02 LOG: redo done at 0/22F1A8C0 LOG: selected new timeline ID: 2 LOG: archive recovery complete LOG: database system is ready to accept connections
Fíjate en la línea decisiva: recovery stopping before commit of transaction 90412. Esa transacción 90412 es el DELETE. La recuperación se detiene justo antes de confirmarla.
Los doce mil socios están de vuelta. Se han perdido solo las operaciones de esos cinco minutos entre las 11:47 y las 11:52, que se reconstruyen a mano desde el directorio apartado en el paso 2 y desde los comprobantes en papel del mostrador.
Otros destinos de recuperación posibles, además de recovery_target_time:
| Parámetro | Detiene la recuperación en |
|---|---|
recovery_target_time |
Un instante |
recovery_target_xid |
Una transacción concreta (útil si se conoce del registro) |
recovery_target_lsn |
Una posición exacta del WAL |
recovery_target_name |
Un punto marcado antes con pg_create_restore_point('antes_migracion') |
Ese último es oro puro antes de una migración de esquema:
Herramientas de gestión
Hacer todo esto a mano es viable, pero en producción se usan herramientas que gestionan retención, verificación y catálogo de copias: pgBackRest, Barman y WAL-G son las tres habituales. Todas implementan el mismo mecanismo de fondo que acabamos de ver. Conocer el mecanismo es lo que permite entender la herramienta, y no al revés.
- La regla 3-2-1 y la verificación de las copias
La regla 3-2-1
3 copias de los datos · en 2 tipos de soporte distintos · con 1 copia fuera del emplazamiento.
Aplicada a BiblioRed:
| Elemento | Implementación |
|---|---|
| Copia 1 | Los datos en producción, en el servidor del ayuntamiento |
| Copia 2 | Copia diaria en el sistema de almacenamiento en red del ayuntamiento |
| Copia 3 | Copia cifrada en el almacenamiento del proveedor externo contratado |
| 2 soportes | Disco local del servidor + almacenamiento en red / almacenamiento externo |
| 1 fuera | La copia en el proveedor externo, en otra ciudad |
Añadidos modernos a la regla, que la experiencia con secuestro de datos ha hecho imprescindibles:
- 1 copia inmutable: almacenamiento que no admite modificación ni borrado durante un plazo. Sin esto, un atacante con acceso al servidor cifra o borra también las copias, que es exactamente lo que ocurre en los incidentes de secuestro de datos.
- 0 errores en la verificación: ninguna copia cuenta hasta que se ha restaurado con éxito.
La verificación: una copia no probada no es una copia
Este es el punto donde falla la mayoría de las organizaciones. El proceso de copia se configura, se ve que genera ficheros, y nadie restaura nunca hasta el día del desastre, que es el peor momento para descubrir que el fichero estaba truncado, que faltaban los roles, o que el proceso llevaba siete meses copiando una base de datos vacía.
Un guion de verificación automática, ejecutado semanalmente:
#!/bin/bash
# verificar_copia.sh — restaura la última copia y comprueba que tiene sentido
set -euo pipefail
COPIA=$(ls -t /copias/biblioredes_*.dump | head -1)
BD_PRUEBA="verificacion_$(date +%Y%m%d)"
echo "Verificando: $COPIA"
createdb "$BD_PRUEBA"
pg_restore -d "$BD_PRUEBA" -j 4 "$COPIA"
# Comprobaciones de contenido: no basta con que restaure sin error
SOCIOS=$(psql -tAc "SELECT count(*) FROM socios" "$BD_PRUEBA")
PRESTAMOS=$(psql -tAc "SELECT count(*) FROM prestamos" "$BD_PRUEBA")
ULTIMO=$(psql -tAc "SELECT max(fecha_prestamo) FROM prestamos" "$BD_PRUEBA")
echo "Socios: $SOCIOS | Préstamos: $PRESTAMOS | Último préstamo: $ULTIMO"
if [ "$SOCIOS" -lt 10000 ] || [ "$PRESTAMOS" -lt 2000000 ]; then
echo "ERROR: la copia no contiene los volúmenes esperados"
dropdb "$BD_PRUEBA"
exit 1
fi
# La fecha del último préstamo debe ser de ayer o de hoy
if [[ "$ULTIMO" < $(date -d 'yesterday' +%Y-%m-%d) ]]; then
echo "ERROR: la copia es antigua; el proceso puede estar detenido"
dropdb "$BD_PRUEBA"
exit 1
fi
dropdb "$BD_PRUEBA"
echo "Verificación correcta"Verificando: /copias/biblioredes_2026-08-02.dump Socios: 12000 | Préstamos: 2841077 | Último préstamo: 2026-08-01 Verificación correcta
Las comprobaciones de volumen y de fecha son el corazón del guion. Una copia que restaura sin errores pero contiene una base de datos vacía restaura perfectamente, y no sirve para nada. El error clásico —copiar la base de datos equivocada, o una que se dejó de usar— solo se detecta contando filas.
Y una vez al año, un simulacro completo: restaurar en un servidor distinto, arrancar la aplicación contra la copia restaurada, y cronometrar cuánto se tarda desde la llamada hasta el servicio funcionando. Ese cronómetro es el RTO real, y casi siempre es el triple del que se había estimado.
- RPO y RTO aplicados a BiblioRed
Dos siglas que ordenan cualquier conversación sobre continuidad, porque convierten "queremos estar seguros" en dos números que se pueden diseñar y presupuestar.
| Sigla | Nombre | Pregunta que responde | Se mide en |
|---|---|---|---|
| RPO | Recovery Point Objective | ¿Cuántos datos podemos permitirnos perder? | Tiempo de trabajo perdido |
| RTO | Recovery Time Objective | ¿Cuánto tiempo puede estar caído el servicio? | Tiempo de indisponibilidad |
Aplicados a las operaciones de BiblioRed:
| Operación | RPO tolerable | RTO tolerable | Por qué |
|---|---|---|---|
| Préstamos y devoluciones | 5 minutos | 2 horas | El mostrador puede apuntar en papel un rato, pero no puede perder préstamos: son libros que no se sabrá dónde están |
Cobro de multas (pagos) |
0 | 2 horas | Es dinero público. Un cobro perdido es una reclamación |
| Alta de socios | 1 hora | 4 horas | Se puede rehacer con el formulario en papel |
| Inscripciones a eventos | 1 hora | 4 horas | Molesto, recuperable |
| Estadísticas e informes | 24 horas | 3 días | No afecta al servicio |
De la tabla salen directamente las decisiones técnicas:
| Requisito | Consecuencia técnica |
|---|---|
| RPO de 5 minutos | Archivado del WAL con archive_timeout = 300. Una copia diaria sola daría un RPO de 24 horas |
| RPO de 0 en pagos | Réplica síncrona (synchronous_commit = remote_apply), o asumir que el papel del datáfono es el respaldo |
| RTO de 2 horas | Copia física verificada y un procedimiento escrito y ensayado. Restaurar 2,8 millones de filas con pg_restore puede tardar más de dos horas |
| RTO de 2 horas con margen | Réplica de reserva lista para promover (la replicación de 03-01), que reduce el RTO a minutos |
Fíjate en la lógica: primero se decide cuánto se puede perder y cuánto se puede esperar; después se elige la tecnología. Hacerlo al revés —montar la infraestructura y ver qué RPO sale— es como diseñar el esquema sin conocer el dominio.
- El plan de copias de BiblioRed, comentado
Con todo lo anterior, este es el plan concreto:
| Cuándo | Qué | Dónde | Retención | Cubre |
|---|---|---|---|---|
| Continuo | Archivado del WAL (archive_timeout = 300) |
Almacenamiento en red + copia externa | 35 días | RPO de 5 min; PITR |
| Diario 03:00 | pg_dump -F c completo |
Almacenamiento en red | 14 días | Restauración por tabla; migraciones |
| Diario 03:30 | pg_dumpall --globals-only |
Junto a la copia diaria | 14 días | Roles y contraseñas |
| Semanal, domingo 02:00 | pg_basebackup completo |
Almacenamiento en red + copia externa cifrada | 8 semanas | Base para PITR; RTO bajo |
| Mensual, día 1 | Copia completa | Almacenamiento externo inmutable | 12 meses | Secuestro de datos; auditoría |
| Semanal, lunes 06:00 | Verificación automática (guion del apartado 15) | Servidor de pruebas | Registro 12 meses | Que las copias sirvan |
| Anual | Simulacro completo de restauración | Servidor alternativo | Informe | Medir el RTO real |
Y las decisiones que lo justifican, una por una:
Por qué copia lógica y física. La física da el RTO bajo (restaurar es copiar ficheros) y es la única base posible para el PITR. La lógica permite restaurar una sola tabla —que es lo que hace falta el 90 % de las veces, porque el desastre típico no es un disco muerto, es un DELETE mal escrito— y es la única que sirve para migrar a una versión mayor distinta.
Por qué el WAL se retiene 35 días y la base semanal 8 semanas. El WAL solo sirve si existe la copia base correspondiente. Retener 35 días de WAL sin bases de más de 35 días de antigüedad sería inútil; al revés, retener bases sin su WAL impide el PITR. La retención del WAL debe cubrir con margen la de la copia base más antigua que se quiera usar para PITR.
Por qué una copia inmutable. Porque un atacante que compromete el servidor y tiene las credenciales de copia borrará o cifrará también las copias. La copia mensual inmutable es el último recurso, y su RPO de un mes es malísimo pero infinitamente mejor que nada.
Por qué la verificación es semanal y no mensual. Porque el fallo típico del proceso de copia es silencioso —un permiso que cambió, un disco lleno, una ruta que dejó de existir— y una semana es el tiempo máximo aceptable para detectarlo.
Por qué el simulacro anual, si ya hay verificación semanal. Porque son cosas distintas. La verificación comprueba que el fichero de copia es bueno; el simulacro comprueba que el procedimiento funciona con personas de por medio: que alguien sabe dónde está el documento, que las credenciales del almacenamiento externo siguen siendo válidas, que la persona que lo escribió no es la única que sabe hacerlo, y cuánto se tarda de verdad.
Qué falta en este plan. Conviene decirlo también: no hay réplica de reserva. Con el RTO de 2 horas del apartado 16 no es imprescindible, pero es la primera inversión que haría falta si la biblioteca decidiera bajar ese objetivo. La replicación primario-secundarios de 03-01 es exactamente esa pieza.
- Los primeros minutos tras un borrado accidental
El momento en que más daño se hace es el que sigue inmediatamente al error, cuando alguien intenta arreglarlo deprisa. Este es el procedimiento, y conviene tenerlo impreso.
Minuto 0 — Parar. No ejecutes nada más. No intentes "volver a insertar los datos". Cada escritura posterior complica la reconciliación y puede sobrescribir información recuperable.
Minuto 1 — ¿Está la transacción abierta todavía? Si el DELETE se ejecutó dentro de un BEGIN sin confirmar, la solución es un ROLLBACK y ya está (06-01). Comprobar quién tiene qué abierto:
SELECT pid, usename, state, now() - xact_start AS duracion, left(query, 60) AS consulta
FROM pg_stat_activity
WHERE state IN ('idle in transaction', 'active')
ORDER BY xact_start;pid | usename | state | duracion | consulta -------+----------+---------------------+-----------------+------------------------------------ 41902 | u_alsina | idle in transaction | 00:03:12.881021 | DELETE FROM socios
Ahí está: la transacción sigue abierta. Un ROLLBACK en esa sesión resuelve el incidente entero. Merece la pena comprobarlo antes que nada.
Minuto 2 — Aislar. Si ya está confirmado, impedir nuevas escrituras mientras se decide:
-- Cortar el acceso de la aplicación sin parar el servidor
REVOKE CONNECT ON DATABASE biblioredes FROM bibliored_app, bibliored_mostrador;
-- Cerrar las sesiones existentes de esos roles
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = 'biblioredes'
AND usename IN ('app_biblioredes','u_alsina','u_pereda');Minuto 3 — Determinar el alcance exacto. Qué tabla, cuántas filas, a qué hora, con qué usuario. La tabla de auditoría del apartado 10 responde a todo esto:
SELECT operacion, min(momento) AS desde, max(momento) AS hasta,
usuario_bd, count(*) AS filas
FROM auditoria_socios
WHERE momento > now() - INTERVAL '1 hour'
GROUP BY operacion, usuario_bd;operacion | desde | hasta | usuario_bd | filas -----------+-----------------------------+-----------------------------+------------+------- DELETE | 2026-08-02 11:47:02.118+02 | 2026-08-02 11:47:04.882+02 | u_alsina | 12000
Minuto 5 — Decidir la vía de recuperación:
| Situación | Vía |
|---|---|
| Transacción aún abierta | ROLLBACK |
Pocas filas y hay auditoría con datos_antes |
Reinsertar desde la tabla de auditoría |
| Una tabla completa, y la copia diaria vale | pg_restore -t socios en una base auxiliar, y copiar las filas |
| Alcance amplio, o hacen falta datos posteriores a la copia | PITR al instante anterior (apartado 14) |
La opción de la tabla de auditoría, cuando aplica, es la más quirúrgica:
INSERT INTO socios (socio_id, nombre, apellidos, email, fecha_alta, sucursal_id, activo)
SELECT (datos_antes ->> 'socio_id')::int,
datos_antes ->> 'nombre',
datos_antes ->> 'apellidos',
datos_antes ->> 'email',
(datos_antes ->> 'fecha_alta')::date,
(datos_antes ->> 'sucursal_id')::int,
(datos_antes ->> 'activo')::boolean
FROM auditoria_socios
WHERE operacion = 'DELETE'
AND momento BETWEEN '2026-08-02 11:47:00+02' AND '2026-08-02 11:47:10+02';Minuto 30 — Después. Restablecer los permisos, comprobar la coherencia con las consultas de control de 05-03, y escribir qué ha pasado. El informe posterior no busca culpables: busca por qué fue posible. Casi siempre la respuesta es alguna de estas tres: la conexión tenía más permisos de los necesarios, no había forma de distinguir el entorno de producción del de pruebas, o no existía procedimiento para operaciones masivas.
Tres medidas preventivas que salen de ahí:
-- 1) Que el mostrador no pueda borrar nada
REVOKE DELETE ON socios, prestamos, multas, pagos FROM bibliored_mostrador;
-- 2) Que el prompt de psql grite en producción
-- (en ~/.psqlrc del servidor de producción)
\set PROMPT1 '%[%033[1;31m%]PRODUCCIÓN%[%033[0m%] %/=# '
-- 3) Punto de restauración antes de cualquier operación masiva
SELECT pg_create_restore_point('antes_de_purga_historico');
- El equivalente mínimo en SQLite
Para cerrar, la versión reducida del mismo problema.
SQLite no tiene usuarios, ni roles, ni GRANT: los permisos son los del fichero en el sistema operativo. Quien puede leer el fichero, lee todo; quien puede escribirlo, escribe todo. Toda la primera mitad de esta lección no tiene equivalente, y esa es una razón importante para no usar SQLite en un sistema multiusuario con datos personales.
Las copias sí tienen equivalente, y es sencillo:
# CORRECTO: copia en caliente y coherente, con la base en uso
sqlite3 biblioredes.db ".backup '/copias/biblioredes_2026-08-02.db'"
# CORRECTO: volcado lógico, equivalente a pg_dump
sqlite3 biblioredes.db ".dump" > /copias/biblioredes_2026-08-02.sql
# CORRECTO: comprobar la integridad del fichero
sqlite3 biblioredes.db "PRAGMA integrity_check;"Y la advertencia central:
Copiar el fichero
.dbconcpmientras la base está en uso produce una copia corrupta, porque puede capturar el fichero a mitad de una transacción, y en modo WAL deja fuera el fichero-walcon los cambios recientes. Copiar el fichero directamente solo es seguro con la base cerrada y sin ninguna conexión abierta.
.backup sí es seguro en caliente: usa el mecanismo interno de copia de SQLite, que garantiza una imagen coherente. Es una diferencia de una línea que separa una copia válida de una inservible.
Errores Comunes y Consejos
Conectar la aplicación como superusuario. Anula toda la configuración de permisos y convierte cualquier inyección en un compromiso del servidor. Es el error más grave y el más fácil de corregir.
Dar SELECT sobre las tablas y olvidar USAGE sobre el esquema. El rol no verá nada y recibirás un informe de error confuso. Es el fallo número uno al configurar permisos por primera vez.
Usar GRANT ... ON ALL TABLES y creer que cubre el futuro. Solo afecta a las tablas existentes. Sin ALTER DEFAULT PRIVILEGES, la tabla que se cree mañana quedará inaccesible o —peor, según cómo esté montado— accesible a quien no debe.
Dejar trust en pg_hba.conf. Aunque sea "un momento, para probar". Ese momento dura tres años.
Usar sslmode=require y creer que es seguro. Cifra, pero no verifica con quién habla. Usa verify-full.
Escapar comillas a mano en lugar de parametrizar. Basta un sitio olvidado de doscientos. Las consultas parametrizadas no tienen ese modo de fallo.
Mostrar el error de la base de datos al usuario final. Regala el esquema. Registra el error completo internamente y muestra un mensaje genérico.
Copiar la base de producción al portátil de desarrollo sin anonimizar. Es la fuga de datos más común y la que menos se percibe como tal. Anonimiza como parte automática del proceso de restauración.
Activar log_statement = 'all' en una base con datos personales. Los correos y teléfonos acaban en ficheros de registro con menos protección y más copias que la base de datos.
Tener copias y no haber restaurado nunca. Es el error de esta lección. Una copia no probada no es una copia. Verifica semanalmente de forma automática y haz un simulacro completo al año.
No copiar los roles. pg_dump no los incluye. Sin pg_dumpall --globals-only, restaurarás los datos y no podrás entrar.
No vigilar pg_stat_archiver. Si archive_command falla, el WAL se acumula, el disco se llena y el servidor se detiene. failed_count debe estar en un panel.
Confundir enmascarar con anonimizar. m***@example.org sigue siendo un dato personal en un registro que contiene el nombre y la sucursal. Anonimizar es sustituir, no tapar.
Consejo final: escribe el procedimiento de recuperación y guárdalo fuera del sistema que protege. Un documento de restauración alojado únicamente en el servidor que se ha incendiado es un chiste que solo tiene gracia antes del incendio. En papel, en el almacenamiento externo, y con las credenciales necesarias custodiadas por al menos dos personas.
Ejercicios
Ejercicio 1: Un cuarto rol con mínimo privilegio
La concejalía de Cultura de Vallmar contrata a una empresa externa para analizar el uso de las bibliotecas durante seis meses. La empresa necesita: leer los préstamos, ejemplares, materiales, sucursales y eventos; no debe poder identificar a ningún socio; no debe poder escribir nada; y su acceso debe caducar automáticamente el 31 de diciembre de 2026.
Escribe el SQL completo: el rol, los permisos, cualquier vista que necesites, y la comprobación de que no puede llegar a los datos personales.
Ejercicio 2: Diagnosticar y corregir una configuración
Un técnico ha dejado esta configuración en el servidor de producción de BiblioRed. Identifica todos los problemas de seguridad, ordenados por gravedad, y escribe la corrección de cada uno.
CREATE ROLE app_biblioredes LOGIN PASSWORD 'bibliored2026' SUPERUSER;
GRANT ALL ON ALL TABLES IN SCHEMA public TO PUBLIC;Ejercicio 3: Diseñar el plan de copias de un caso nuevo
La red de bibliotecas de Vallmar añade un servicio de préstamo de instrumentos musicales, con su propia base de datos instrumentos. Sus características: 400 instrumentos, 900 usuarios, unas 30 operaciones al día, y los importes de las fianzas (hasta 300 € por instrumento) se registran en una tabla fianzas.
Define el RPO y el RTO de cada tipo de operación, justifícalos, y escribe el plan de copias que se deriva de ellos. Explica en qué se diferencia del plan de BiblioRed del apartado 17 y por qué.
Soluciones
Solución 1
-- =========================================================
-- 1) Vista sin ningún dato identificable de socios
-- =========================================================
CREATE VIEW v_prestamos_analitica AS
SELECT p.prestamo_id,
-- Seudónimo estable pero no invertible sin la sal
encode(digest(p.socio_id::text || current_setting('app.sal_analitica'), 'sha256'), 'hex')
AS socio_seudonimo,
p.fecha_prestamo,
p.fecha_devolucion_prevista,
p.fecha_devolucion,
e.sucursal_id,
e.material_id,
m.tipo AS tipo_material
FROM prestamos p
JOIN ejemplares e ON e.ejemplar_id = p.ejemplar_id
JOIN materiales m ON m.material_id = e.material_id;
-- =========================================================
-- 2) Rol de grupo con permisos mínimos
-- =========================================================
CREATE ROLE bibliored_analitica NOLOGIN;
GRANT CONNECT ON DATABASE biblioredes TO bibliored_analitica;
GRANT USAGE ON SCHEMA public TO bibliored_analitica;
GRANT SELECT ON v_prestamos_analitica,
ejemplares, materiales, sucursales,
v_eventos_publicados, tipos_evento, salas
TO bibliored_analitica;
-- Explícito y verificable: nada de lo sensible
REVOKE ALL ON socios, telefonos_socio, multas, pagos,
inscripciones, auditoria_socios, prestamos
FROM bibliored_analitica;
-- Que un objeto futuro no se le conceda por descuido
ALTER DEFAULT PRIVILEGES IN SCHEMA public
REVOKE ALL ON TABLES FROM bibliored_analitica;
-- =========================================================
-- 3) Rol de conexión con caducidad y límite
-- =========================================================
CREATE ROLE u_consultora LOGIN
PASSWORD 'contraseña-larga-generada-aleatoriamente'
VALID UNTIL '2026-12-31 23:59:59+01'
CONNECTION LIMIT 3;
GRANT bibliored_analitica TO u_consultora;Y en pg_hba.conf, restringiendo el origen y obligando a TLS:
Comprobación:
SET ROLE bibliored_analitica;
SELECT count(*) FROM v_prestamos_analitica; -- debe funcionar
SELECT email FROM socios LIMIT 1; -- debe fallar
SELECT * FROM prestamos LIMIT 1; -- debe fallar (socio_id en claro)
INSERT INTO prestamos (socio_id) VALUES (14); -- debe fallar
RESET ROLE;count --------- 2841077 ERROR: permission denied for table socios ERROR: permission denied for table prestamos ERROR: permission denied for table prestamos
Puntos clave de la solución:
- Se revoca
prestamosy se da acceso solo a la vista. DarSELECTsobreprestamoshabría entregadosocio_iden claro, que combinado con las fechas permite reidentificar a personas concretas con poco esfuerzo. VALID UNTILhace que el acceso caduque solo. Confiar en que alguien se acuerde de revocarlo en diciembre es confiar demasiado.CONNECTION LIMIT 3evita que una herramienta de análisis mal configurada abra doscientas conexiones y afecte al servicio del mostrador.hostsslcon la dirección concreta limita el acceso a la red de la consultora y obliga al cifrado.- Nota de compliance: el seudónimo hace más difícil la reidentificación, pero un conjunto con fechas, sucursal y materiales puede seguir siendo reidentificable por combinación. Que este tratamiento sea suficiente y con qué base jurídica se cede a un tercero es una cuestión que debe validar el delegado de protección de datos del ayuntamiento, no el equipo técnico.
Solución 2
Problema 1 (crítico) — SUPERUSER en la conexión de la aplicación.
Anula todos los permisos y todas las políticas de fila, y convierte cualquier inyección en ejecución de órdenes del sistema operativo mediante COPY ... FROM PROGRAM.
ALTER ROLE app_biblioredes NOSUPERUSER;
ALTER ROLE app_biblioredes CONNECTION LIMIT 40;
GRANT bibliored_app TO app_biblioredes;Problema 2 (crítico) — GRANT ALL ... TO PUBLIC.
PUBLIC incluye a todos los roles presentes y futuros. Cualquiera que consiga conectarse tiene control total sobre todas las tablas, incluido DELETE y TRUNCATE.
REVOKE ALL ON ALL TABLES IN SCHEMA public FROM PUBLIC;
REVOKE ALL ON SCHEMA public FROM PUBLIC;
REVOKE ALL ON DATABASE biblioredes FROM PUBLIC;
-- Y aplicar el script de roles del apartado 4Problema 3 (crítico) — Inyección SQL por concatenación.
sql = "SELECT socio_id, nombre, apellidos FROM socios WHERE apellidos = %s"
cursor.execute(sql, (apellidos,))Además, se elimina el SELECT *: la consulta debe pedir solo las columnas que usa, lo que evita exponer email por accidente y facilita el Index Only Scan de 06-03.
Problema 4 (crítico) — local all all trust.
Cualquier usuario del sistema operativo del servidor entra como cualquier rol, incluido postgres, sin contraseña.
Problema 5 (grave) — host all all 0.0.0.0/0.
La base de datos acepta conexiones desde cualquier lugar de internet, a cualquier base y con cualquier usuario.
hostssl biblioredes bibliored_mostrador 10.20.0.0/16 scram-sha-256 hostssl biblioredes app_biblioredes 10.20.5.11/32 scram-sha-256 host all all 0.0.0.0/0 reject
Problema 6 (grave) — md5 en lugar de scram-sha-256.
Método con debilidades conocidas. Se cambia el método y se reasignan las contraseñas, porque el cambio de método no reconvierte solo las existentes:
SET password_encryption = 'scram-sha-256';
ALTER ROLE app_biblioredes PASSWORD 'nueva-contraseña-larga';Problema 7 (grave) — Sin TLS.
Todas las líneas eran host, no hostssl: el tráfico viaja en claro. Corregido en el problema 5, más ssl = on en postgresql.conf.
Problema 8 (moderado) — Contraseña débil y previsible.
bibliored2026 se adivina en el primer intento de un ataque dirigido. Contraseña larga generada aleatoriamente, guardada en un gestor de secretos, nunca en el repositorio de código, y con rotación planificada.
Orden de intervención recomendado: primero cerrar pg_hba.conf (problemas 4, 5, 6 y 7), que detiene el acceso indebido de inmediato; después quitar SUPERUSER y PUBLIC (1 y 2), que limitan el daño; y en paralelo corregir el código (3), que requiere despliegue. Y en cuanto se cierre lo urgente: revisar los registros para determinar si esta configuración fue aprovechada, lo cual es un incidente que debe comunicarse por los cauces de la organización.
Solución 3
RPO y RTO por operación:
| Operación | RPO | RTO | Justificación |
|---|---|---|---|
Registro de fianzas (fianzas) |
0 | 4 horas | Es dinero de ciudadanos, hasta 300 € por instrumento. Una fianza perdida es una reclamación y un problema de intervención municipal |
| Préstamo y devolución de instrumentos | 1 hora | 8 horas | 30 operaciones al día: en una hora se pierden 2 o 3 operaciones, reconstruibles con el resguardo en papel |
| Alta de usuarios | 24 horas | 24 horas | Se rehace con el formulario de alta |
| Catálogo de instrumentos | 24 horas | 24 horas | Cambia muy poco |
Plan de copias derivado:
| Cuándo | Qué | Dónde | Retención |
|---|---|---|---|
| Continuo | Archivado del WAL, archive_timeout = 600 |
Almacenamiento en red + externo | 35 días |
| Diario 03:00 | pg_dump -F c completo |
Almacenamiento en red | 30 días |
| Diario 03:15 | pg_dumpall --globals-only |
Junto a la copia diaria | 30 días |
| Semanal | pg_basebackup |
Almacenamiento en red + externo cifrado | 8 semanas |
| Mensual | Copia completa | Almacenamiento externo inmutable | 12 meses |
| Semanal | Verificación automática con restauración | Servidor de pruebas | Registro 12 meses |
| Anual | Simulacro completo | Servidor alternativo | Informe |
Diferencias con el plan de BiblioRed y por qué:
-
archive_timeout = 600en lugar de 300. Con 30 operaciones al día, un segmento de WAL cada 10 minutos es más que suficiente y genera muchos menos ficheros que archivar y retener. El RPO efectivo de 10 minutos sigue siendo mejor que el requisito de 1 hora del préstamo. -
Retención de la copia lógica de 30 días en lugar de 14. La base es diminuta —900 usuarios y 400 instrumentos—, así que 30 copias diarias comprimidas ocupan unos pocos megabytes. Cuando el espacio no es una restricción, se retiene más, porque el error que más tarda en detectarse es la corrupción lógica silenciosa: alguien modificó mal unos datos hace tres semanas y nadie lo vio.
-
RTO más holgado (4-8 horas frente a 2). El servicio de instrumentos no es crítico para el funcionamiento diario de las bibliotecas y admite operar en papel durante una jornada. Eso hace innecesaria cualquier inversión en réplica de reserva.
-
El RPO de 0 en
fianzasno obliga aquí a réplica síncrona. A diferencia de los pagos de multas de BiblioRed —que llegan por datáfono y no dejan comprobante en la biblioteca—, la fianza de un instrumento se cobra con un resguardo en papel firmado por el usuario. Ese resguardo es el respaldo del RPO 0, y es mucho más barato que la infraestructura equivalente. Es un buen ejemplo de que un requisito de continuidad no siempre se resuelve con tecnología. -
El mismo régimen de verificación y simulacro. Este punto no se relaja por el tamaño de la base. La probabilidad de que un proceso de copias falle silenciosamente no depende de cuántas filas haya, y una base pequeña suele tener menos supervisión, no más. Es exactamente donde más falta hace la verificación automática.
Conclusión
Las dos preguntas de la concejalía de Vallmar ya tienen respuesta, y las dos son respuestas concretas.
¿Quién puede consultar los teléfonos y los correos de los socios? El rol bibliored_app, porque la aplicación web necesita enviar los avisos de vencimiento, y nadie más. El personal de mostrador puede modificar un correo pero no leerlo en un listado. La dirección accede a v_socios_publico, donde esas columnas no existen. La consultora externa trabaja sobre v_prestamos_analitica con seudónimos con sal. Y todo cambio sobre socios queda registrado en auditoria_socios con el rol, la dirección de origen y el valor anterior. La respuesta ya no es "no lo sé": es un script de GRANT que se puede leer, comprobar con has_column_privilege y auditar.
¿Qué pasaría si el disco muriera esta noche? Se restauraría el último pg_basebackup, se aplicaría el WAL archivado hasta el último segmento disponible, y el servicio volvería con una pérdida máxima de cinco minutos y en menos de dos horas. Y esa afirmación no es una esperanza: es un procedimiento escrito, verificado automáticamente cada lunes con un guion que cuenta filas y comprueba fechas, y ensayado entero una vez al año con cronómetro.
Por el camino hemos recorrido las capas completas. La autenticación, con roles que son a la vez usuarios y grupos, scram-sha-256 como método y ese pg_hba.conf que se lee de arriba abajo y debe terminar en reject. La autorización, con su jerarquía de base, esquema y tabla —y el USAGE que todo el mundo olvida—, los privilegios a nivel de columna, ALTER DEFAULT PRIVILEGES para los objetos que aún no existen, y el principio de mínimo privilegio convertido en tres roles concretos y comprobables. Las vistas para ocultar columnas y la seguridad a nivel de fila para ocultar filas, con la advertencia de que ni una ni otra protegen de un superusuario. La inyección SQL, con el buscador del catálogo devolviendo doce mil correos y la consulta parametrizada convirtiendo el mismo ataque en una búsqueda sin resultados, porque no escapa nada: separa los caminos. El cifrado en tránsito con verify-full y no con require, en reposo a nivel de disco, y las contraseñas que no se cifran sino que se derivan con sal y con coste. La auditoría con SECURITY DEFINER, que permite escribir en el registro sin poder borrarlo. Y los datos personales, con la minimización que es la única medida que no puede fallar, la seudonimización que sin sal no es seudonimización, la anonimización que debe ser un paso automático del proceso de restauración, y el recordatorio —que conviene repetir— de que estos son mecanismos técnicos y de que el cumplimiento normativo lo decide un profesional de compliance, no el equipo de desarrollo.
Y el bloque de copias, donde el WAL de 06-01 ha tenido su segunda vida: lo que en aquella lección era el mecanismo que garantizaba la durabilidad de un COMMIT frente a un corte de luz, aquí se ha convertido en la historia completa de las modificaciones que permite detener la recuperación justo antes de la transacción 90412, esa que borró doce mil socios a las 11:47:03. Hemos visto la copia lógica y la física con sus papeles distintos y complementarios, la regla 3-2-1 con sus añadidos modernos de inmutabilidad, el RPO y el RTO como los dos números que convierten "queremos estar seguros" en decisiones de ingeniería, un plan de copias comentado decisión por decisión, y el procedimiento de los primeros minutos, que empieza por lo más contraintuitivo y lo más eficaz: parar y comprobar si la transacción sigue abierta.
Con esto se cierra el módulo 6 y, con él, el bloque teórico del curso. Merece la pena mirar atrás el recorrido completo. Empezamos en el módulo 1 con una hoja de cálculo desbordada y abrimos el SGBD para ver sus piezas por dentro. En el 2 aprendimos el modelo relacional y SQL hasta los agregados y la integridad referencial. En el 3 salimos del mundo relacional para entender NoSQL, el teorema CAP y la consistencia eventual, y volvimos sabiendo qué se gana y qué se paga en cada lado. En el 4 diseñamos el esquema de BiblioRed desde el diagrama entidad-relación hasta el último CHECK. En el 5 lo sometimos a un examen formal, encontramos un fallo real en multas y justificamos por escrito cada desnormalización. Y en el 6 hemos dejado de mirar el esquema para mirar el sistema en funcionamiento: las transacciones que ocurren enteras o no ocurren, la concurrencia que rompe el código correcto en cuanto hay dos personas, los índices que convirtieron catorce segundos en cuarenta milisegundos, y la seguridad y las copias que separan una base de datos de un accidente esperando a ocurrir.
Lo que queda del curso ya no es teoría nueva: es hacerlo con las manos. El módulo 7, Ejercicios Prácticos, es donde todo lo anterior se convierte en destreza, y está organizado siguiendo el mismo recorrido: ejercicios de SQL sobre el esquema de BiblioRed que ya conoces palmo a palmo (07-01); ejercicios de diseño de esquemas, con dominios nuevos que tendrás que modelar desde cero (07-02); ejercicios de normalización, con tablas que esconden dependencias funcionales que ahora sabes detectar (07-03); y ejercicios de consultas avanzadas y transacciones, donde volverán las dos sesiones psql en paralelo, los niveles de aislamiento y los planes de ejecución de este módulo (07-04). Después, el módulo 8 recorrerá tres casos de estudio completos —relacional, no relacional y de persistencia políglota— y el módulo 9 reunirá los libros, cursos y herramientas con los que seguir por tu cuenta. Abre los dos terminales, ten el esquema de BiblioRed a mano, y nos vemos en el primer ejercicio.
Fundamentos de Bases de Datos
Módulo 1: Introducción a las Bases de Datos
- Conceptos Básicos de Bases de Datos
- Tipos de Bases de Datos
- Historia y Evolución de las Bases de Datos
- Sistemas Gestores de Bases de Datos y Arquitectura
Módulo 2: Bases de Datos Relacionales
- Modelo Relacional
- Lenguaje SQL
- Operaciones Básicas en SQL
- Consultas Multitabla: JOIN y Subconsultas
- Agregación y Agrupación de Datos
- Integridad Referencial
Módulo 3: Bases de Datos No Relacionales
- Introducción a NoSQL
- Tipos de Bases de Datos NoSQL
- Modelado de Datos en NoSQL
- Comparación entre Bases de Datos Relacionales y No Relacionales
Módulo 4: Diseño de Esquemas
- Principios de Diseño de Esquemas
- Diagramas Entidad-Relación (ER)
- Transformación de Diagramas ER a Esquemas Relacionales
- Tipos de Datos y Restricciones
Módulo 5: Normalización
Módulo 6: Transacciones, Rendimiento y Seguridad
- Transacciones y Propiedades ACID
- Concurrencia y Niveles de Aislamiento
- Índices y Optimización de Consultas
- Seguridad, Permisos y Copias de Seguridad
Módulo 7: Ejercicios Prácticos
- Ejercicios de SQL
- Ejercicios de Diseño de Esquemas
- Ejercicios de Normalización
- Ejercicios de Consultas Avanzadas y Transacciones
Módulo 8: Casos de Estudio
- Caso de Estudio: Base de Datos Relacional
- Caso de Estudio: Base de Datos No Relacional
- Caso de Estudio: Persistencia Políglota
