diff --git a/pkg/services/cloudflare_service.go b/pkg/services/cloudflare_service.go index 8d444ca..c18b084 100644 --- a/pkg/services/cloudflare_service.go +++ b/pkg/services/cloudflare_service.go @@ -182,6 +182,28 @@ type CFTokenVerify struct { ExpiresOn string `json:"expires_on"` } +// CFTokenPolicy representa una política de permisos del token. +type CFTokenPolicy struct { + ID string `json:"id"` + Effect string `json:"effect"` + Resources map[string]string `json:"resources"` + PermGroups []CFTokenPermGroup `json:"permission_groups"` +} + +// CFTokenPermGroup un grupo de permisos. +type CFTokenPermGroup struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// CFTokenDetail detalle completo de un token incluyendo sus políticas. +type CFTokenDetail struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Policies []CFTokenPolicy `json:"policies"` +} + // VerifyToken verifica la validez del API token usando /user/tokens/verify. func (c *CloudflareClient) VerifyToken() (*CFTokenVerify, error) { var result CFTokenVerify @@ -191,6 +213,15 @@ func (c *CloudflareClient) VerifyToken() (*CFTokenVerify, error) { return &result, nil } +// GetTokenDetail obtiene el detalle completo de un token (permisos y recursos). +func (c *CloudflareClient) GetTokenDetail(tokenID string) (*CFTokenDetail, error) { + var detail CFTokenDetail + if err := c.get("/user/tokens/"+tokenID, &detail); err != nil { + return nil, err + } + return &detail, nil +} + // GetZones lista todas las zonas de la cuenta. func (c *CloudflareClient) GetZones() ([]CFZone, error) { var zones []CFZone diff --git a/rest/controllers/cloudflare_controller.go b/rest/controllers/cloudflare_controller.go index 2918f9c..2eb172b 100644 --- a/rest/controllers/cloudflare_controller.go +++ b/rest/controllers/cloudflare_controller.go @@ -101,7 +101,7 @@ func cloudflareClient() (*services.CloudflareClient, error) { return services.NewCloudflareClient(sanitizeCFToken(cfg.APIToken), sanitizeCFToken(cfg.AccountID)), nil } -// VerifyCloudflareToken verifica que el token activo sea válido contra la API de Cloudflare. +// VerifyCloudflareToken verifica el token y devuelve sus permisos reales. func VerifyCloudflareToken(c *fiber.Ctx) error { cfg, err := models.GetCloudflareConfig() if err != nil { @@ -116,70 +116,77 @@ func VerifyCloudflareToken(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"ok": false, "error": "Token vacío"}) } dirty := len([]rune(raw)) != len([]rune(token)) - client := services.NewCloudflareClient(token, sanitizeCFToken(cfg.AccountID)) - - // Primer carácter y último para diagnóstico sin exponer el token completo runes := []rune(token) preview := "" if len(runes) >= 8 { preview = string(runes[:4]) + "…" + string(runes[len(runes)-4:]) } - // 1. Verificar que el token sea válido + client := services.NewCloudflareClient(token, sanitizeCFToken(cfg.AccountID)) + + // 1. Verificar token verify, err := client.VerifyToken() if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "ok": false, "step": "verify_token", "error": err.Error(), - "hint": "El token no es válido. Crea uno nuevo en dash.cloudflare.com → Mi Perfil → API Tokens", + "hint": "Token inválido. Crea uno nuevo en dash.cloudflare.com → Mi Perfil → API Tokens", "token_len": len(token), "token_prev": preview, "was_dirty": dirty, }) } - // 2. Probar Zone:Zone:Read (listar zonas) - _, zonesErr := client.GetZones() + // 2. Obtener políticas reales del token + detail, detailErr := client.GetTokenDetail(verify.ID) + detailErrMsg := "" + if detailErr != nil { + detailErrMsg = detailErr.Error() + } + + // 3. Probar Zone:Zone:Read + zones, zonesErr := client.GetZones() zonesOK := zonesErr == nil zonesErrMsg := "" if zonesErr != nil { zonesErrMsg = zonesErr.Error() } - // 3. Probar Zone:DNS:Read (leer DNS de la primera zona disponible) + // 4. Probar Zone:DNS:Read en la primera zona dnsOK := false dnsErrMsg := "" - if zonesOK { - zones, _ := client.GetZones() - if len(zones) > 0 { - _, dnsErr := client.GetDNSRecords(zones[0].ID) - dnsOK = dnsErr == nil - if dnsErr != nil { - dnsErrMsg = dnsErr.Error() - } + dnsZoneID := "" + if zonesOK && len(zones) > 0 { + dnsZoneID = zones[0].ID + _, dnsErr := client.GetDNSRecords(dnsZoneID) + dnsOK = dnsErr == nil + if dnsErr != nil { + dnsErrMsg = dnsErr.Error() } } + hint := "Token válido con todos los permisos necesarios" + if !zonesOK { + hint = "Falta permiso Zone:Zone:Read" + } else if !dnsOK { + hint = "Falta permiso Zone:DNS:Read — o el token tiene scope de zonas específicas y no incluye la zona consultada. En Cloudflare: edita el token → Zone Resources → cambia a 'All zones'." + } + return c.JSON(fiber.Map{ - "ok": true, - "token_len": len(token), - "token_prev": preview, - "was_dirty": dirty, + "ok": true, "token_status": verify.Status, - "permissions": fiber.Map{ - "zone_read": fiber.Map{"ok": zonesOK, "error": zonesErrMsg}, - "dns_read": fiber.Map{"ok": dnsOK, "error": dnsErrMsg}, + "token_id": verify.ID, + "token_len": len(token), + "token_prev": preview, + "was_dirty": dirty, + "detail_error": detailErrMsg, + "policies": func() interface{} { if detail != nil { return detail.Policies }; return nil }(), + "permissions_test": fiber.Map{ + "zone_read": fiber.Map{"ok": zonesOK, "error": zonesErrMsg}, + "dns_read": fiber.Map{"ok": dnsOK, "error": dnsErrMsg, "zone_tested": dnsZoneID}, }, - "hint": func() string { - if !zonesOK { - return "Falta permiso Zone:Zone:Read en el token" - } - if !dnsOK { - return "Falta permiso Zone:DNS:Read en el token. Edita el token en Cloudflare y agrega ese permiso." - } - return "Token válido con todos los permisos necesarios" - }(), + "hint": hint, }) }