Cambios de la lección 11
Todo lo que cambia en tienda/pasos/paso-11 respecto al paso anterior. Vuelve a la lección: 11. Federación con Active Directory.
8 archivos cambian. En verde lo que se añade; en rojo lo que se quita. go.sum no se muestra.
| Archivo | Estado | Líneas |
|---|---|---|
cmd/admin/main.go | modificado | +16 −4 |
infra/ad/0globals.conf | nuevo | +4 −0 |
infra/ad/admin-password | nuevo | +1 −0 |
infra/ad/seed.sh | nuevo | +33 −0 |
infra/docker-compose.ldaps.yml | nuevo | +21 −0 |
infra/docker-compose.yml | modificado | +45 −5 |
infra/realm/tienda-realm.json | modificado | +268 −0 |
internal/admin/admin.go | modificado | +101 −22 |
cmd/admin/main.go
@@ -10,7 +10,8 @@
// go run ./cmd/admin miembro -usuario lucia -grupo personal
// go run ./cmd/admin sesiones -usuario ana
// go run ./cmd/admin expulsar -usuario ana (cierra todas sus sesiones)
-// go run ./cmd/admin baja -usuario lucia (deshabilita)
+// go run ./cmd/admin baja -usuario lucia (deshabilita; solo usuarios locales)
+// go run ./cmd/admin sincronizar (importa/actualiza usuarios de AD)
package main
import (
@@ -53,9 +54,9 @@
users, err := a.Users(ctx)
check(err)
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
- fmt.Fprintln(tw, "USUARIO\tNOMBRE\tEMAIL\tACTIVO\tROLES\tGRUPOS")
+ fmt.Fprintln(tw, "USUARIO\tORIGEN\tNOMBRE\tEMAIL\tACTIVO\tROLES\tGRUPOS")
for _, u := range users {
- fmt.Fprintf(tw, "%s\t%s\t%s\t%v\t%s\t%s\n", u.Username, u.Name, u.Email, u.Enabled,
+ fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%v\t%s\t%s\n", u.Username, u.Origin, u.Name, u.Email, u.Enabled,
strings.Join(u.Roles, ","), strings.Join(u.Groups, ","))
}
tw.Flush()
@@ -69,6 +70,7 @@
id, err := a.NewCustomer(ctx, *usuario, *email, *nombre, *apellido, *password)
check(err)
fmt.Printf("alta de %s (id %s) con rol cliente; deberá cambiar la contraseña al entrar\n", *usuario, id)
+ fmt.Println("(es un usuario local de Keycloak: los usuarios de AD se crean en AD)")
case "rol":
rol := fs.String("rol", "", "rol de realm")
@@ -116,6 +118,16 @@
check(a.Disable(ctx, *usuario))
fmt.Printf("%s deshabilitado\n", *usuario)
+ case "sincronizar":
+ res, err := a.Sync(ctx)
+ check(err)
+ if len(res) == 0 {
+ fmt.Println("el realm no tiene directorios federados")
+ }
+ for _, r := range res {
+ fmt.Printf("%s: %d nuevos, %d actualizados, %d eliminados, %d fallidos\n", r.Provider, r.Added, r.Updated, r.Removed, r.Failed)
+ }
+
default:
usage()
}
@@ -138,7 +150,7 @@
}
func usage() {
- log.Fatal("uso: admin usuarios | alta | rol | grupo | miembro | sesiones | expulsar | baja (-h en cada uno)")
+ log.Fatal("uso: admin usuarios | alta | rol | grupo | miembro | sesiones | expulsar | baja | sincronizar (-h en cada uno)")
}
func env(key, def string) string {
infra/ad/0globals.conf
@@ -0,0 +1,4 @@
+# Solo para desarrollo: permite que Keycloak se autentique en el puerto 389 con
+# un bind simple, sin cifrar. Un AD real exige LDAPS (puerto 636) o firma; la
+# lección 11 explica cómo pasar a LDAPS.
+ldap server require strong auth = no
infra/ad/admin-password
@@ -0,0 +1 @@
+Admin-Tienda-2026!
infra/ad/seed.sh
@@ -0,0 +1,33 @@
+#!/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)"
infra/docker-compose.ldaps.yml
@@ -0,0 +1,21 @@
+# 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
infra/docker-compose.yml
@@ -1,11 +1,43 @@
-# Keycloak + PostgreSQL para el curso "Go + Keycloak".
-# Solo para desarrollo: usa start-dev (HTTP, cachés locales, sin hostname fijo).
+# Keycloak + PostgreSQL + un «Active Directory» de prueba (Samba AD DC) para
+# el curso "Go + Keycloak". Solo para desarrollo.
#
-# docker compose up -d # arrancar
-# docker compose logs -f keycloak
-# docker compose down -v # parar y BORRAR datos (fuerza reimportar el realm)
+# 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:
@@ -46,6 +78,14 @@
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:
infra/realm/tienda-realm.json
@@ -316,6 +316,274 @@
]
}
],
+ "components": {
+ "org.keycloak.storage.UserStorageProvider": [
+ {
+ "name": "active-directory",
+ "providerId": "ldap",
+ "subComponents": {
+ "org.keycloak.storage.ldap.mappers.LDAPStorageMapper": [
+ {
+ "name": "username",
+ "providerId": "user-attribute-ldap-mapper",
+ "subComponents": {},
+ "config": {
+ "ldap.attribute": [
+ "sAMAccountName"
+ ],
+ "is.mandatory.in.ldap": [
+ "true"
+ ],
+ "always.read.value.from.ldap": [
+ "false"
+ ],
+ "read.only": [
+ "true"
+ ],
+ "user.model.attribute": [
+ "username"
+ ]
+ }
+ },
+ {
+ "name": "first name",
+ "providerId": "user-attribute-ldap-mapper",
+ "subComponents": {},
+ "config": {
+ "ldap.attribute": [
+ "givenName"
+ ],
+ "is.mandatory.in.ldap": [
+ "false"
+ ],
+ "always.read.value.from.ldap": [
+ "true"
+ ],
+ "read.only": [
+ "true"
+ ],
+ "user.model.attribute": [
+ "firstName"
+ ]
+ }
+ },
+ {
+ "name": "last name",
+ "providerId": "user-attribute-ldap-mapper",
+ "subComponents": {},
+ "config": {
+ "ldap.attribute": [
+ "sn"
+ ],
+ "is.mandatory.in.ldap": [
+ "true"
+ ],
+ "read.only": [
+ "true"
+ ],
+ "always.read.value.from.ldap": [
+ "true"
+ ],
+ "user.model.attribute": [
+ "lastName"
+ ]
+ }
+ },
+ {
+ "name": "email",
+ "providerId": "user-attribute-ldap-mapper",
+ "subComponents": {},
+ "config": {
+ "ldap.attribute": [
+ "mail"
+ ],
+ "is.mandatory.in.ldap": [
+ "false"
+ ],
+ "always.read.value.from.ldap": [
+ "false"
+ ],
+ "read.only": [
+ "true"
+ ],
+ "user.model.attribute": [
+ "email"
+ ]
+ }
+ },
+ {
+ "name": "MSAD account controls",
+ "providerId": "msad-user-account-control-mapper",
+ "subComponents": {},
+ "config": {
+ "always.read.enabled.value.from.ldap": [
+ "true"
+ ]
+ }
+ },
+ {
+ "name": "roles desde grupos de AD",
+ "providerId": "role-ldap-mapper",
+ "subComponents": {},
+ "config": {
+ "mode": [
+ "LDAP_ONLY"
+ ],
+ "membership.attribute.type": [
+ "DN"
+ ],
+ "roles.dn": [
+ "OU=Tienda,DC=tienda,DC=local"
+ ],
+ "user.roles.retrieve.strategy": [
+ "GET_ROLES_FROM_USER_MEMBEROF_ATTRIBUTE"
+ ],
+ "membership.ldap.attribute": [
+ "member"
+ ],
+ "membership.user.ldap.attribute": [
+ "sAMAccountName"
+ ],
+ "role.name.ldap.attribute": [
+ "cn"
+ ],
+ "memberof.ldap.attribute": [
+ "memberOf"
+ ],
+ "use.realm.roles.mapping": [
+ "true"
+ ],
+ "role.object.classes": [
+ "group"
+ ],
+ "roles.ldap.filter": [
+ "(|(cn=cliente)(cn=admin))"
+ ]
+ }
+ },
+ {
+ "name": "creation date",
+ "providerId": "user-attribute-ldap-mapper",
+ "subComponents": {},
+ "config": {
+ "ldap.attribute": [
+ "whenCreated"
+ ],
+ "is.mandatory.in.ldap": [
+ "false"
+ ],
+ "always.read.value.from.ldap": [
+ "true"
+ ],
+ "read.only": [
+ "true"
+ ],
+ "user.model.attribute": [
+ "createTimestamp"
+ ]
+ }
+ },
+ {
+ "name": "modify date",
+ "providerId": "user-attribute-ldap-mapper",
+ "subComponents": {},
+ "config": {
+ "ldap.attribute": [
+ "whenChanged"
+ ],
+ "is.mandatory.in.ldap": [
+ "false"
+ ],
+ "read.only": [
+ "true"
+ ],
+ "always.read.value.from.ldap": [
+ "true"
+ ],
+ "user.model.attribute": [
+ "modifyTimestamp"
+ ]
+ }
+ },
+ {
+ "name": "Kerberos principal attribute mapper",
+ "providerId": "kerberos-principal-attribute-mapper",
+ "subComponents": {},
+ "config": {}
+ }
+ ]
+ },
+ "config": {
+ "authType": [
+ "simple"
+ ],
+ "bindCredential": [
+ "Keycloak-Lectura-2026!"
+ ],
+ "bindDn": [
+ "CN=svc-keycloak,CN=Users,DC=tienda,DC=local"
+ ],
+ "cachePolicy": [
+ "NO_CACHE"
+ ],
+ "changedSyncPeriod": [
+ "-1"
+ ],
+ "connectionUrl": [
+ "ldap://ad:389"
+ ],
+ "editMode": [
+ "READ_ONLY"
+ ],
+ "enabled": [
+ "true"
+ ],
+ "fullSyncPeriod": [
+ "-1"
+ ],
+ "importEnabled": [
+ "true"
+ ],
+ "krbPrincipalAttribute": [
+ "userPrincipalName"
+ ],
+ "pagination": [
+ "true"
+ ],
+ "priority": [
+ "0"
+ ],
+ "rdnLDAPAttribute": [
+ "cn"
+ ],
+ "searchScope": [
+ "2"
+ ],
+ "syncRegistrations": [
+ "false"
+ ],
+ "trustEmail": [
+ "true"
+ ],
+ "userObjectClasses": [
+ "person, organizationalPerson, user"
+ ],
+ "usernameLDAPAttribute": [
+ "sAMAccountName"
+ ],
+ "usersDn": [
+ "OU=Tienda,DC=tienda,DC=local"
+ ],
+ "uuidLDAPAttribute": [
+ "objectGUID"
+ ],
+ "vendor": [
+ "ad"
+ ]
+ }
+ }
+ ]
+ },
"clientScopes": [
{
"name": "api-pedidos",
internal/admin/admin.go
@@ -19,9 +19,10 @@
// Admin es un cliente de administración ya autenticado.
type Admin struct {
- kc *gocloak.GoCloak
- realm string
- token string // access token de la service account de admin-tool
+ kc *gocloak.GoCloak
+ baseURL string
+ realm string
+ token string // access token de la service account de admin-tool
}
// Config indica dónde está Keycloak y con qué client entrar.
@@ -32,8 +33,13 @@
ClientSecret string
}
-// ErrNotFound indica que el usuario o el grupo no existe.
-var ErrNotFound = errors.New("no existe")
+var (
+ // ErrNotFound indica que el usuario o el grupo no existe.
+ ErrNotFound = errors.New("no existe")
+ // ErrFederated indica que el usuario viene de Active Directory: sus datos
+ // se cambian en AD, no en Keycloak.
+ ErrFederated = errors.New("el usuario viene de Active Directory")
+)
// New obtiene un token de la service account. Una CLI vive menos que el
// token (5 min), así que basta con pedirlo una vez.
@@ -43,13 +49,14 @@
if err != nil {
return nil, fmt.Errorf("login de %s: %w", cfg.ClientID, err)
}
- return &Admin{kc: kc, realm: cfg.Realm, token: jwt.AccessToken}, nil
+ return &Admin{kc: kc, baseURL: cfg.URL, realm: cfg.Realm, token: jwt.AccessToken}, nil
}
// UserInfo resume un usuario para listarlo.
type UserInfo struct {
ID, Username, Email, Name string
Enabled bool
+ Origin string // "AD" si viene de un directorio (federationLink), si no "local"
Roles []string // roles de realm asignados directamente
Groups []string
}
@@ -71,6 +78,7 @@
Email: gocloak.PString(u.Email),
Name: gocloak.PString(u.FirstName) + " " + gocloak.PString(u.LastName),
Enabled: gocloak.PBool(u.Enabled),
+ Origin: origin(u),
}
roles, err := a.kc.GetRealmRolesByUserID(ctx, a.token, a.realm, info.ID)
if err != nil {
@@ -117,19 +125,28 @@
}
// 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 {
- id, err := a.userID(ctx, username)
- if err != nil {
- return err
- }
+ 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 {
- return explain(a.kc.AddRealmRoleToUser(ctx, a.token, a.realm, id, []gocloak.Role{*r}))
- }
- return explain(a.kc.DeleteRealmRoleFromUser(ctx, a.token, a.realm, id, []gocloak.Role{*r}))
+ 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)
}
// EnsureGroup crea el grupo si no existe y le asigna los roles de realm.
@@ -217,27 +234,89 @@
// 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 {
- id, err := a.userID(ctx, username)
- if err != nil {
- return err
- }
- return explain(a.kc.UpdateUser(ctx, a.token, a.realm, gocloak.User{ID: &id, Enabled: gocloak.BoolP(false)}))
+ 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)}))
+}
+
+// SyncResult es el resultado de sincronizar un directorio.
+type SyncResult struct {
+ Provider string `json:"-"`
+ Added int `json:"added"`
+ Updated int `json:"updated"`
+ Removed int `json:"removed"`
+ Failed int `json:"failed"`
+}
+
+// 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
+}
+
+// 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"
}
// userID busca el ID de un usuario por su nombre exacto.
func (a *Admin) userID(ctx context.Context, username string) (string, error) {
+ u, err := a.findUser(ctx, username)
+ if err != nil {
+ return "", err
+ }
+ return gocloak.PString(u.ID), nil
+}
+
+// findUser busca un usuario por su nombre exacto.
+func (a *Admin) findUser(ctx context.Context, username string) (*gocloak.User, error) {
users, err := a.kc.GetUsers(ctx, a.token, a.realm, gocloak.GetUsersParams{
Username: gocloak.StringP(username),
Exact: gocloak.BoolP(true), // sin Exact, «ana» también encontraría a «mariana»
})
if err != nil {
- return "", explain(err)
+ return nil, explain(err)
}
if len(users) == 0 {
- return "", fmt.Errorf("usuario %q: %w", username, ErrNotFound)
- }
- return gocloak.PString(users[0].ID), nil
+ return nil, fmt.Errorf("usuario %q: %w", username, ErrNotFound)
+ }
+ return users[0], nil
}
// groupID busca el ID de un grupo de primer nivel por su nombre exacto.