Qué cambió — paso 10
Qué cambió · paso-09 → paso-10

Cambios de la lección 10

Todo lo que cambia en tienda/pasos/paso-10 respecto al paso anterior. Vuelve a la lección: 10. Automatizar el realm desde Go.

4 archivos cambian. En verde lo que se añade; en rojo lo que se quita. go.sum no se muestra.

ArchivoEstadoLíneas
cmd/provision/main.gonuevo+79 −0
infra/realm/tienda-realm.jsonmodificado+3 −4
internal/provision/provision.gonuevo+256 −0
servicios.jsonnuevo+16 −0
cmd/provision/main.go nuevo · +79 −0
@@ -0,0 +1,79 @@
+// 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
+}
infra/realm/tienda-realm.json modificado · +3 −4 · generado, plegado
@@ -121,11 +121,10 @@
       ],
       "clientRoles": {
         "realm-management": [
-          "view-users",
-          "query-users",
           "manage-users",
-          "query-groups",
-          "view-realm"
+          "view-realm",
+          "view-clients",
+          "manage-clients"
         ]
       }
     }
internal/provision/provision.go nuevo · +256 −0
@@ -0,0 +1,256 @@
+// 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
+}
servicios.json nuevo · +16 −0
@@ -0,0 +1,16 @@
+{
+  "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"] }
+    }
+  ]
+}

← Volver a la lección 10