Módulo 6 · Lección 11

Federación con Active Directory

En una empresa, las personas ya existen: están en el Active Directory, con su contraseña de Windows y sus grupos. Aquí conectas Keycloak a un AD para que sofia y diego entren en la tienda con esa cuenta. Lo interesante es lo poco que cambia el código Go, y por qué.

≈ 60 min Carpeta: tienda/pasos/paso-11 AD de prueba: Samba en Docker Realm nuevo: hay que reimportar

En este capítulo

Trabajas en
Keycloak conectado a un Active Directory de prueba, y admin-tool, que aprende a tratar usuarios de AD. tienda-web, api-pedidos y facturacion no cambian.
Carpeta
tienda/pasos/paso-11 · realm nuevo
Archivos
Nuevos: infra/ad/ (AD de prueba) e infra/docker-compose.ldaps.yml; cambian infra/docker-compose.yml, el realm y internal/admin/admin.go · ver todos los cambios del paso.
En marcha
Keycloak y el AD del paso 11 (los levanta el mismo docker compose up -d), api, web y facturacion.
Comprueba
go -C tools/comprobar run . -paso 11 -servicios (desde course/)

Al terminar sabrás

  • Qué hace Keycloak con un directorio LDAP o AD (User Federation) y por qué tus servicios Go no se enteran.
  • Configurar el proveedor y sus mappers: atributos de AD, grupos de AD como roles de realm.
  • Quién manda en cada dato (AD o Keycloak): altas, grupos, contraseñas, bajas.
  • Adaptar admin-tool a usuarios que no son de Keycloak y pasar a LDAPS.
No necesitas un Active Directory

El paso trae un controlador de dominio Samba AD DC en Docker: implementa el mismo LDAP que un AD de Windows (sAMAccountName, memberOf, userAccountControl, objectGUID…), así que la configuración de Keycloak es la que usarías contra uno real. Funciona en Windows, macOS (también Apple Silicon) y Linux.

1. La idea: Keycloak habla con el AD; tú hablas con Keycloak

El navegador envía la contraseña a Keycloak, que la comprueba contra Active Directory; los servicios Go solo reciben tokens Navegador sofia Keycloak realm tienda + proveedor LDAP Active Directory tienda.local usuarios y grupos usuario + contraseña busca y hace bind datos y grupos LDAP 389 · LDAPS 636 solo Keycloak le habla tokens OIDC: los mismos claims de siempre tus servicios Go: no cambian tienda-web api-pedidos facturacion
El AD queda detrás de Keycloak. Para tus servicios, un usuario de AD es un usuario más.

Cuando sofia escribe su usuario y contraseña en la página de login de Keycloak:

  1. Keycloak busca sofia en el AD con una cuenta de servicio de solo lectura (svc-keycloak).
  2. Comprueba la contraseña haciendo un bind LDAP como sofia: si el AD lo acepta, la contraseña es buena. Keycloak no la guarda.
  3. Lee sus atributos (nombre, correo) y sus grupos (memberOf), los convierte en datos y roles de Keycloak, y emite los tokens de siempre.

Por eso tienda-web, api-pedidos y facturacion funcionan sin tocar una línea. Solo cambia la herramienta que administra usuarios, porque ahora algunos usuarios no son de Keycloak:

Componente¿Cambia?Por qué
tienda-webNoSigue haciendo Authorization Code + PKCE contra Keycloak. El formulario de login es de Keycloak.
api-pedidosNoValida el JWT con JWKS. Los roles llegan en realm_access, vengan de donde vengan.
facturacionNoClient Credentials y Token Exchange no dependen del origen del usuario.
admin-toolSí, pocoDebe distinguir usuarios locales y de AD, y no intentar cambiar en Keycloak lo que manda el AD (sección 6).

Hay tres maneras de conectar Keycloak con un directorio de Microsoft. Esta lección cubre la primera:

FormaPara quéCómo
User Federation LDAP (esta lección)AD de la empresa (en sus servidores)Keycloak consulta el AD por LDAP. El login lo pinta Keycloak.
Kerberos / SPNEGOEntrar sin escribir la contraseña desde un PC del dominioSe añade al proveedor LDAP. Requiere un keytab y configurar el navegador.
Identity brokering con Microsoft Entra ID (o ADFS)Cuentas de Microsoft 365 / AzureKeycloak delega el login en Microsoft por OIDC o SAML. No hay LDAP.
cd tienda/pasos/paso-10/infra && docker compose down -v
cd ../../paso-11/infra && docker compose up -d
docker compose logs -f ad-seed     # espera a ver «AD de prueba listo» (Ctrl+C para salir)

La primera vez tarda alrededor de un minuto: Samba crea el dominio tienda.local, ad-seed lo llena y solo entonces arranca Keycloak (que importa el realm con el proveedor ya configurado). El docker-compose.yml añade dos servicios a los de siempre:

# Keycloak + PostgreSQL + un «Active Directory» de prueba (Samba AD DC) para
# el curso "Go + Keycloak". Solo para desarrollo.
#
#   docker compose up -d          # arrancar (la primera vez tarda: crea el dominio)
#   docker compose logs -f ad-seed
#   docker compose down -v        # parar y BORRAR datos (dominio y realm incluidos)

services:
  # Controlador de dominio tienda.local (TIENDA\…). Samba implementa el LDAP
  # de Active Directory: grupos, sAMAccountName, memberOf, userAccountControl…
  ad:
    image: instantlinux/samba-dc:4.23.10-r0
    hostname: dc1
    cap_add: [SYS_ADMIN]            # Samba guarda atributos extendidos del sistema de archivos
    environment:
      REALM: tienda.local
      WORKGROUP: TIENDA
      NETBIOS_NAME: DC1
      DOMAIN_ACTION: provision      # crea el dominio la primera vez
      BIND_INTERFACES_ONLY: "no"
    secrets:
      - samba-admin-password
    volumes:
      - ad-etc:/etc/samba
      - ad-lib:/var/lib/samba
      - ./ad/0globals.conf:/etc/samba/conf.d/0globals.conf:ro

  # Llena el AD (unidad Tienda, grupos, usuarios) y termina. Es idempotente.
  ad-seed:
    image: instantlinux/samba-dc:4.23.10-r0
    entrypoint: ["sh", "/seed.sh"]
    environment:
      AD_ADMIN_PASSWORD_FILE: /run/secrets/samba-admin-password
    secrets:
      - samba-admin-password
    volumes:
      - ./ad/seed.sh:/seed.sh:ro
    depends_on:
      - ad

  postgres:
    image: postgres:17
    environment:
      POSTGRES_DB: keycloak
      POSTGRES_USER: keycloak
      POSTGRES_PASSWORD: keycloak
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U keycloak -d keycloak"]
      interval: 5s
      timeout: 3s
      retries: 10

  keycloak:
    image: quay.io/keycloak/keycloak:26.8.0
    # --import-realm importa cada .json de /opt/keycloak/data/import
    # (si el realm ya existe, la importación se omite).
    command: start-dev --import-realm
    environment:
      KC_BOOTSTRAP_ADMIN_USERNAME: admin
      KC_BOOTSTRAP_ADMIN_PASSWORD: admin
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
      KC_DB_USERNAME: keycloak
      KC_DB_PASSWORD: keycloak
      KC_HEALTH_ENABLED: "true"
    ports:
      - "127.0.0.1:8080:8080"   # consola y endpoints OIDC
      - "127.0.0.1:9000:9000"   # puerto de gestión: /health/ready
    volumes:
      - ./realm:/opt/keycloak/data/import
    # «host.docker.internal» = tu máquina, vista desde el contenedor. Docker
    # Desktop (Windows, macOS) ya lo define; esta línea lo añade en Linux.
    # Lo usa el back-channel logout (lección 4) para llamar a tienda-web.
    extra_hosts:
      - "host.docker.internal:host-gateway"
    depends_on:
      postgres:
        condition: service_healthy
      ad-seed:
        condition: service_completed_successfully

secrets:
  samba-admin-password:
    file: ./ad/admin-password      # contraseña del administrador del dominio (solo desarrollo)

volumes:
  pgdata:
  ad-etc:
  ad-lib:

ad-seed usa samba-tool, la herramienta de administración de Samba, para crear lo que un equipo de TI ya tendría hecho en un AD real:

#!/bin/sh
# Llena el «Active Directory» de prueba (Samba AD DC) con lo que usa la
# tienda. Es idempotente: si algo ya existe, lo deja como está.
set -e
AD_ADMIN_PASSWORD=$(cat "$AD_ADMIN_PASSWORD_FILE")
AUTH="-H ldap://ad -U TIENDA\\Administrator%${AD_ADMIN_PASSWORD}"

echo "esperando al controlador de dominio..."
until samba-tool ou list $AUTH >/dev/null 2>&1; do sleep 3; done

ou()     { samba-tool ou list $AUTH | grep -qx "$1" || samba-tool ou create "$1,DC=tienda,DC=local" $AUTH; }
group()  { samba-tool group show "$1" $AUTH >/dev/null 2>&1 || samba-tool group add "$1" --groupou="OU=Tienda" $AUTH; }
# user <login> <contraseña> <nombre> <apellido> <ou>
user() {
  if ! samba-tool user show "$1" $AUTH >/dev/null 2>&1; then
    samba-tool user create "$1" "$2" --use-username-as-cn --given-name="$3" --surname="$4" \
      --mail-address="$1@tienda.local" --userou="$5" $AUTH
  fi
  samba-tool user setexpiry "$1" --noexpiry $AUTH >/dev/null
}
member() { samba-tool group addmembers "$1" "$2" $AUTH 2>/dev/null || true; }

ou "OU=Tienda"
# Cuenta de servicio con la que Keycloak lee el directorio (solo lectura).
user svc-keycloak "Keycloak-Lectura-2026!" Keycloak Servicio "CN=Users"
# Grupos con el mismo nombre que los roles de realm de Keycloak.
group cliente
group admin
user sofia "Sofia-Tienda-2026!" Sofia Ventas "OU=Tienda"
user diego "Diego-Tienda-2026!" Diego Soporte "OU=Tienda"
member cliente sofia,diego
member admin diego
echo "AD de prueba listo: sofia (cliente), diego (cliente, admin)"
CuentaContraseñaGrupos de ADPara qué
sofiaSofia-Tienda-2026!clienteUna clienta, como ana.
diegoDiego-Tienda-2026!cliente, adminUn administrador, como carlos.
svc-keycloakKeycloak-Lectura-2026!(ninguno)La cuenta con la que Keycloak lee el directorio.

Los usuarios locales ana y carlos siguen existiendo: un realm puede mezclar usuarios propios y de uno o varios directorios.

Una concesión solo para desarrollo

Un AD rechaza por defecto los bind con contraseña en claro por el puerto 389 (en Samba: «Strong(er) authentication required»). ad/0globals.conf lo permite para que el primer contacto sea simple. En la sección 7 pasas a LDAPS, que es lo que usarás contra un AD real.

3. El proveedor LDAP en Keycloak

El realm del paso trae el proveedor ya creado (en tools/realm/tienda-11.json se ve como un component de tipo UserStorageProvider). Míralo en la consola: User federation → active-directory. Para crearlo a mano: Add Ldap providers, Vendor = Active Directory (rellena los atributos típicos de AD) y los datos de la tabla.

Conexión del proveedor LDAP: Connection URL ldap://ad:389 y Bind DN de svc-keycloak
Dónde está el AD y con qué cuenta se conecta Keycloak.
Búsqueda: Edit mode READ_ONLY, Users DN OU=Tienda, Username LDAP attribute sAMAccountName
Dónde buscar usuarios y quién manda: el AD.
OpciónValorQué significa
Connection URLldap://ad:389El servicio ad de Compose. En producción, ldaps://…:636.
Bind DN / Bind credentialsCN=svc-keycloak,CN=Users,DC=tienda,DC=localLa cuenta de servicio. Solo necesita leer el directorio.
Edit modeREAD_ONLYKeycloak nunca escribe en el AD: contraseñas, datos y grupos se cambian allí.
Users DNOU=Tienda,DC=tienda,DC=localDónde buscar. Solo esta unidad: svc-keycloak y el Administrator, que están en CN=Users, no pueden entrar en la tienda.
Username LDAP attributesAMAccountNameEl «usuario» de Windows (TIENDA\sofia → sofia).
UUID LDAP attributeobjectGUIDEl identificador inmutable del AD. Keycloak lo guarda en el atributo LDAP_ID del usuario.
Import usersOnKeycloak guarda una copia local del usuario (con su propio ID) la primera vez que lo ve.
Sync registrationsOffLos usuarios que se registren en Keycloak no se crean en el AD.
Cache policyNO_CACHEConsulta el AD en cada login: los cambios de grupos se notan al momento (sección 5).
Trust emailOnEl correo del AD se da por verificado (email_verified: true).

Test connection y Test authentication (bajo la contraseña) son lo primero que debes probar con un AD nuevo: el primero comprueba red y certificado; el segundo, la cuenta de servicio.

Los mappers: de atributos de AD a datos de Keycloak

La pestaña Mappers dice qué atributo del AD alimenta cada dato de Keycloak. Al elegir el vendor Active Directory, Keycloak crea casi todos; el realm cambia dos cosas: sustituye «full name» (que esperaría el nombre completo en cn) por first name desde givenName, y añade el mapper de roles.

Lista de mappers del proveedor LDAP con roles desde grupos de AD remarcado
User federation → active-directory → Mappers.
MapperAtributo de AD → Keycloak
usernamesAMAccountName → usuario
first name · last name · emailgivenName, sn, mail → nombre, apellido, correo (y de ahí los claims name y email)
MSAD account controlsuserAccountControl → si la cuenta está deshabilitada en AD, Keycloak no la deja entrar
roles desde grupos de ADgrupos cliente y admin → roles de realm con el mismo nombre
creation date · modify date · Kerberos principalMetadatos; el de Kerberos solo importa si activas SPNEGO

El mapper de roles es el que conecta el AD con la autorización de la lección 6: api-pedidos exige el rol de realm admin en /admin/pedidos, y ahora diego lo obtiene por estar en el grupo admin del AD.

Detalle del mapper role-ldap-mapper: LDAP Filter con cliente y admin, Mode LDAP_ONLY
El filtro elige qué grupos importan; el modo, quién puede cambiar la asignación.
OpciónValorPor qué
LDAP Roles DNOU=Tienda,DC=tienda,DC=localDónde están los grupos.
LDAP Filter(|(cn=cliente)(cn=admin))Un AD real tiene cientos de grupos («Todos-Madrid», «VPN»…). Sin filtro, cada uno se convertiría en un rol de realm.
User Roles Retrieve StrategyGET_ROLES_FROM_USER_MEMBEROF_ATTRIBUTELee los grupos del atributo memberOf del usuario: una sola consulta, lo habitual en AD.
Use Realm Roles MappingOnLos grupos se convierten en roles de realm (no de un client).
ModeLDAP_ONLYLos roles de un usuario de AD son solo los de sus grupos. Nadie puede añadirle uno desde Keycloak.
READ_ONLY no es lo que parece

Probado con el modo READ_ONLY del mapper: Keycloak no escribe en el AD, pero sí permite asignar roles locales a un usuario de AD. Un administrador de Keycloak pudo dar admin a sofia aunque no está en ese grupo del AD. Si la política es «los permisos se gestionan en el AD», usa LDAP_ONLY: entonces Keycloak responde 400 Could not add user role mappings!.

4. Probarlo: usuarios de AD en la tienda

Arranca los tres servicios del paso como siempre (go run ./cmd/api, ./cmd/facturacion y ./cmd/web, cada uno en su terminal) y entra en http://localhost:3000 como sofia / Sofia-Tienda-2026!. Compra algo: funciona igual que con ana. Su access token, decodificado (salida real):

{
  "sub": "33e38e06-2ad2-4f5a-864c-ea12177327ea",
  "preferred_username": "sofia",
  "name": "Sofia Ventas",
  "email": "sofia@tienda.local",
  "email_verified": true,
  "realm_access": { "roles": ["cliente"] },
  "aud": ["api-pedidos", "facturacion"]
}

Nada en el token dice «Active Directory»: es exactamente la forma que espera api-pedidos. Sal y entra como diego / Diego-Tienda-2026!: ve el enlace Admin y la lista de todos los pedidos, porque su grupo de AD le da el rol admin. Sofia, en cambio, recibe el 403 de la lección 6 en /admin.

Página Todos los pedidos de tienda-web con diego conectado; el pedido 1004 es de sofia
diego, del grupo admin del AD, en /admin. El pedido #1004 es de sofia: su «cliente» es el sub que le dio Keycloak.

En la consola, Users aparece vacío con un aviso: con un directorio federado, Keycloak no lista todo (podrían ser miles de cuentas). Busca * para verlos todos, o un nombre:

Lista de usuarios buscando asterisco: ana y carlos locales, diego y sofia de AD
Usuarios locales y de AD, juntos.
Detalle de sofia con Federation link active-directory
Federation link: de qué directorio viene.
El sub lo pone Keycloak, no el AD

El sub de sofia es el ID de su copia local en Keycloak, creado cuando se importó. El identificador del AD (objectGUID) queda en el atributo LDAP_ID. Tus servicios guardan el sub (los pedidos lo usan como dueño), y eso tiene una consecuencia que verás en la sección 5.

5. Quién manda en cada dato

Con READ_ONLY y LDAP_ONLY, el AD es la fuente de verdad de los usuarios que vienen de él. Todo lo de esta tabla está probado con el paso 11:

QuéDónde se haceQué pasa en Keycloak
Dar de altaADSe importa la primera vez que entra, que alguien lo busca o con una sincronización.
Cambiar de grupoADCon NO_CACHE, el siguiente token (login o renovación) ya trae los roles nuevos. Con la caché por defecto, no: siguió con admin hasta vaciar la caché (Action → Clear user cache).
Cambiar la contraseñaADVale la nueva. La antigua siguió funcionando un rato: es un periodo de gracia del propio AD, no de Keycloak. «Reset password» en Keycloak responde 400 Can't reset password as account is read only.
DeshabilitarADLogin: «Invalid username or password». Refresh de una sesión abierta: invalid_grant «User disabled». El access token ya emitido sigue valiendo hasta que caduca (5 min): la API respondió 200.
BorrarADLa copia local desaparece la siguiente vez que Keycloak busca a ese usuario.
AD caído—Los usuarios de AD no pueden entrar (StorageUnavailableException en el log). Los locales (ana, carlos) sí.
No borres en Keycloak a un usuario de AD

Probado: borrar a sofia en la consola no toca el AD (es de solo lectura). La siguiente vez que entra, Keycloak la importa de nuevo con otro ID, y su sub pasa de 33e38e06-… a 8b2bd3fa-…. Para api-pedidos es otra persona: GET /pedidos devuelve {"pedidos":[]} y el #1004 queda huérfano. Si necesitas que alguien no entre, deshabilítalo en el AD.

Para cambios en AD que deben notarse ya (alguien sale de la empresa), deshabilitar no basta para las sesiones abiertas: combínalo con cerrar sus sesiones (admin expulsar, lección 9). El access token vivo aún tiene hasta 5 minutos; es el precio de que la API valide tokens sin preguntar a nadie (lección 5).

6. admin-tool ante usuarios de AD

La CLI de la lección 9 funcionaba, pero mentía: baja de un usuario de AD «funcionaba» sin efecto, y rol devolvía un 400 críptico. Tres cambios en internal/admin:

  1. Saber el origen. Un usuario federado trae federationLink (el ID del proveedor). La columna ORIGEN de usuarios sale de ahí.
  2. Decir dónde se hace cada cosa. Un error ErrFederated que explica que ese cambio se hace en el AD.
  3. Sincronizar. Un comando que pide a Keycloak importar y actualizar todos los usuarios del AD.
// origin dice de dónde viene un usuario: "AD" si está federado, "local" si no.
func origin(u *gocloak.User) string {
	if gocloak.PString(u.FederationLink) != "" {
		return "AD"
	}
	return "local"
}
// SetRole asigna (add=true) o quita un rol de realm a un usuario.
// En usuarios de AD, los roles que vienen de grupos de AD (mapper LDAP_ONLY)
// no se pueden cambiar aquí: Keycloak responde 400 y lo explicamos.
func (a *Admin) SetRole(ctx context.Context, username, role string, add bool) error {
	u, err := a.findUser(ctx, username)
	if err != nil {
		return err
	}
	id := gocloak.PString(u.ID)
	r, err := a.kc.GetRealmRole(ctx, a.token, a.realm, role)
	if err != nil {
		return explain(err)
	}
	if add {
		err = a.kc.AddRealmRoleToUser(ctx, a.token, a.realm, id, []gocloak.Role{*r})
	} else {
		err = a.kc.DeleteRealmRoleFromUser(ctx, a.token, a.realm, id, []gocloak.Role{*r})
	}
	var apiErr *gocloak.APIError
	if errors.As(err, &apiErr) && apiErr.Code == http.StatusBadRequest && origin(u) == "AD" {
		return fmt.Errorf("%w: el rol %s sale de los grupos de AD; cambia la pertenencia al grupo en AD (%v)", ErrFederated, role, err)
	}
	return explain(err)
}

Keycloak responde un 400 genérico; la función lo traduce solo cuando sabe la causa (usuario de AD). Con un usuario local, el error sigue su camino normal.

// Disable deshabilita a un usuario: no podrá iniciar sesión ni renovar tokens.
// Es preferible a borrarlo: se conserva su historial (y su «sub»).
// Los usuarios de AD se deshabilitan en AD: con el proveedor en solo lectura,
// Keycloak ignoraría el cambio sin dar error, así que ni lo intentamos.
func (a *Admin) Disable(ctx context.Context, username string) error {
	u, err := a.findUser(ctx, username)
	if err != nil {
		return err
	}
	if origin(u) == "AD" {
		return fmt.Errorf("%w: deshabilita a %s en AD (Keycloak lo verá en su siguiente login)", ErrFederated, username)
	}
	return explain(a.kc.UpdateUser(ctx, a.token, a.realm, gocloak.User{ID: u.ID, Enabled: gocloak.BoolP(false)}))
}

Aquí no hay error que traducir: con el proveedor en solo lectura, Keycloak responde 204 a la actualización, pero diego sigue habilitado y entra sin problema (probado). Por eso la función se niega antes de llamar.

// Sync lanza una sincronización completa de cada directorio (LDAP/AD)
// federado en el realm: importa a Keycloak los usuarios nuevos de AD y
// actualiza los existentes. gocloak no tiene una función para esto, así que
// usamos su cliente HTTP autenticado para llamar al endpoint directamente.
func (a *Admin) Sync(ctx context.Context) ([]SyncResult, error) {
	providers, err := a.kc.GetComponentsWithParams(ctx, a.token, a.realm, gocloak.GetComponentsParams{
		ProviderType: gocloak.StringP("org.keycloak.storage.UserStorageProvider"),
	})
	if err != nil {
		return nil, explain(err)
	}
	var out []SyncResult
	for _, p := range providers {
		var res SyncResult
		url := fmt.Sprintf("%s/admin/realms/%s/user-storage/%s/sync", a.baseURL, a.realm, gocloak.PString(p.ID))
		resp, err := a.kc.GetRequestWithBearerAuth(ctx, a.token).
			SetQueryParam("action", "triggerFullSync").
			SetResult(&res).
			Post(url)
		if err != nil {
			return out, err
		}
		if resp.IsError() {
			return out, fmt.Errorf("sincronizar %s: %s", gocloak.PString(p.Name), resp.Status())
		}
		res.Provider = gocloak.PString(p.Name)
		out = append(out, res)
	}
	return out, nil
}

gocloak no cubre todos los endpoints de la Admin API. Para los que faltan, GetRequestWithBearerAuth te da su cliente HTTP (resty) ya autenticado: solo pones la URL y el tipo del resultado.

Salida real:

cd tienda/pasos/paso-11
go run ./cmd/admin usuarios
# USUARIO  ORIGEN  NOMBRE         EMAIL               ACTIVO  ROLES          GRUPOS
# ana      local   Ana Cliente    ana@tienda.test     true    cliente
# carlos   local   Carlos Admin   carlos@tienda.test  true    admin,cliente
# diego    AD      Diego Soporte  diego@tienda.local  true    admin,cliente
# sofia    AD      Sofia Ventas   sofia@tienda.local  true    cliente

go run ./cmd/admin rol -usuario sofia -rol admin
# el usuario viene de Active Directory: el rol admin sale de los grupos de AD; cambia la
# pertenencia al grupo en AD (400 Bad Request: invalid_request: Could not add user role mappings!)

go run ./cmd/admin baja -usuario sofia
# el usuario viene de Active Directory: deshabilita a sofia en AD (Keycloak lo verá en su siguiente login)

go run ./cmd/admin sincronizar
# active-directory: 0 nuevos, 2 actualizados, 0 eliminados, 0 fallidos

go run ./cmd/admin rol -usuario ana -rol admin      # ana es local: funciona como antes
# rol admin asignado a ana

La columna ROLES muestra los roles de diego aunque no están asignados en Keycloak: el mapper los calcula desde el AD cada vez que alguien los pide.

7. LDAPS: cifrar la conexión con el AD

Por el puerto 389 sin cifrar viajan la contraseña de svc-keycloak y la de cada persona que entra. Un AD real exige LDAPS (puerto 636, TLS), y eso supone dos cosas para Keycloak: confiar en la CA que firmó el certificado del AD y conectarse con el nombre que figura en él.

# LDAPS (opcional, lección 11): Keycloak habla con el AD cifrado, en el puerto 636.
#
#   1) docker compose cp ad:/var/lib/samba/private/tls/ca.pem ad/ad-ca.pem
#   2) docker compose -f docker-compose.yml -f docker-compose.ldaps.yml up -d
#   3) En la consola: User federation → active-directory → Connection URL:
#      ldaps://dc1.tienda.local:636 y vuelve a escribir Bind credentials
#      (Keycloak lo exige al cambiar la URL): Keycloak-Lectura-2026!
#
# La CA cambia cada vez que se crea el dominio (docker compose down -v):
# entonces repite el paso 1.
services:
  ad:
    networks:
      default:
        aliases: [dc1.tienda.local]   # el nombre que figura en el certificado del AD
  keycloak:
    environment:
      # Confía en la CA que firmó el certificado del AD (archivo PEM).
      KC_TRUSTSTORE_PATHS: /opt/keycloak/conf/truststores/ad-ca.pem
    volumes:
      - ./ad/ad-ca.pem:/opt/keycloak/conf/truststores/ad-ca.pem:ro
docker compose cp ad:/var/lib/samba/private/tls/ca.pem ad/ad-ca.pem
docker compose -f docker-compose.yml -f docker-compose.ldaps.yml up -d

Luego, en la consola, cambia Connection URL a ldaps://dc1.tienda.local:636, vuelve a escribir Bind credentials (Keycloak lo exige al cambiar la URL) y pulsa Test authentication y Save. diego entra igual, ahora cifrado. KC_TRUSTSTORE_PATHS añade la CA al almacén de confianza de Keycloak; la opción Use Truststore SPI = Always del proveedor (la de por defecto) hace que la conexión LDAP lo use.

Lo que pasa si falta una pieza (probado):

FaltaResultado
El nombre dc1.tienda.local (sin el alias de red)Test connection: UnknownHost.
La CA (KC_TRUSTSTORE_PATHS)Nadie de AD puede entrar; en el log, PKIX path building failed: unable to find valid certification path to requested target.
Volver a escribir la contraseña al cambiar la URL400 «Bind credentials must be re-entered when the Connection URL or Bind DN is changed.»

Para volver al estado del paso, levanta solo el compose base y deja la URL en ldap://ad:389. La CA cambia cada vez que se crea el dominio (docker compose down -v): repite la copia si recreas el AD. Por eso ad-ca.pem está en .gitignore.

8. Con un AD de verdad

Lo que tendrás que pedir al equipo de sistemas:

  • Nombre del controlador y LDAPS: ldaps://dc01.empresa.local:636 y el certificado de la CA interna (en PEM). Si hay varios controladores, Keycloak acepta varias URL separadas por espacios (no por comas) y usa la primera que responde.
  • Una cuenta de servicio de solo lectura, con contraseña que no caduque o con un procedimiento de rotación. Si caduca, nadie del AD podrá entrar.
  • Users DN y filtro: en qué unidad están las personas que deben entrar. Para limitarlo a un grupo, el User LDAP filter admite algo como (memberOf=CN=App-Tienda,OU=Grupos,DC=empresa,DC=local).
  • Los grupos que serán roles, con nombres pactados y un filtro que los liste explícitamente.
  • Caché: NO_CACHE hace una consulta al AD por login. Con muchos usuarios, una caché de minutos (MAX_LIFESPAN) es un término medio: los cambios de grupo tardan ese tiempo en notarse.

Si la empresa usa Microsoft 365, quizá las cuentas vivan en Entra ID (antes Azure AD) y no haya LDAP accesible. Entonces el camino es identity brokering: Keycloak redirige el login a Microsoft por OIDC. Para tu código Go vuelve a ser invisible.

Ejercicios

1. Un empleado nuevo · fácil

Da de alta a lucas en el AD, en el grupo cliente, y haz que compre en la tienda. ¿Hace falta tocar Keycloak?

Ver solución
docker compose exec -T ad sh -c 'samba-tool user create lucas "Lucas-Tienda-2026!" --use-username-as-cn --given-name=Lucas --surname=Compras --mail-address=lucas@tienda.local --userou="OU=Tienda" && samba-tool group addmembers cliente lucas'

El mismo comando funciona en PowerShell (las comillas simples protegen el &&). No hace falta tocar Keycloak: lucas entra con su contraseña de AD y Keycloak lo importa en ese momento con el rol cliente. go run ./cmd/admin sincronizar lo habría importado antes de su primer login.

2. Quitarle a diego el rol admin · fácil

Intenta quitárselo con admin-tool y después hazlo bien. ¿Cuándo pierde el acceso a /admin?

Ver solución
go run ./cmd/admin rol -usuario diego -rol admin -quitar
# el usuario viene de Active Directory: el rol admin sale de los grupos de AD; cambia la
# pertenencia al grupo en AD (400 Bad Request: invalid_request: Could not remove user role mappings!)

cd infra
docker compose exec -T ad samba-tool group removemembers admin diego

Con NO_CACHE, el siguiente token que Keycloak emita para diego ya no lleva admin, incluso el de una renovación (probado: ["cliente", "admin"] → ["cliente"]). En cuanto tienda-web renueve, o al volver a entrar, /admin da 403. El token que tenía antes del cambio sigue valiendo hasta que caduca. Vuelve a ponerlo con samba-tool group addmembers admin diego.

3. ¿Y si queremos dar de baja desde admin-tool? · media

Recursos humanos pide que admin baja funcione también con usuarios de AD. ¿Qué opciones hay y qué implican?

Ver solución

Con el proveedor en READ_ONLY, Keycloak no puede. Opciones:

  • Edit mode WRITABLE: Keycloak escribe los cambios en el AD (también contraseñas y, con el mapper en LDAP_ONLY, grupos). La cuenta de servicio necesita permisos de escritura en esa unidad: pasa a ser una cuenta muy valiosa. Pocos equipos de sistemas lo aceptan.
  • Edit mode UNSYNCED: los cambios se quedan en la copia local de Keycloak y nunca llegan al AD. Deshabilitar en Keycloak funcionaría, pero AD y Keycloak dirían cosas distintas: confuso.
  • Hablar con el AD directamente (por ejemplo, con la librería github.com/go-ldap/ldap/v3) o, mejor, con la herramienta de altas y bajas que ya use la empresa. Keycloak solo debe enterarse.

La recomendación habitual es la última: un único sitio para el ciclo de vida de las personas (el AD) y Keycloak de solo lectura. Mientras tanto, admin expulsar sí sirve para cortar sus sesiones en Keycloak.

Errores comunes

Strong(er) authentication required Test authentication falla con un AD real

El AD no acepta un bind con contraseña sin cifrar. Usa LDAPS (sección 7). Rebajar la exigencia del AD, como hace 0globals.conf en el de prueba, solo es aceptable en desarrollo.

Invalid credentials con la cuenta de servicio y la contraseña correctas

El Bind DN está mal. En AD, el CN suele ser el nombre completo («Keycloak Servicio»), no el usuario: el DN es CN=Keycloak Servicio,CN=Users,…. El seed crea las cuentas con --use-username-as-cn para que el CN sea el usuario. Pregunta el DN exacto o búscalo con samba-tool user show svc-keycloak (o Get-ADUser en Windows).

PKIX path building failed nadie de AD puede entrar

Keycloak no confía en el certificado del AD. Añade la CA con KC_TRUSTSTORE_PATHS y comprueba que Use Truststore SPI está en Always. Si el AD se recreó, su CA es otra: vuelve a copiarla.

UnknownHost o error de nombre del certificado

La URL debe usar el nombre que figura en el certificado (dc1.tienda.local), no una IP ni otro alias, y ese nombre debe resolverse desde el contenedor de Keycloak.

ad-ca.pem es una carpeta Keycloak no arranca con el override LDAPS

Si montas un archivo que no existe, Docker crea en su lugar una carpeta vacía. Bórrala, copia la CA con docker compose cp y vuelve a levantar.

400 Could not add user role mappings!

Es el mapper en LDAP_ONLY haciendo su trabajo: los roles de ese usuario vienen del AD. Cambia sus grupos en el AD.

sigue siendo admin Lo quité del grupo y conserva el rol

Caché del proveedor. Con la política por defecto, Keycloak reutiliza al usuario importado. Vacía la caché (Action → Clear user cache en el proveedor) o usa NO_CACHE/MAX_LIFESPAN. Y recuerda que un token ya emitido no cambia.

perdió sus pedidos Un usuario de AD ya no ve lo suyo

Alguien lo borró en Keycloak y se reimportó con otro sub (sección 5). Recupera la relación con el ID antiguo o, si tu sistema debe sobrevivir a eso, guarda también un identificador estable del directorio (el atributo LDAP_ID, que puedes añadir al token con un mapper de atributo de usuario).

Users vacío La consola no lista a nadie

Con un directorio federado, Keycloak no lista todo por defecto. Busca * o un nombre.

Keycloak no arranca se queda esperando a ad-seed

Keycloak espera a que ad-seed termine bien. Mira docker compose logs ad ad-seed: lo habitual es que el dominio aún se esté creando (la primera vez) o que falte cap_add: [SYS_ADMIN], que Samba necesita para sus atributos de archivo.

Resumen

Keycloak puede delegar en un Active Directory quién es cada persona, su contraseña y sus grupos. Tus servicios Go siguen validando los mismos tokens: el cambio está en la configuración del realm y en las herramientas que administran usuarios, que deben respetar que el AD manda. Dos reglas prácticas: los permisos se gestionan en un solo sitio (LDAP_ONLY) y a un usuario de AD se le deshabilita, no se le borra.