Automatizar el realm desde Go
El JSON del realm solo sirve para crearlo de cero. Un Keycloak de verdad vive años y cambia poco a poco. Aquí escribes un aprovisionador: describes en un archivo qué servicios deben existir y un programa Go lleva Keycloak a ese estado, sin tocar lo que ya está bien.
En este capítulo
- Trabajas en
- provision: un programa que lleva Keycloak a un estado descrito en un archivo.
- Carpeta
tienda/pasos/paso-10· realm nuevo- Archivos
- Nuevos:
servicios.json,cmd/provision/main.go,internal/provision/provision.go· ver todos los cambios del paso. - En marcha
- Keycloak del paso 10.
- Comprueba
go -C tools/comprobar run . -paso 10(desdecourse/) ygo run ./cmd/provision -plan
Al terminar sabrás
- Por qué importar el realm no sirve para mantenerlo y qué alternativas hay.
- El patrón estado deseado → comparar → aplicar solo la diferencia (reconciliación).
- Crear y corregir clients, asignar client scopes y roles de client con gocloak.
- Ofrecer un modo
-plan, detectar desviaciones y tratar los secretos con cuidado.
1. Por qué no basta el JSON
En todo el curso has cambiado el realm con docker compose down -v y reimportando. En producción eso borraría usuarios, sesiones y contraseñas. Opciones reales:
| Herramienta | Para qué sirve | Límite |
|---|---|---|
Importar al arrancar (--import-realm) | Crear un realm nuevo (desarrollo, tests) | Si el realm existe, no hace nada. |
| Exportar e importar (CLI) | Copias de seguridad, mover realms | Todo o nada; con el servidor parado. |
| Partial import (consola o Admin API) | Añadir clients, roles o grupos sueltos | No corrige lo que ya existe: o lo salta o lo sobrescribe entero. |
| Admin API desde código (esta lección) | Cambios incrementales, revisables y repetibles | Lo escribes y mantienes tú. |
| Herramientas declarativas (keycloak-config-cli, el proveedor de Terraform para Keycloak) | Lo mismo, ya hecho, para todo el realm | Otra herramienta que aprender y versionar. |
Escribir un aprovisionador pequeño te enseña cómo funcionan por dentro esas herramientas, y a veces es todo lo que necesitas: por ejemplo, que cada equipo declare sus servicios en un archivo y un proceso los dé de alta.
2. El estado deseado
cd tienda/pasos/paso-09/infra && docker compose down -v
cd ../../paso-10/infra && docker compose up -d
El único cambio del realm: admin-tool recibe view-clients y manage-clients, y pierde los roles que el ejercicio 3 de la lección 9 demostró innecesarios. Su service account queda con manage-users, view-realm, view-clients y manage-clients.
El estado deseado es un archivo versionado junto al código. Cada servicio dice su client, a qué APIs llama (audiencias) y con qué permisos (roles de client de esas APIs):
{
"servicios": [
{
"clientId": "facturacion",
"descripcion": "Servicio interno: Client Credentials (lección 7) y Token Exchange (lección 8)",
"audiencias": ["api-pedidos"],
"rolesDeAPI": { "api-pedidos": ["facturar"] }
},
{
"clientId": "informes",
"descripcion": "Informes de ventas: solo lectura de pedidos",
"audiencias": ["api-pedidos"],
"rolesDeAPI": { "api-pedidos": ["informar"] }
}
]
}
facturacion ya existe (lo creó el realm), así que el aprovisionador no debería tocarlo. informes es el servicio del ejercicio 3 de la lección 7, que hasta ahora habrías creado a mano.
3. Reconciliar
Apply recorre las tres partes de un servicio y, en cada una, compara antes de cambiar:
- El client. Si no existe, lo crea confidencial y solo con service account. Si existe,
driftcompara sus opciones con las deseadas y corrige las que se hayan desviado. - Las audiencias. Asigna como Default los client scopes de audiencia que falten.
- Los permisos. Para cada API, da a la service account los roles de client que falten (y crea el rol en la API si no existe).
// Apply lleva un servicio al estado deseado.
func (p *Provisioner) Apply(ctx context.Context, s Service) (Result, error) {
var res Result
change := func(format string, args ...any) { res.Changes = append(res.Changes, fmt.Sprintf(format, args...)) }
// 1. El client: confidencial, solo con service account.
client, err := p.findClient(ctx, s.ClientID)
if err != nil {
return res, err
}
if client == nil {
change("crear client %s (confidencial, solo service account)", s.ClientID)
if p.Plan {
// Sin client no podemos mirar más: lo demás se crearía entero.
for _, a := range s.Audiences {
change("asignar el scope %s como Default", a)
}
for api, roles := range s.APIRoles {
for _, r := range roles {
exists, err := p.roleExists(ctx, api, r)
if err != nil {
return res, err
}
if !exists {
change("crear el rol %s en %s", r, api)
}
change("dar a la service account el rol %s de %s", r, api)
}
}
return res, nil
}
id, err := p.KC.CreateClient(ctx, p.Token, p.Realm, wantedClient(s))
if err != nil {
return res, fmt.Errorf("crear client %s: %w", s.ClientID, err)
}
if client, err = p.KC.GetClient(ctx, p.Token, p.Realm, id); err != nil {
return res, err
}
cred, err := p.KC.GetClientSecret(ctx, p.Token, p.Realm, id)
if err != nil {
return res, err
}
res.Secret = gocloak.PString(cred.Value)
} else if diffs := drift(client, s); len(diffs) > 0 {
for _, d := range diffs {
change("corregir %s", d)
}
if !p.Plan {
fix(client, s)
if err := p.KC.UpdateClient(ctx, p.Token, p.Realm, *client); err != nil {
return res, fmt.Errorf("actualizar client %s: %w", s.ClientID, err)
}
}
}
clientID := gocloak.PString(client.ID)
// 2. Audiencias: los client scopes, asignados como Default.
assigned, err := p.KC.GetClientsDefaultScopes(ctx, p.Token, p.Realm, clientID)
if err != nil {
return res, err
}
for _, name := range s.Audiences {
if slices.ContainsFunc(assigned, func(cs *gocloak.ClientScope) bool { return gocloak.PString(cs.Name) == name }) {
continue
}
scopeID, err := p.scopeID(ctx, name)
if err != nil {
return res, err
}
change("asignar el scope %s como Default", name)
if !p.Plan {
if err := p.KC.AddDefaultScopeToClient(ctx, p.Token, p.Realm, clientID, scopeID); err != nil {
return res, fmt.Errorf("asignar scope %s: %w", name, err)
}
}
}
// 3. Permisos: roles de client de cada API para la service account.
sa, err := p.KC.GetClientServiceAccount(ctx, p.Token, p.Realm, clientID)
if err != nil {
return res, fmt.Errorf("service account de %s: %w", s.ClientID, err)
}
for api, roles := range s.APIRoles {
apiClient, err := p.findClient(ctx, api)
if err != nil {
return res, err
}
if apiClient == nil {
return res, fmt.Errorf("la API %s no existe como client", api)
}
apiID := gocloak.PString(apiClient.ID)
have, err := p.KC.GetClientRolesByUserID(ctx, p.Token, p.Realm, apiID, gocloak.PString(sa.ID))
if err != nil {
return res, err
}
for _, roleName := range roles {
if slices.ContainsFunc(have, func(r *gocloak.Role) bool { return gocloak.PString(r.Name) == roleName }) {
continue
}
role, err := p.KC.GetClientRole(ctx, p.Token, p.Realm, apiID, roleName)
if isNotFound(err) {
change("crear el rol %s en %s", roleName, api)
if p.Plan {
change("dar a la service account el rol %s de %s", roleName, api)
continue
}
if _, err = p.KC.CreateClientRole(ctx, p.Token, p.Realm, apiID, gocloak.Role{Name: gocloak.StringP(roleName)}); err != nil {
return res, err
}
role, err = p.KC.GetClientRole(ctx, p.Token, p.Realm, apiID, roleName)
}
if err != nil {
return res, err
}
change("dar a la service account el rol %s de %s", roleName, api)
if !p.Plan {
if err := p.KC.AddClientRolesToUser(ctx, p.Token, p.Realm, apiID, gocloak.PString(sa.ID), []gocloak.Role{*role}); err != nil {
return res, err
}
}
}
}
return res, nil
}
// drift lista en qué se aparta un client existente de lo deseado.
func drift(c *gocloak.Client, s Service) []string {
want := wantedClient(s)
var out []string
check := func(name string, got, wanted *bool) {
if gocloak.PBool(got) != gocloak.PBool(wanted) {
out = append(out, fmt.Sprintf("%s: %v → %v", name, gocloak.PBool(got), gocloak.PBool(wanted)))
}
}
check("enabled", c.Enabled, want.Enabled)
check("publicClient", c.PublicClient, want.PublicClient)
check("serviceAccountsEnabled", c.ServiceAccountsEnabled, want.ServiceAccountsEnabled)
check("standardFlowEnabled", c.StandardFlowEnabled, want.StandardFlowEnabled)
check("implicitFlowEnabled", c.ImplicitFlowEnabled, want.ImplicitFlowEnabled)
check("directAccessGrantsEnabled", c.DirectAccessGrantsEnabled, want.DirectAccessGrantsEnabled)
if gocloak.PString(c.Description) != s.Description {
out = append(out, fmt.Sprintf("description: %q → %q", gocloak.PString(c.Description), s.Description))
}
return out
}
// fix pone en c la configuración que exige un servicio interno.
func fix(c *gocloak.Client, s Service) {
c.Description = gocloak.StringP(s.Description)
c.Enabled = gocloak.BoolP(true)
c.PublicClient = gocloak.BoolP(false)
c.ClientAuthenticatorType = gocloak.StringP("client-secret")
c.ServiceAccountsEnabled = gocloak.BoolP(true)
c.StandardFlowEnabled = gocloak.BoolP(false)
c.ImplicitFlowEnabled = gocloak.BoolP(false)
c.DirectAccessGrantsEnabled = gocloak.BoolP(false)
}
Ver internal/provision/provision.go y cmd/provision/main.go completos
// Package provision lleva la configuración de Keycloak a un estado deseado,
// descrito en un archivo: qué servicios internos existen, a qué APIs pueden
// llamar (audiencias) y con qué permisos (roles de client de esas APIs).
//
// Cada paso compara lo que hay con lo que debería haber y solo cambia la
// diferencia: aplicarlo dos veces no hace nada la segunda (idempotencia).
// Con Plan=true no cambia nada: solo dice lo que haría.
package provision
import (
"context"
"errors"
"fmt"
"net/http"
"slices"
"github.com/Nerzal/gocloak/v14"
)
// Service describe un servicio interno que llama a APIs con Client Credentials.
type Service struct {
ClientID string `json:"clientId"`
Description string `json:"descripcion"`
Audiences []string `json:"audiencias"` // client scopes de audiencia, como Default
APIRoles map[string][]string `json:"rolesDeAPI"` // client de la API → roles para la service account
}
// Desired es el contenido del archivo de estado deseado.
type Desired struct {
Services []Service `json:"servicios"`
}
// Provisioner aplica (o planifica) el estado deseado en un realm.
type Provisioner struct {
KC *gocloak.GoCloak
Token string // token de admin-tool (manage-clients, manage-users…)
Realm string
Plan bool // true: no cambia nada, solo lista los cambios
}
// Result es lo que se hizo (o se haría) con un servicio.
type Result struct {
Changes []string
Secret string // solo si se acaba de crear el client
}
// Apply lleva un servicio al estado deseado.
func (p *Provisioner) Apply(ctx context.Context, s Service) (Result, error) {
var res Result
change := func(format string, args ...any) { res.Changes = append(res.Changes, fmt.Sprintf(format, args...)) }
// 1. El client: confidencial, solo con service account.
client, err := p.findClient(ctx, s.ClientID)
if err != nil {
return res, err
}
if client == nil {
change("crear client %s (confidencial, solo service account)", s.ClientID)
if p.Plan {
// Sin client no podemos mirar más: lo demás se crearía entero.
for _, a := range s.Audiences {
change("asignar el scope %s como Default", a)
}
for api, roles := range s.APIRoles {
for _, r := range roles {
exists, err := p.roleExists(ctx, api, r)
if err != nil {
return res, err
}
if !exists {
change("crear el rol %s en %s", r, api)
}
change("dar a la service account el rol %s de %s", r, api)
}
}
return res, nil
}
id, err := p.KC.CreateClient(ctx, p.Token, p.Realm, wantedClient(s))
if err != nil {
return res, fmt.Errorf("crear client %s: %w", s.ClientID, err)
}
if client, err = p.KC.GetClient(ctx, p.Token, p.Realm, id); err != nil {
return res, err
}
cred, err := p.KC.GetClientSecret(ctx, p.Token, p.Realm, id)
if err != nil {
return res, err
}
res.Secret = gocloak.PString(cred.Value)
} else if diffs := drift(client, s); len(diffs) > 0 {
for _, d := range diffs {
change("corregir %s", d)
}
if !p.Plan {
fix(client, s)
if err := p.KC.UpdateClient(ctx, p.Token, p.Realm, *client); err != nil {
return res, fmt.Errorf("actualizar client %s: %w", s.ClientID, err)
}
}
}
clientID := gocloak.PString(client.ID)
// 2. Audiencias: los client scopes, asignados como Default.
assigned, err := p.KC.GetClientsDefaultScopes(ctx, p.Token, p.Realm, clientID)
if err != nil {
return res, err
}
for _, name := range s.Audiences {
if slices.ContainsFunc(assigned, func(cs *gocloak.ClientScope) bool { return gocloak.PString(cs.Name) == name }) {
continue
}
scopeID, err := p.scopeID(ctx, name)
if err != nil {
return res, err
}
change("asignar el scope %s como Default", name)
if !p.Plan {
if err := p.KC.AddDefaultScopeToClient(ctx, p.Token, p.Realm, clientID, scopeID); err != nil {
return res, fmt.Errorf("asignar scope %s: %w", name, err)
}
}
}
// 3. Permisos: roles de client de cada API para la service account.
sa, err := p.KC.GetClientServiceAccount(ctx, p.Token, p.Realm, clientID)
if err != nil {
return res, fmt.Errorf("service account de %s: %w", s.ClientID, err)
}
for api, roles := range s.APIRoles {
apiClient, err := p.findClient(ctx, api)
if err != nil {
return res, err
}
if apiClient == nil {
return res, fmt.Errorf("la API %s no existe como client", api)
}
apiID := gocloak.PString(apiClient.ID)
have, err := p.KC.GetClientRolesByUserID(ctx, p.Token, p.Realm, apiID, gocloak.PString(sa.ID))
if err != nil {
return res, err
}
for _, roleName := range roles {
if slices.ContainsFunc(have, func(r *gocloak.Role) bool { return gocloak.PString(r.Name) == roleName }) {
continue
}
role, err := p.KC.GetClientRole(ctx, p.Token, p.Realm, apiID, roleName)
if isNotFound(err) {
change("crear el rol %s en %s", roleName, api)
if p.Plan {
change("dar a la service account el rol %s de %s", roleName, api)
continue
}
if _, err = p.KC.CreateClientRole(ctx, p.Token, p.Realm, apiID, gocloak.Role{Name: gocloak.StringP(roleName)}); err != nil {
return res, err
}
role, err = p.KC.GetClientRole(ctx, p.Token, p.Realm, apiID, roleName)
}
if err != nil {
return res, err
}
change("dar a la service account el rol %s de %s", roleName, api)
if !p.Plan {
if err := p.KC.AddClientRolesToUser(ctx, p.Token, p.Realm, apiID, gocloak.PString(sa.ID), []gocloak.Role{*role}); err != nil {
return res, err
}
}
}
}
return res, nil
}
// wantedClient es el client tal como debe quedar al crearlo.
func wantedClient(s Service) gocloak.Client {
c := gocloak.Client{ClientID: gocloak.StringP(s.ClientID)}
fix(&c, s)
return c
}
// fix pone en c la configuración que exige un servicio interno.
func fix(c *gocloak.Client, s Service) {
c.Description = gocloak.StringP(s.Description)
c.Enabled = gocloak.BoolP(true)
c.PublicClient = gocloak.BoolP(false)
c.ClientAuthenticatorType = gocloak.StringP("client-secret")
c.ServiceAccountsEnabled = gocloak.BoolP(true)
c.StandardFlowEnabled = gocloak.BoolP(false)
c.ImplicitFlowEnabled = gocloak.BoolP(false)
c.DirectAccessGrantsEnabled = gocloak.BoolP(false)
}
// drift lista en qué se aparta un client existente de lo deseado.
func drift(c *gocloak.Client, s Service) []string {
want := wantedClient(s)
var out []string
check := func(name string, got, wanted *bool) {
if gocloak.PBool(got) != gocloak.PBool(wanted) {
out = append(out, fmt.Sprintf("%s: %v → %v", name, gocloak.PBool(got), gocloak.PBool(wanted)))
}
}
check("enabled", c.Enabled, want.Enabled)
check("publicClient", c.PublicClient, want.PublicClient)
check("serviceAccountsEnabled", c.ServiceAccountsEnabled, want.ServiceAccountsEnabled)
check("standardFlowEnabled", c.StandardFlowEnabled, want.StandardFlowEnabled)
check("implicitFlowEnabled", c.ImplicitFlowEnabled, want.ImplicitFlowEnabled)
check("directAccessGrantsEnabled", c.DirectAccessGrantsEnabled, want.DirectAccessGrantsEnabled)
if gocloak.PString(c.Description) != s.Description {
out = append(out, fmt.Sprintf("description: %q → %q", gocloak.PString(c.Description), s.Description))
}
return out
}
// findClient busca un client por su clientId; nil si no existe.
func (p *Provisioner) findClient(ctx context.Context, clientID string) (*gocloak.Client, error) {
cs, err := p.KC.GetClients(ctx, p.Token, p.Realm, gocloak.GetClientsParams{ClientID: gocloak.StringP(clientID)})
if err != nil {
return nil, err
}
for _, c := range cs {
if gocloak.PString(c.ClientID) == clientID {
return c, nil
}
}
return nil, nil
}
// scopeID busca el ID de un client scope por su nombre.
func (p *Provisioner) scopeID(ctx context.Context, name string) (string, error) {
scopes, err := p.KC.GetClientScopes(ctx, p.Token, p.Realm)
if err != nil {
return "", err
}
for _, cs := range scopes {
if gocloak.PString(cs.Name) == name {
return gocloak.PString(cs.ID), nil
}
}
return "", fmt.Errorf("el client scope %s no existe", name)
}
// roleExists indica si la API (un client) tiene el rol de client roleName.
func (p *Provisioner) roleExists(ctx context.Context, api, roleName string) (bool, error) {
apiClient, err := p.findClient(ctx, api)
if err != nil || apiClient == nil {
return false, fmt.Errorf("la API %s no existe como client (%v)", api, err)
}
_, err = p.KC.GetClientRole(ctx, p.Token, p.Realm, gocloak.PString(apiClient.ID), roleName)
if isNotFound(err) {
return false, nil
}
return err == nil, err
}
func isNotFound(err error) bool {
var apiErr *gocloak.APIError
return errors.As(err, &apiErr) && apiErr.Code == http.StatusNotFound
}
// Command provision lleva los servicios internos de servicios.json a Keycloak:
// crea o corrige sus clients, audiencias y roles de API. Es idempotente.
//
// Uso (desde tienda/pasos/paso-10):
//
// go run ./cmd/provision -plan (solo muestra los cambios)
// go run ./cmd/provision (los aplica)
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"time"
"github.com/Nerzal/gocloak/v14"
"tienda/internal/provision"
)
func main() {
log.SetFlags(0)
file := flag.String("f", "servicios.json", "archivo con el estado deseado")
plan := flag.Bool("plan", false, "no cambiar nada: solo listar los cambios")
flag.Parse()
raw, err := os.ReadFile(*file)
if err != nil {
log.Fatal(err)
}
var desired provision.Desired
if err := json.Unmarshal(raw, &desired); err != nil {
log.Fatalf("%s: %v", *file, err)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
realm := env("KEYCLOAK_REALM", "tienda")
kc := gocloak.NewClient(env("KEYCLOAK_URL", "http://localhost:8080"))
jwt, err := kc.LoginClient(ctx, env("ADMIN_CLIENT_ID", "admin-tool"), env("ADMIN_CLIENT_SECRET", "admin-tool-secret"), realm)
if err != nil {
log.Fatalf("login de admin-tool: %v", err)
}
p := &provision.Provisioner{KC: kc, Token: jwt.AccessToken, Realm: realm, Plan: *plan}
verb := "aplicado"
if *plan {
verb = "plan"
}
for _, s := range desired.Services {
res, err := p.Apply(ctx, s)
if err != nil {
log.Fatalf("%s: %v", s.ClientID, err)
}
if len(res.Changes) == 0 {
fmt.Printf("= %s: sin cambios\n", s.ClientID)
continue
}
fmt.Printf("~ %s (%s):\n", s.ClientID, verb)
for _, c := range res.Changes {
fmt.Printf(" - %s\n", c)
}
if res.Secret != "" {
// Se muestra una sola vez: guárdalo en tu gestor de secretos.
fmt.Printf(" secreto del client: %s\n", res.Secret)
}
}
}
func env(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
UpdateClient envía la representación completa del client. Por eso Apply parte del client que devolvió Keycloak y cambia solo los campos que le importan: si construyeras uno nuevo con cuatro campos, borrarías el resto (por ejemplo, el atributo de token exchange de facturacion).
4. Probarlo
Todo lo que sigue es salida real. Primero, qué haría:
cd tienda/pasos/paso-10
go run ./cmd/provision -plan
# = facturacion: sin cambios
# ~ informes (plan):
# - crear client informes (confidencial, solo service account)
# - asignar el scope api-pedidos como Default
# - crear el rol informar en api-pedidos
# - dar a la service account el rol informar de api-pedidos
Aplicarlo:
go run ./cmd/provision
# = facturacion: sin cambios
# ~ informes (aplicado):
# - crear client informes (confidencial, solo service account)
# - asignar el scope api-pedidos como Default
# - crear el rol informar en api-pedidos
# - dar a la service account el rol informar de api-pedidos
# secreto del client: vxecjdzpvy8D4dNTr0yj0d2iVSmepbzu2N4FbXVLRYa9f5s…
go run ./cmd/provision # otra vez
# = facturacion: sin cambios
# = informes: sin cambios
Su token de Client Credentials lleva lo esperado: aud: ["api-pedidos", "account"] y resource_access.api-pedidos.roles: ["informar"].
Ahora alguien activa, desde la consola, Standard flow y Direct access grants en facturacion «para probar una cosa» y se olvida de quitarlo:
go run ./cmd/provision -plan
# ~ facturacion (plan):
# - corregir standardFlowEnabled: true → false
# - corregir directAccessGrantsEnabled: true → false
# = informes: sin cambios
go run ./cmd/provision
# ~ facturacion (aplicado):
# - corregir standardFlowEnabled: true → false
# - corregir directAccessGrantsEnabled: true → false
# = informes: sin cambios
5. En un proyecto real
- En la integración continua:
-planen cada pull request (que se vea qué cambiará) y la aplicación al fusionar. Ejecutar el plan periódicamente detecta desviaciones como la de arriba. - Secretos: imprimirlo por consola es aceptable en el curso. En producción, escríbelo directamente en tu gestor de secretos y no lo muestres nunca.
- El aprovisionador es casi un administrador. Con
manage-clientspuede crear un client y darle permisos. Protege su secreto como el de un administrador y limita quién puede ejecutarlo. - ¿Quién crea el aprovisionador? Alguien tiene que crear
admin-toolla primera vez. Aquí lo hace el JSON del realm; en otros entornos, un paso manual documentado o una herramienta con el admin de master, una sola vez. - Una sola fuente de verdad. Si el equipo también cambia esos clients desde la consola, el aprovisionador y las personas se pisarán. Decide qué gestiona el código y que nadie lo toque a mano.
Ejercicios
1. Que la API use el rol informar · fácil
El rol informar existe, pero ninguna ruta lo exige. Añade a api-pedidos un GET /informes/resumen que cuente los pedidos e importes por estado, solo para ese rol.
Ver solución
// en Register:
mux.Handle("GET /informes/resumen", protect(v, h.summary, apiauth.RequireAPIRole("informar")))
// summary cuenta los pedidos y suma sus importes por estado (para informes).
func (h *handlers) summary(w http.ResponseWriter, r *http.Request) {
type linea struct {
Pedidos int `json:"pedidos"`
Importe float64 `json:"importe"`
}
resumen := map[string]*linea{}
for _, o := range h.store.All() {
l := resumen[o.Status]
if l == nil {
l = &linea{}
resumen[o.Status] = l
}
l.Pedidos++
l.Importe += o.Total
}
jsonhttp.Write(w, http.StatusOK, resumen)
}
Con un token de informes (Client Credentials), salida real:
{"Entregado":{"pedidos":1,"importe":30},"Enviado":{"pedidos":1,"importe":29.88},"Pendiente":{"pedidos":1,"importe":9.5}}
2. Rotar un secreto · fácil
Añade un flag -rotar <clientId> que regenere el secreto de un client y muestre el nuevo.
Ver solución
rotar := flag.String("rotar", "", "clientId cuyo secreto regenerar")
// … después de crear p:
if *rotar != "" {
clients, err := kc.GetClients(ctx, jwt.AccessToken, realm, gocloak.GetClientsParams{ClientID: rotar})
if err != nil || len(clients) == 0 {
log.Fatalf("client %s: %v", *rotar, err)
}
cred, err := kc.RegenerateClientSecret(ctx, jwt.AccessToken, realm, gocloak.PString(clients[0].ID))
if err != nil {
log.Fatal(err)
}
fmt.Printf("nuevo secreto de %s: %s\n", *rotar, gocloak.PString(cred.Value))
return
}
Probado: tras rotar el de informes, el secreto antiguo deja de valer y el nuevo obtiene tokens. Recuerda la lección 7: los servicios que ya tenían un token siguen funcionando hasta que caduca.
3. ¿Y si quito algo del archivo? · media
Borra "api-pedidos" de las audiencias de informes y aplica. ¿Desaparece el scope del client? ¿Cómo lo harías?
Ver solución
No: este aprovisionador solo añade lo que falta. Para quitar, tiene que saber qué le pertenece. Si eliminara todo scope que no esté en el archivo, borraría profile, email, roles… Un enfoque sensato: definir qué gestiona (por ejemplo, solo los scopes que son audiencias de APIs, que podrías marcar con un prefijo o un atributo) y quitar de ese conjunto los que no estén en el archivo, con RemoveDefaultScopeFromClient. Es la diferencia entre un aprovisionador aditivo y uno completo («prune» en la jerga de Kubernetes o Terraform), y la razón de que estas herramientas tengan opciones como managed o prune.
Errores comunes
403 Forbidden «crear client ventas: 403 Forbidden»
A admin-tool le falta manage-clients (probado quitándoselo). Para leer clients basta view-clients; para asignar roles a una service account hace falta además manage-users.
client a medias El programa falló a mitad de un servicio
La Admin API no tiene transacciones: si falla la asignación de un rol, el client ya está creado. Gracias a la idempotencia, la solución es simplemente volver a ejecutar: cada paso comprueba lo que ya existe. El único dato que se pierde es el secreto mostrado al crear; recupéralo en la consola o rótalo (ejercicio 2).
campos borrados Tras actualizar, el client perdió configuración
Llamaste a UpdateClient con un struct construido desde cero. Arreglo: lee el client, modifícalo y envíalo entero.
el client scope … no existe
El aprovisionador asigna scopes de audiencia, pero no los crea. Crea el scope primero (en el realm o ampliando el aprovisionador con CreateClientScope y su mapper).
ping-pong Cada ejecución «corrige» lo mismo
Otra herramienta, o una persona, cambia el mismo campo en sentido contrario. Hay dos fuentes de verdad: elige una.
La Admin REST API permite automatizar todo lo que haces en la consola. Con una service account de permisos mínimos y gocloak, Go gestiona usuarios y grupos (lección 9) y aprovisiona servicios de forma declarativa e idempotente, con plan y corrección de desviaciones (esta lección). Con esto, la tienda está completa: login, API protegida, servicios con y sin usuario, y su configuración como código.

