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

  1. Por qué la seguridad de la base de datos es distinta de la de la aplicación
  2. Autenticación: roles, contraseñas y pg_hba.conf
  3. Autorización: GRANT, REVOKE y el catálogo de privilegios
  4. Mínimo privilegio: los tres roles de BiblioRed
  5. El peligro de conectarse como superusuario
  6. Vistas para ocultar columnas sensibles
  7. Seguridad a nivel de fila: cada sucursal ve lo suyo
  8. Inyección SQL: cómo se produce y cómo se impide
  9. Cifrado en tránsito, en reposo y el caso especial de las contraseñas
  10. Auditoría: registro de accesos y tabla de cambios
  11. Datos personales: minimización, seudonimización y retención
  12. Copias de seguridad: lógicas y físicas
  13. Completa, diferencial e incremental
  14. Archivado del WAL y recuperación a un instante concreto
  15. La regla 3-2-1 y la verificación de las copias
  16. RPO y RTO aplicados a BiblioRed
  17. El plan de copias de BiblioRed, comentado
  18. Los primeros minutos tras un borrado accidental
  19. El equivalente mínimo en SQLite

  1. 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.

  1. Autenticación: roles, contraseñas y pg_hba.conf

Roles: 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;
CREATE ROLE
CREATE ROLE
GRANT ROLE

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:

\du
                             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ínea host all all 0.0.0.0/0 trust en 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:

SELECT line_number, type, database, user_name, address, auth_method
FROM pg_hba_file_rules;
 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

  1. Autorización: GRANT, REVOKE y el catálogo de privilegios

Autenticado 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:

SELECT nombre, email FROM socios WHERE socio_id = 14;
ERROR:  permission denied for table socios

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:

ALTER DEFAULT PRIVILEGES IN SCHEMA public
    GRANT SELECT ON TABLES TO bibliored_direccion;

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:

REVOKE CREATE ON SCHEMA public FROM PUBLIC;
REVOKE ALL ON DATABASE biblioredes FROM PUBLIC;

Inspeccionar los permisos concedidos:

\dp socios
 Esquema | Nombre |  Tipo  |                 Privilegios de acceso
---------+--------+--------+--------------------------------------------------------
 public  | socios | tabla  | bibliored_app=arwd/postgres                            +
         |        |        | bibliored_direccion=r/postgres                          +
         |        |        | bibliored_mostrador=r(socio_id,nombre,apellidos)/postgres

Las letras: r = SELECT, a = INSERT, w = UPDATE, d = DELETE, x = REFERENCES, U = USAGE.

  1. 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;
 puede_borrar_multas | puede_leer_email
---------------------+------------------
 f                   | f

  1. 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 postgres ni ningún rol con SUPERUSER.
  • 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:

SELECT rolname FROM pg_roles WHERE rolsuper;
 rolname
----------
 postgres

Uno. Si aparecen tres o cuatro, hay trabajo que hacer.

  1. 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:

SET ROLE bibliored_direccion;
SELECT * FROM v_socios_publico LIMIT 3;
 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 | t
SELECT email FROM socios LIMIT 1;
ERROR:  permission denied for table socios

SELECT * 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;
SELECT * FROM v_socios_contacto_enmascarado WHERE socio_id = 14;
 socio_id | nombre | apellidos | email_enmascarado  | sucursal_id
----------+--------+-----------+--------------------+-------------
       14 | Marta  | Alsina    | m***@example.org   |           1

Una 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.

  1. 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 WHERE implícito y obligatorio, aplicado por el gestor a toda consulta de los roles afectados.

Paso 1: activar RLS

ALTER TABLE prestamos ENABLE ROW LEVEL SECURITY;

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:

SET app.sucursal_actual = '2';

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

SET ROLE bibliored_mostrador;
SET app.sucursal_actual = '2';

SELECT count(*) FROM socios;
 count
-------
  2914
SET app.sucursal_actual = '1';
SELECT count(*) FROM socios;
 count
-------
  4102

La misma consulta, el mismo rol, resultados distintos: el gestor está aplicando el filtro por su cuenta. Y es imposible saltárselo desde SQL:

SELECT count(*) FROM socios WHERE sucursal_id = 3;
 count
-------
     0

Las tres advertencias sobre RLS

  1. 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;.
  2. 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 prestamos pueden cambiar el plan de ejecución. Compruébalo con EXPLAIN ANALYZE (06-03).
  3. 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.

  1. 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:

SELECT material_id, titulo FROM materiales WHERE titulo LIKE '%mapa%'

Ahora un visitante escribe en el buscador:

' UNION SELECT socio_id, email FROM socios --

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:

 material_id | titulo
-------------+--------
(0 rows)

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');
 material_id |       titulo
-------------+---------------------
         907 | El mapa del tiempo
EXECUTE buscar_material (''' UNION SELECT socio_id, email FROM socios --');
 material_id | titulo
-------------+--------
(0 rows)

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 exist es 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.

  1. 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:

psql "host=db.bibliored.vallmar.example dbname=biblioredes user=u_direccion sslmode=verify-full"

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 No No
verify-ca No
verify-full

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:

SELECT ssl, version, cipher FROM pg_stat_ssl WHERE pid = pg_backend_pid();
 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);
 socio_id
----------
       14

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.

  1. 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 DEFINER hace 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_user identifica 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.
  • JSONB para el antes y el después evita tener que rehacer la tabla de auditoría cada vez que cambia el esquema de socios. Es un uso legítimo de jsonb de 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.

  1. 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;
BEGIN
UPDATE 12000
UPDATE 14118
TRUNCATE TABLE
COMMIT

Tres reglas para que una anonimización sirva de algo:

  1. Se ejecuta como parte del proceso de restauración, automáticamente, nunca a mano. Un paso manual se olvidará.
  2. 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.
  3. 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.

  1. 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.dump

En 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:

pg_dumpall --globals-only -f /copias/globales_2026-08-02.sql

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.

pg_basebackup -h localhost -U replicador \
              -D /copias/base_2026-08-02 \
              -F t -z -P -X stream
1048576/1048576 kB (100%), 1/1 tablespace
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 , 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.

  1. 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.)

  1. 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:

DELETE FROM socios;
DELETE 12000

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 postgresql

En 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.

SELECT count(*) FROM socios;
 count
-------
 12000

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:

SELECT pg_create_restore_point('antes_migracion_v14');
 pg_create_restore_point
-------------------------
 0/22F1A8C0

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.

  1. 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.

  1. 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.

  1. 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.

  1. 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';
INSERT 0 12000

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');

  1. 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;"
ok

Y la advertencia central:

Copiar el fichero .db con cp mientras 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 -wal con 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.

# pg_hba.conf
local   all   all                    trust
host    all   all   0.0.0.0/0        md5
CREATE ROLE app_biblioredes LOGIN PASSWORD 'bibliored2026' SUPERUSER;
GRANT ALL ON ALL TABLES IN SCHEMA public TO PUBLIC;
sql = "SELECT * FROM socios WHERE apellidos = '" + apellidos + "'"
cursor.execute(sql)

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:

hostssl   biblioredes   u_consultora   203.0.113.44/32   scram-sha-256

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 prestamos y se da acceso solo a la vista. Dar SELECT sobre prestamos habría entregado socio_id en claro, que combinado con las fechas permite reidentificar a personas concretas con poco esfuerzo.
  • VALID UNTIL hace que el acceso caduque solo. Confiar en que alguien se acuerde de revocarlo en diciembre es confiar demasiado.
  • CONNECTION LIMIT 3 evita que una herramienta de análisis mal configurada abra doscientas conexiones y afecte al servicio del mostrador.
  • hostssl con 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 4

Problema 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.

local   all   postgres   peer
local   all   all        scram-sha-256

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é:

  1. archive_timeout = 600 en 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.

  2. 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.

  3. 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.

  4. El RPO de 0 en fianzas no 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.

  5. 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

Módulo 2: Bases de Datos Relacionales

Módulo 3: Bases de Datos No Relacionales

Módulo 4: Diseño de Esquemas

Módulo 5: Normalización

Módulo 6: Transacciones, Rendimiento y Seguridad

Módulo 7: Ejercicios Prácticos

Módulo 8: Casos de Estudio

Módulo 9: Recursos Adicionales

© Copyright 2026. Todos los derechos reservados