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.
| Archivo | Estado | Líneas |
|---|---|---|
cmd/provision/main.go | nuevo | +79 −0 |
infra/realm/tienda-realm.json | modificado | +3 −4 |
internal/provision/provision.go | nuevo | +256 −0 |
servicios.json | nuevo | +16 −0 |
cmd/provision/main.go
@@ -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
@@ -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
@@ -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
@@ -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"] }
+ }
+ ]
+}