Módulo 5 · Lección 09

gocloak: usuarios, roles y grupos

Hasta ahora has configurado Keycloak con clics y JSON. Ahora lo haces desde Go: una CLI de administración que da de alta clientes, asigna roles, crea grupos, consulta sesiones y expulsa usuarios, a través de la Admin REST API.

≈ 60 min Carpeta: tienda/pasos/paso-09 gocloak v14 Realm nuevo: hay que reimportar

En este capítulo

Trabajas en
admin-tool: una CLI nueva que administra Keycloak desde Go.
Carpeta
tienda/pasos/paso-09 · realm nuevo
Archivos
Nuevos: cmd/admin/main.go, internal/admin/admin.go; en Keycloak, el client admin-tool · ver todos los cambios del paso.
En marcha
Keycloak del paso 09. Los servicios solo para ver los efectos (contraseña temporal, baja).
Comprueba
go -C tools/comprobar run . -paso 9 (desde course/) y go run ./cmd/admin usuarios

Al terminar sabrás

  • Qué es la Admin REST API y cómo se autoriza (roles del client realm-management).
  • Dar a una herramienta solo los permisos de administración que necesita, con una service account.
  • Usar gocloak para usuarios, contraseñas, roles, grupos y sesiones.
  • Escribir operaciones idempotentes y traducir los errores de la API a algo útil.

1. La Admin REST API

Todo lo que hace la consola de administración lo hace llamando a una API REST en /admin/realms/{realm}/…: /users, /groups, /roles, /clients… La consola es solo un cliente más. Tu código puede hacer lo mismo, con un access token que tenga los permisos adecuados.

Esos permisos son roles del client realm-management, un client que Keycloak crea en cada realm. Los más usados:

RolPermite
view-users / query-usersLeer y buscar usuarios.
manage-usersCrear, modificar y deshabilitar usuarios, sus contraseñas, roles, grupos y sesiones. Incluye lo anterior.
query-groupsBuscar grupos.
view-realm / manage-realmLeer o cambiar la configuración del realm, incluidos sus roles.
view-clients / manage-clientsLeer o cambiar clients (lección 10).
realm-adminTodo lo anterior. Evítalo en herramientas.
No uses el admin del realm master

Muchos ejemplos entran en la Admin API con el usuario admin del realm master (LoginAdmin de gocloak). Ese usuario puede hacer cualquier cosa en todos los realms. Una herramienta debe tener su propio client en el realm que administra, con una service account y solo los roles que necesita.

2. El client admin-tool

cd tienda/pasos/paso-08/infra && docker compose down -v
cd ../../paso-09/infra && docker compose up -d

Es un client confidencial con service account, igual que facturacion en la lección 7, pero sus roles son de realm-management:

{
  "username": "service-account-admin-tool",
  "enabled": true,
  "serviceAccountClientId": "admin-tool",
  "realmRoles": [
    "default-roles-tienda"
  ],
  "clientRoles": {
    "realm-management": [
      "view-users",
      "query-users",
      "manage-users",
      "query-groups",
      "view-realm"
    ]
  }
}
Service account roles de admin-tool con roles de realm-management
Clients → admin-tool → Service account roles.

Su token lleva esos roles en resource_access.realm-management, con aud: ["realm-management", "account"]: la Admin API es, a efectos de OAuth, una API más que exige su audiencia y sus roles.

3. gocloak

go get github.com/Nerzal/gocloak/v14

gocloak envuelve la Admin API con funciones tipadas. Sus structs usan punteros para distinguir «vacío» de «no enviado», así que verás mucho gocloak.StringP("…") para crear y gocloak.PString(p) para leer.

La herramienta se autentica una vez con Client Credentials (LoginClient): una CLI vive menos que el token.

// New obtiene un token de la service account. Una CLI vive menos que el
// token (5 min), así que basta con pedirlo una vez.
func New(ctx context.Context, cfg Config) (*Admin, error) {
	kc := gocloak.NewClient(cfg.URL)
	jwt, err := kc.LoginClient(ctx, cfg.ClientID, cfg.ClientSecret, cfg.Realm)
	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
}

Casi todas las operaciones necesitan el ID del usuario, no su nombre. Ojo con la búsqueda: sin Exact, Keycloak hace una búsqueda parcial y «ana» también encontraría a «mariana».

// userID busca el ID de un usuario por su nombre exacto.
func (a *Admin) userID(ctx context.Context, username string) (string, 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)
	}
	if len(users) == 0 {
		return "", fmt.Errorf("usuario %q: %w", username, ErrNotFound)
	}
	return gocloak.PString(users[0].ID), nil
}

Dar de alta a un cliente son tres llamadas: crear el usuario, ponerle una contraseña temporal (Keycloak le obligará a cambiarla al entrar) y asignarle el rol cliente.

// NewCustomer da de alta a un cliente: usuario habilitado, rol «cliente» y
// una contraseña temporal que tendrá que cambiar en su primer login.
func (a *Admin) NewCustomer(ctx context.Context, username, email, first, last, tempPassword string) (string, error) {
	id, err := a.kc.CreateUser(ctx, a.token, a.realm, gocloak.User{
		Username:      gocloak.StringP(username),
		Email:         gocloak.StringP(email),
		FirstName:     gocloak.StringP(first),
		LastName:      gocloak.StringP(last),
		Enabled:       gocloak.BoolP(true),
		EmailVerified: gocloak.BoolP(true),
	})
	if err != nil {
		return "", explain(err)
	}
	// temporary=true: Keycloak le pedirá una contraseña nueva al entrar.
	if err := a.kc.SetPassword(ctx, a.token, id, a.realm, tempPassword, true); err != nil {
		return id, explain(err)
	}
	return id, a.SetRole(ctx, username, "cliente", true)
}

Las operaciones de configuración conviene que sean idempotentes: ejecutarlas dos veces deja el mismo resultado. EnsureGroup busca el grupo y solo lo crea si no existe:

// EnsureGroup crea el grupo si no existe y le asigna los roles de realm.
// Es idempotente: ejecutarlo dos veces deja el mismo resultado.
func (a *Admin) EnsureGroup(ctx context.Context, name string, roles ...string) (id string, created bool, err error) {
	id, err = a.groupID(ctx, name)
	switch {
	case errors.Is(err, ErrNotFound):
		if id, err = a.kc.CreateGroup(ctx, a.token, a.realm, gocloak.Group{Name: gocloak.StringP(name)}); err != nil {
			return "", false, explain(err)
		}
		created = true
	case err != nil:
		return "", false, err
	}
	var rs []gocloak.Role
	for _, role := range roles {
		r, err := a.kc.GetRealmRole(ctx, a.token, a.realm, role)
		if err != nil {
			return id, created, explain(err)
		}
		rs = append(rs, *r)
	}
	if len(rs) > 0 {
		err = explain(a.kc.AddRealmRoleToGroup(ctx, a.token, a.realm, id, rs))
	}
	return id, created, err
}

Y los errores de la API llegan como *gocloak.APIError con el código HTTP; explain añade una pista a los dos más frecuentes:

// explain añade una pista a los errores típicos de la Admin API.
func explain(err error) error {
	var apiErr *gocloak.APIError
	if !errors.As(err, &apiErr) {
		return err
	}
	switch apiErr.Code {
	case http.StatusForbidden:
		return fmt.Errorf("%w (¿le falta un rol de realm-management a la service account de admin-tool?)", err)
	case http.StatusConflict:
		return fmt.Errorf("%w (ya existe)", err)
	}
	return err
}
Ver internal/admin/admin.go y cmd/admin/main.go completos
// Package admin gestiona usuarios, roles, grupos y sesiones del realm tienda
// a través de la Admin REST API de Keycloak, con la librería gocloak.
//
// Se autentica como el client confidencial admin-tool (Client Credentials).
// Lo que puede hacer lo deciden los roles del client «realm-management»
// asignados a su service account (manage-users, view-users…).
package admin

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"sort"
	"time"

	"github.com/Nerzal/gocloak/v14"
)

// 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
}

// Config indica dónde está Keycloak y con qué client entrar.
type Config struct {
	URL          string // http://localhost:8080 (sin /realms/…)
	Realm        string // realm a administrar (y en el que vive el client)
	ClientID     string
	ClientSecret string
}

// ErrNotFound indica que el usuario o el grupo no existe.
var ErrNotFound = errors.New("no existe")

// New obtiene un token de la service account. Una CLI vive menos que el
// token (5 min), así que basta con pedirlo una vez.
func New(ctx context.Context, cfg Config) (*Admin, error) {
	kc := gocloak.NewClient(cfg.URL)
	jwt, err := kc.LoginClient(ctx, cfg.ClientID, cfg.ClientSecret, cfg.Realm)
	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
}

// UserInfo resume un usuario para listarlo.
type UserInfo struct {
	ID, Username, Email, Name string
	Enabled                   bool
	Roles                     []string // roles de realm asignados directamente
	Groups                    []string
}

// Users lista los usuarios del realm (sin las service accounts).
func (a *Admin) Users(ctx context.Context) ([]UserInfo, error) {
	users, err := a.kc.GetUsers(ctx, a.token, a.realm, gocloak.GetUsersParams{Max: gocloak.IntP(100)})
	if err != nil {
		return nil, explain(err)
	}
	var out []UserInfo
	for _, u := range users {
		if u.ServiceAccountClientID != nil {
			continue
		}
		info := UserInfo{
			ID:       gocloak.PString(u.ID),
			Username: gocloak.PString(u.Username),
			Email:    gocloak.PString(u.Email),
			Name:     gocloak.PString(u.FirstName) + " " + gocloak.PString(u.LastName),
			Enabled:  gocloak.PBool(u.Enabled),
		}
		roles, err := a.kc.GetRealmRolesByUserID(ctx, a.token, a.realm, info.ID)
		if err != nil {
			return nil, explain(err)
		}
		for _, r := range roles {
			if name := gocloak.PString(r.Name); name != "default-roles-"+a.realm {
				info.Roles = append(info.Roles, name)
			}
		}
		groups, err := a.kc.GetUserGroups(ctx, a.token, a.realm, info.ID, gocloak.GetGroupsParams{})
		if err != nil {
			return nil, explain(err)
		}
		for _, g := range groups {
			info.Groups = append(info.Groups, gocloak.PString(g.Name))
		}
		sort.Strings(info.Roles)
		out = append(out, info)
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Username < out[j].Username })
	return out, nil
}

// NewCustomer da de alta a un cliente: usuario habilitado, rol «cliente» y
// una contraseña temporal que tendrá que cambiar en su primer login.
func (a *Admin) NewCustomer(ctx context.Context, username, email, first, last, tempPassword string) (string, error) {
	id, err := a.kc.CreateUser(ctx, a.token, a.realm, gocloak.User{
		Username:      gocloak.StringP(username),
		Email:         gocloak.StringP(email),
		FirstName:     gocloak.StringP(first),
		LastName:      gocloak.StringP(last),
		Enabled:       gocloak.BoolP(true),
		EmailVerified: gocloak.BoolP(true),
	})
	if err != nil {
		return "", explain(err)
	}
	// temporary=true: Keycloak le pedirá una contraseña nueva al entrar.
	if err := a.kc.SetPassword(ctx, a.token, id, a.realm, tempPassword, true); err != nil {
		return id, explain(err)
	}
	return id, a.SetRole(ctx, username, "cliente", true)
}

// SetRole asigna (add=true) o quita un rol de realm a un usuario.
func (a *Admin) SetRole(ctx context.Context, username, role string, add bool) error {
	id, err := a.userID(ctx, username)
	if err != nil {
		return err
	}
	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}))
}

// EnsureGroup crea el grupo si no existe y le asigna los roles de realm.
// Es idempotente: ejecutarlo dos veces deja el mismo resultado.
func (a *Admin) EnsureGroup(ctx context.Context, name string, roles ...string) (id string, created bool, err error) {
	id, err = a.groupID(ctx, name)
	switch {
	case errors.Is(err, ErrNotFound):
		if id, err = a.kc.CreateGroup(ctx, a.token, a.realm, gocloak.Group{Name: gocloak.StringP(name)}); err != nil {
			return "", false, explain(err)
		}
		created = true
	case err != nil:
		return "", false, err
	}
	var rs []gocloak.Role
	for _, role := range roles {
		r, err := a.kc.GetRealmRole(ctx, a.token, a.realm, role)
		if err != nil {
			return id, created, explain(err)
		}
		rs = append(rs, *r)
	}
	if len(rs) > 0 {
		err = explain(a.kc.AddRealmRoleToGroup(ctx, a.token, a.realm, id, rs))
	}
	return id, created, err
}

// AddToGroup mete a un usuario en un grupo: hereda sus roles.
func (a *Admin) AddToGroup(ctx context.Context, username, group string) error {
	uid, err := a.userID(ctx, username)
	if err != nil {
		return err
	}
	gid, err := a.groupID(ctx, group)
	if err != nil {
		return err
	}
	return explain(a.kc.AddUserToGroup(ctx, a.token, a.realm, uid, gid))
}

// Session es una sesión SSO de un usuario.
type Session struct {
	Started, LastAccess time.Time
	IP                  string
	Clients             []string
}

// Sessions lista las sesiones SSO activas de un usuario.
func (a *Admin) Sessions(ctx context.Context, username string) ([]Session, error) {
	id, err := a.userID(ctx, username)
	if err != nil {
		return nil, err
	}
	ss, err := a.kc.GetUserSessions(ctx, a.token, a.realm, id)
	if err != nil {
		return nil, explain(err)
	}
	var out []Session
	for _, s := range ss {
		sess := Session{
			Started:    time.UnixMilli(gocloak.PInt64(s.Start)),
			LastAccess: time.UnixMilli(gocloak.PInt64(s.LastAccess)),
			IP:         gocloak.PString(s.IPAddress),
		}
		for _, c := range s.Clients {
			sess.Clients = append(sess.Clients, c)
		}
		sort.Strings(sess.Clients)
		out = append(out, sess)
	}
	return out, nil
}

// LogoutAll cierra todas las sesiones de un usuario (y avisa por back-channel
// a los clients que lo tengan configurado).
func (a *Admin) LogoutAll(ctx context.Context, username string) error {
	id, err := a.userID(ctx, username)
	if err != nil {
		return err
	}
	return explain(a.kc.LogoutAllSessions(ctx, a.token, a.realm, id))
}

// Disable deshabilita a un usuario: no podrá iniciar sesión ni renovar tokens.
// Es preferible a borrarlo: se conserva su historial (y su «sub»).
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)}))
}

// userID busca el ID de un usuario por su nombre exacto.
func (a *Admin) userID(ctx context.Context, username string) (string, 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)
	}
	if len(users) == 0 {
		return "", fmt.Errorf("usuario %q: %w", username, ErrNotFound)
	}
	return gocloak.PString(users[0].ID), nil
}

// groupID busca el ID de un grupo de primer nivel por su nombre exacto.
func (a *Admin) groupID(ctx context.Context, name string) (string, error) {
	groups, err := a.kc.GetGroups(ctx, a.token, a.realm, gocloak.GetGroupsParams{
		Search: gocloak.StringP(name),
		Exact:  gocloak.BoolP(true),
	})
	if err != nil {
		return "", explain(err)
	}
	for _, g := range groups {
		if gocloak.PString(g.Name) == name {
			return gocloak.PString(g.ID), nil
		}
	}
	return "", fmt.Errorf("grupo %q: %w", name, ErrNotFound)
}

// explain añade una pista a los errores típicos de la Admin API.
func explain(err error) error {
	var apiErr *gocloak.APIError
	if !errors.As(err, &apiErr) {
		return err
	}
	switch apiErr.Code {
	case http.StatusForbidden:
		return fmt.Errorf("%w (¿le falta un rol de realm-management a la service account de admin-tool?)", err)
	case http.StatusConflict:
		return fmt.Errorf("%w (ya existe)", err)
	}
	return err
}
// Command admin es una CLI de administración del realm tienda, escrita con
// gocloak sobre la Admin REST API de Keycloak.
//
// Uso (desde tienda/pasos/paso-09):
//
//	go run ./cmd/admin usuarios
//	go run ./cmd/admin alta -usuario lucia -email lucia@tienda.test -nombre Lucía -apellido Cliente -password Temporal123
//	go run ./cmd/admin rol -usuario lucia -rol admin            (añade; con -quitar lo quita)
//	go run ./cmd/admin grupo -nombre personal -rol admin        (crea el grupo o le añade el rol)
//	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)
package main

import (
	"context"
	"flag"
	"fmt"
	"log"
	"os"
	"strings"
	"text/tabwriter"
	"time"

	"tienda/internal/admin"
)

func main() {
	log.SetFlags(0)
	if len(os.Args) < 2 {
		usage()
	}
	cmd, args := os.Args[1], os.Args[2:]

	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	a, err := admin.New(ctx, admin.Config{
		URL:          env("KEYCLOAK_URL", "http://localhost:8080"),
		Realm:        env("KEYCLOAK_REALM", "tienda"),
		ClientID:     env("ADMIN_CLIENT_ID", "admin-tool"),
		ClientSecret: env("ADMIN_CLIENT_SECRET", "admin-tool-secret"), // solo para desarrollo
	})
	if err != nil {
		log.Fatal(err)
	}

	fs := flag.NewFlagSet(cmd, flag.ExitOnError)
	usuario := fs.String("usuario", "", "nombre de usuario")
	switch cmd {
	case "usuarios":
		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")
		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,
				strings.Join(u.Roles, ","), strings.Join(u.Groups, ","))
		}
		tw.Flush()

	case "alta":
		email := fs.String("email", "", "email")
		nombre := fs.String("nombre", "", "nombre")
		apellido := fs.String("apellido", "", "apellido")
		password := fs.String("password", "", "contraseña temporal")
		parse(fs, args, func() bool { return *usuario != "" && *email != "" && *password != "" })
		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)

	case "rol":
		rol := fs.String("rol", "", "rol de realm")
		quitar := fs.Bool("quitar", false, "quitar el rol en vez de añadirlo")
		parse(fs, args, func() bool { return *usuario != "" && *rol != "" })
		check(a.SetRole(ctx, *usuario, *rol, !*quitar))
		fmt.Printf("rol %s %s a %s\n", *rol, map[bool]string{true: "quitado", false: "asignado"}[*quitar], *usuario)

	case "grupo":
		nombre := fs.String("nombre", "", "nombre del grupo")
		rol := fs.String("rol", "", "rol de realm para el grupo (opcional)")
		parse(fs, args, func() bool { return *nombre != "" })
		var roles []string
		if *rol != "" {
			roles = append(roles, *rol)
		}
		id, created, err := a.EnsureGroup(ctx, *nombre, roles...)
		check(err)
		fmt.Printf("grupo %s (id %s) %s\n", *nombre, id, map[bool]string{true: "creado", false: "ya existía"}[created])

	case "miembro":
		grupo := fs.String("grupo", "", "nombre del grupo")
		parse(fs, args, func() bool { return *usuario != "" && *grupo != "" })
		check(a.AddToGroup(ctx, *usuario, *grupo))
		fmt.Printf("%s ahora es miembro de %s\n", *usuario, *grupo)

	case "sesiones":
		parse(fs, args, func() bool { return *usuario != "" })
		ss, err := a.Sessions(ctx, *usuario)
		check(err)
		tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
		fmt.Fprintln(tw, "INICIO\tÚLTIMO ACCESO\tIP\tCLIENTS")
		for _, s := range ss {
			fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", s.Started.Format("15:04:05"), s.LastAccess.Format("15:04:05"), s.IP, strings.Join(s.Clients, ","))
		}
		tw.Flush()

	case "expulsar":
		parse(fs, args, func() bool { return *usuario != "" })
		check(a.LogoutAll(ctx, *usuario))
		fmt.Printf("sesiones de %s cerradas\n", *usuario)

	case "baja":
		parse(fs, args, func() bool { return *usuario != "" })
		check(a.Disable(ctx, *usuario))
		fmt.Printf("%s deshabilitado\n", *usuario)

	default:
		usage()
	}
}

// parse lee los flags y comprueba los obligatorios. ok es una función porque
// hay que evaluarla DESPUÉS de fs.Parse, cuando los flags ya tienen valor.
func parse(fs *flag.FlagSet, args []string, ok func() bool) {
	_ = fs.Parse(args)
	if !ok() {
		fs.Usage()
		os.Exit(2)
	}
}

func check(err error) {
	if err != nil {
		log.Fatal(err)
	}
}

func usage() {
	log.Fatal("uso: admin usuarios | alta | rol | grupo | miembro | sesiones | expulsar | baja  (-h en cada uno)")
}

func env(key, def string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return def
}

4. Usarla

Salida real contra el realm del paso 9:

cd tienda/pasos/paso-09
go run ./cmd/admin alta -usuario lucia -email lucia@tienda.test -nombre Lucía -apellido Cliente -password Temporal123
# alta de lucia (id ee217c28-5d00-4895-af40-4fc01b9f89fd) con rol cliente; deberá cambiar la contraseña al entrar

go run ./cmd/admin alta -usuario lucia -email lucia@tienda.test -nombre Lucía -apellido Cliente -password Temporal123
# 409 Conflict: User exists with same email (ya existe)

go run ./cmd/admin grupo -nombre personal -rol admin
# grupo personal (id a6b00e72-3d12-419b-bba4-c29454238a02) creado
go run ./cmd/admin grupo -nombre personal -rol admin
# grupo personal (id a6b00e72-3d12-419b-bba4-c29454238a02) ya existía

go run ./cmd/admin miembro -usuario lucia -grupo personal
# lucia ahora es miembro de personal

go run ./cmd/admin usuarios
# USUARIO  NOMBRE         EMAIL               ACTIVO  ROLES          GRUPOS
# ana      Ana Cliente    ana@tienda.test     true    cliente
# carlos   Carlos Admin   carlos@tienda.test  true    admin,cliente
# lucia    Lucía Cliente  lucia@tienda.test   true    cliente        personal

go run ./cmd/admin rol -usuario nadie -rol admin
# usuario "nadie": no existe
go run ./cmd/admin rol -usuario ana -rol superadmin
# 404 Not Found: Could not find role

La columna ROLES muestra solo los roles asignados directamente: lucia tiene también admin, heredado del grupo personal (ejercicio 1).

Cuando lucia (o cualquier alta nueva, como marta) entra por primera vez, Keycloak le obliga a cambiar la contraseña temporal:

Pantalla de Keycloak para cambiar la contraseña temporal
Primer login con contraseña temporal.
Login de Keycloak con el error Account is disabled
Tras admin baja -usuario lucia.

Las sesiones conectan con la lección 4:

go run ./cmd/admin sesiones -usuario ana
# INICIO    ÚLTIMO ACCESO  IP          CLIENTS
# 17:22:15  17:22:15       172.20.0.1  tienda-web
# 17:22:30  17:22:31       172.20.0.1  tienda-web
go run ./cmd/admin expulsar -usuario ana
# sesiones de ana cerradas

expulsar hace lo mismo que el botón Logout all sessions de la consola: si tienda-web tiene configurado el back-channel logout, se entera al instante; si no, en su siguiente renovación de token.

Ejercicios

1. Roles efectivos · fácil

Haz que usuarios muestre también los roles heredados de grupos y roles compuestos, para que lucia aparezca con admin.

Ver solución

Cambia GetRealmRolesByUserID (roles asignados directamente) por GetCompositeRealmRolesByUserID (roles efectivos, con grupos y compuestos expandidos). Con eso aparecen también offline_access y uma_authorization, que vienen de default-roles-tienda: fíltralos igual que ya se filtra default-roles-tienda.

2. Alta masiva desde CSV · media

Añade un comando que lea un CSV usuario,email,nombre,apellido y dé de alta a cada cliente. Debe poder ejecutarse varias veces sin fallar por los que ya existen.

Ver solución
// importCSV da de alta a los clientes de un CSV «usuario,email,nombre,apellido».
// Los que ya existen se saltan: se puede ejecutar varias veces (idempotente).
func importCSV(ctx context.Context, a *admin.Admin, path, tempPassword string) error {
	f, err := os.Open(path)
	if err != nil {
		return err
	}
	defer f.Close()
	rows, err := csv.NewReader(f).ReadAll()
	if err != nil {
		return err
	}
	for _, r := range rows {
		if len(r) != 4 {
			return fmt.Errorf("fila mal formada: %v", r)
		}
		_, err := a.NewCustomer(ctx, r[0], r[1], r[2], r[3], tempPassword)
		var apiErr *gocloak.APIError
		switch {
		case errors.As(err, &apiErr) && apiErr.Code == http.StatusConflict:
			fmt.Printf("%s: ya existe, se salta\n", r[0])
		case err != nil:
			return fmt.Errorf("%s: %w", r[0], err)
		default:
			fmt.Printf("%s: alta\n", r[0])
		}
	}
	return nil
}

Salida real con un CSV de ana, pepe y rosa, ejecutado dos veces:

ana: ya existe, se salta
pepe: alta
rosa: alta
--- segunda vez:
ana: ya existe, se salta
pepe: ya existe, se salta
rosa: ya existe, se salta

Funciona porque explain envuelve el error con %w, así que errors.As sigue encontrando el *gocloak.APIError.

3. Mínimo privilegio · media

La service account tiene cinco roles de realm-management. ¿Cuáles necesita de verdad esta CLI? Pruébalo quitándolos uno a uno en Service account roles.

Ver solución

En nuestras pruebas, quitando de uno en uno:

  • Sin view-realm: rol falla con 403, porque GetRealmRole lee un rol del realm. El resto funciona.
  • Sin query-users, query-groups o view-users (cada uno por separado): todo sigue funcionando, porque manage-users ya los incluye.
  • Sin manage-users: usuarios funciona (gracias a view-users), pero alta falla con 403.

Conclusión: para esta CLI bastan manage-users y view-realm. Una versión de solo lectura tendría view-users y nada más. Mejor aún: dos clients distintos, uno para leer y otro para escribir.

Errores comunes

403 Forbidden «HTTP 403 Forbidden»

A la service account le falta un rol de realm-management (ejercicio 3). Ojo: si se lo asignas mientras la herramienta corre, necesita un token nuevo.

409 Conflict «User exists with same email» / «…same username»

Ya existe un usuario con ese nombre o email. Si tu herramienta debe poder repetirse, trata el 409 como «ya estaba» (ejercicio 2).

404 «Could not find role»

El rol no existe en el realm (¿es un rol de client?). Para roles de client, gocloak tiene funciones aparte (GetClientRole, AddClientRolesToUser), que necesitan el ID interno del client.

usuario equivocado La operación se aplicó a otra persona

Buscaste por nombre sin Exact: true y tomaste el primer resultado de una búsqueda parcial. Arreglo: siempre Exact, y comprueba que hay exactamente un resultado.

/auth 404 en todas las llamadas

Desde Keycloak 17 las rutas ya no llevan el prefijo /auth. Guías antiguas usan http://localhost:8080/auth; con Keycloak actual, la URL base es http://localhost:8080.

punteros nil Pánico al leer un campo

Los campos de gocloak son punteros y pueden venir a nil (por ejemplo, un usuario sin email). Lee con gocloak.PString, PBool o PInt64, que devuelven el valor cero si el puntero es nil.

Resumen

La Admin REST API es una API OAuth más: se llama con un access token que tenga roles del client realm-management. Una herramienta Go se autentica con su propia service account (nunca con el admin de master), usa gocloak para las operaciones y las escribe idempotentes. En la lección 10 usarás lo mismo para algo más ambicioso: aprovisionar clients y scopes desde código.