From 40dfaf5773f407f24c179b0d3bba3b07c509ee3f Mon Sep 17 00:00:00 2001 From: Lizandro GD Date: Mon, 10 Aug 2026 16:33:57 +0000 Subject: [PATCH] Agrega API Keys scoped (token + IP obligatoria + alcance) para /api/v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alternativa al ADMIN_API_KEY único de entorno, que sigue funcionando como llave maestra para no romper integraciones existentes. - Modelo ApiKey: token hasheado, IP/CIDR obligatoria (fail-closed sin IP), scopes habilitados, último uso (fecha + IP). - AdminApiAuth() acepta ahora tanto la llave maestra como una ApiKey; nuevo middleware RequireScope(scope) para gatear grupos de rutas. - Fase 1: scopes aplicados a lo más sensible — oss (archivos), query_runner (SQL arbitrario), usuarios (usuarios/roles/módulos), pasarelas (credenciales de pago). El resto de /api/v2 sigue con la llave maestra hasta una fase 2. - Panel /app/api-keys: crear/editar/revocar, token visible solo al crear, scopes por checkbox, IP obligatoria. --- main.go | 3 + migrations/migrate.go | 39 +++ pkg/models/api_key.go | 172 ++++++++++++ resources/views/api_keys.html | 279 +++++++++++++++++++ rest/controllers/api_key_admin_controller.go | 157 +++++++++++ rest/middlewares/hermes_auth.go | 47 +++- rest/routes/hermes.go | 101 ++++--- rest/routes/user.go | 10 + 8 files changed, 759 insertions(+), 49 deletions(-) create mode 100644 pkg/models/api_key.go create mode 100644 resources/views/api_keys.html create mode 100644 rest/controllers/api_key_admin_controller.go diff --git a/main.go b/main.go index f13e5bc..064f8ea 100755 --- a/main.go +++ b/main.go @@ -151,6 +151,8 @@ func main() { &models.UmindDocumento{}, &models.UmindChunk{}, &models.UmindMensaje{}, + // API Keys de /api/v2 (token + IP obligatoria + scopes) + &models.ApiKey{}, } for _, m := range modelosBase { if err := app.Http.Database.DB.AutoMigrate(m); err != nil { @@ -186,6 +188,7 @@ func main() { migrations.SeedPagosExternos() migrations.SeedAutomatizacionIA() migrations.SeedUmind() + migrations.SeedApiKeys() if n, err := models.RepararEstadosTareaInvalidos(); err != nil { log.Printf("[FIX] Error reparando estados de tareas: %v", err) } else if n > 0 { diff --git a/migrations/migrate.go b/migrations/migrate.go index 425cef7..bddad2f 100755 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -122,6 +122,8 @@ func Migrate() { &models.UmindDocumento{}, &models.UmindChunk{}, &models.UmindMensaje{}, + // API Keys de /api/v2 (token + IP obligatoria + scopes) + &models.ApiKey{}, } for _, m := range modelosPrincipales { if err := app.Http.Database.DB.Migrator().AutoMigrate(m); err != nil { @@ -1309,3 +1311,40 @@ func SeedUmind() { } log.Println("[SEED] Seed de uMind completado.") } + +// SeedApiKeys registra el submódulo del panel de administración de API Keys +// (credenciales scoped para /api/v2, alternativa al ADMIN_API_KEY maestro). +func SeedApiKeys() { + db := app.Http.Database.DB + var modulo models.Modules + if err := db.Where("title = ?", "Administración").First(&modulo).Error; err != nil { + log.Println("[SEED] Módulo 'Administración' no encontrado, se omite SeedApiKeys") + return + } + url := "/app/api-keys" + var sub models.Submodules + if err := db.Where("url = ?", url).First(&sub).Error; err != nil { + sub = models.Submodules{ + Title: "API Keys", + Description: "Credenciales scoped para /api/v2 (token + IP + alcance), alternativa a la llave maestra", + Url: url, + ModuleId: modulo.ID, + ModifiedAt: time.Now(), + } + if err := db.Create(&sub).Error; err != nil { + log.Printf("[SEED] Error creando submódulo 'API Keys': %v", err) + return + } + log.Printf("[SEED] Submódulo 'API Keys' creado") + } else if sub.ModuleId != modulo.ID { + db.Model(&sub).Update("module_id", modulo.ID) + } + var rol models.Roles + if err := db.Where("name = ?", "Administrador").First(&rol).Error; err != nil { + log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err) + } else { + asignarSubmodulosSiFaltan(&rol, []models.Submodules{sub}) + log.Printf("[SEED] Submódulo 'API Keys' asignado al rol 'Administrador'") + } + log.Println("[SEED] Seed de API Keys completado.") +} diff --git a/pkg/models/api_key.go b/pkg/models/api_key.go new file mode 100644 index 0000000..9f33bf5 --- /dev/null +++ b/pkg/models/api_key.go @@ -0,0 +1,172 @@ +package models + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "net" + "strings" + "time" + + "github.com/sujit-baniya/fiber-boilerplate/app" + "gorm.io/gorm" +) + +// ApiKey es una credencial para /api/v2, alternativa al ADMIN_API_KEY único de +// entorno (que sigue funcionando como llave maestra para no romper lo que ya +// depende de él). A diferencia de esa llave maestra, cada ApiKey: +// - Solo funciona desde la IP/CIDR que se le asignó (obligatoria, no opcional +// — a diferencia del patrón de Pagos Externos, aquí se decidió exigirla +// siempre porque esta llave puede dar acceso a datos internos, no solo a +// pedir un cobro). +// - Solo puede usar los scopes (grupos de endpoints) que se le habilitaron. +// - Se puede revocar individualmente sin afectar a otras integraciones. +type ApiKey struct { + gorm.Model + Nombre string `json:"nombre" gorm:"column:nombre;size:150;not null"` + TokenHash string `json:"-" gorm:"column:token_hash;uniqueIndex;size:64"` + TokenPreview string `json:"token_preview" gorm:"column:token_preview;size:12"` + IPPermitida string `json:"ip_permitida" gorm:"column:ip_permitida;size:60;not null"` // IP exacta o CIDR, obligatoria + Scopes string `json:"scopes" gorm:"column:scopes;size:300"` // comma-separated, ej: "oss,query_runner" + Activa bool `json:"activa" gorm:"column:activa;default:true"` + CreadoPorID uint `json:"creado_por_id" gorm:"column:creado_por_id"` + UltimoUsoAt *time.Time `json:"ultimo_uso_at" gorm:"column:ultimo_uso_at"` + UltimoUsoIP string `json:"ultimo_uso_ip" gorm:"column:ultimo_uso_ip;size:60"` +} + +func (ApiKey) TableName() string { return "api_keys" } + +// GenerarApiKeyToken crea un token aleatorio de 32 bytes y su hash SHA-256, +// mismo esquema que ServicioPagoExterno: el token crudo se devuelve una sola +// vez, en la base solo queda el hash. +func GenerarApiKeyToken() (raw string, hash string, err error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", "", fmt.Errorf("no se pudo generar el token: %w", err) + } + raw = "sak_" + hex.EncodeToString(b) + sum := sha256.Sum256([]byte(raw)) + hash = hex.EncodeToString(sum[:]) + return raw, hash, nil +} + +func apiKeyTokenPreview(raw string) string { + if len(raw) <= 8 { + return raw + } + return "..." + raw[len(raw)-6:] +} + +func CreateApiKey(k *ApiKey) (tokenPlano string, err error) { + raw, hash, err := GenerarApiKeyToken() + if err != nil { + return "", err + } + k.TokenHash = hash + k.TokenPreview = apiKeyTokenPreview(raw) + if err := app.Http.Database.DB.Create(k).Error; err != nil { + return "", err + } + return raw, nil +} + +func RegenerarApiKeyToken(id uint) (tokenPlano string, err error) { + raw, hash, err := GenerarApiKeyToken() + if err != nil { + return "", err + } + result := app.Http.Database.DB.Model(&ApiKey{}).Where("id = ?", id). + Updates(map[string]interface{}{"token_hash": hash, "token_preview": apiKeyTokenPreview(raw)}) + if result.Error != nil { + return "", result.Error + } + if result.RowsAffected == 0 { + return "", fmt.Errorf("api key no encontrada") + } + return raw, nil +} + +func GetAllApiKeys(limit, offset int) ([]ApiKey, int64, error) { + var items []ApiKey + var total int64 + db := app.Http.Database.DB.Model(&ApiKey{}) + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + if err := db.Order("id DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil { + return nil, 0, err + } + return items, total, nil +} + +func GetApiKeyByID(id uint) (*ApiKey, error) { + var k ApiKey + if err := app.Http.Database.DB.First(&k, id).Error; err != nil { + return nil, err + } + return &k, nil +} + +// FindApiKeyActivaByToken resuelve la llave a partir del token crudo recibido +// en el header Authorization/X-API-Key. Solo hace match si está activa. +func FindApiKeyActivaByToken(rawToken string) (*ApiKey, error) { + sum := sha256.Sum256([]byte(rawToken)) + hash := hex.EncodeToString(sum[:]) + var k ApiKey + if err := app.Http.Database.DB.Where("token_hash = ? AND activa = ?", hash, true).First(&k).Error; err != nil { + return nil, err + } + return &k, nil +} + +func UpdateApiKey(id uint, updates map[string]interface{}) error { + return app.Http.Database.DB.Model(&ApiKey{}).Where("id = ?", id).Updates(updates).Error +} + +func DeleteApiKey(id uint) error { + return app.Http.Database.DB.Delete(&ApiKey{}, id).Error +} + +// RegistrarUsoApiKey deja constancia de la última vez (y desde qué IP) que se +// usó la llave, para poder detectar llaves zombis o uso desde un origen raro. +func RegistrarUsoApiKey(id uint, ip string) { + now := time.Now() + app.Http.Database.DB.Model(&ApiKey{}).Where("id = ?", id). + Updates(map[string]interface{}{"ultimo_uso_at": now, "ultimo_uso_ip": ip}) +} + +// IPPermitida valida la IP del caller contra el CIDR/IP exacta configurada. +// A diferencia de ServicioPagoExterno, aquí es obligatoria: una ApiKey sin +// IP válida configurada nunca debe dar acceso (fail-closed). +func (k *ApiKey) IPValida(ip string) bool { + entrada := strings.TrimSpace(k.IPPermitida) + if entrada == "" { + return false + } + callerIP := net.ParseIP(ip) + if callerIP == nil { + return false + } + if strings.Contains(entrada, "/") { + _, red, err := net.ParseCIDR(entrada) + return err == nil && red.Contains(callerIP) + } + permitida := net.ParseIP(entrada) + return permitida != nil && permitida.Equal(callerIP) +} + +// ScopesList devuelve los scopes habilitados para esta llave. +func (k *ApiKey) ScopesList() []string { + return SplitModulos(k.Scopes) +} + +// TieneScope indica si la llave puede usar ese grupo de endpoints. +func (k *ApiKey) TieneScope(scope string) bool { + for _, s := range k.ScopesList() { + if s == scope { + return true + } + } + return false +} diff --git a/resources/views/api_keys.html b/resources/views/api_keys.html new file mode 100644 index 0000000..3e1e81f --- /dev/null +++ b/resources/views/api_keys.html @@ -0,0 +1,279 @@ + +
+ +
+ Cargando... +
+ +
+ +
+
+

API Keys

+

Credenciales para /api/v2 restringidas a una IP y a un alcance puntual — alternativa a compartir la llave maestra (ADMIN_API_KEY).

+
+ +
+ +
+
+
+ Solo estos scopes restringen de verdad hoy: oss, query_runner, usuarios, pasarelas. El resto de /api/v2 sigue funcionando únicamente con la llave maestra (fase 2 pendiente). +
+ +
+ + + + + + + + + + + + + + + + +
NombreTokenIP permitidaScopesÚltimo usoEstadoAcciones
+
+
+ + +
+
+

+
+
+
+ + +
+
+ + +

Obligatoria — sin ella la llave no puede usarse desde ningún lado.

+
+
+ +
+ +
+
+
+ + +
+
+ +
+ +
+ + +
+
+
+
+ + +
+
+

⚠️ Guarda este token ahora

+

No se volverá a mostrar completo.

+
+ + +
+
+ +
+
+
+ + +
+
+

¿Regenerar token?

+

El token actual dejará de funcionar de inmediato.

+
+ + +
+
+
+ + +
+
+

¿Eliminar API key?

+

Esta acción no se puede deshacer.

+
+ + +
+
+
+
+ + diff --git a/rest/controllers/api_key_admin_controller.go b/rest/controllers/api_key_admin_controller.go new file mode 100644 index 0000000..4c0b23f --- /dev/null +++ b/rest/controllers/api_key_admin_controller.go @@ -0,0 +1,157 @@ +package controllers + +import ( + "math" + "strconv" + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +// scopesValidos son los grupos de /api/v2 que hoy sí respetan RequireScope. +// El resto de endpoints de /api/v2 sigue funcionando solo con la llave +// maestra (ADMIN_API_KEY) mientras se migran en una fase posterior. +var scopesValidos = map[string]bool{ + "oss": true, + "query_runner": true, + "usuarios": true, + "pasarelas": true, +} + +// ApiKeysIndex renderiza el panel de administración de API keys. +func ApiKeysIndex(c *fiber.Ctx) error { + return c.Render("api_keys", fiber.Map{ + "user": c.Locals("user"), + "modules": c.Locals("userModules"), + }, "layouts/main") +} + +func GetApiKeys(c *fiber.Ctx) error { + page, _ := strconv.Atoi(c.Query("page", "1")) + if page < 1 { + page = 1 + } + limit := 20 + offset := (page - 1) * limit + items, total, err := models.GetAllApiKeys(limit, offset) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{ + "items": items, + "total": total, + "totalPages": int(math.Ceil(float64(total) / float64(limit))), + "page": page, + }) +} + +type apiKeyReq struct { + Nombre string `json:"nombre"` + IPPermitida string `json:"ip_permitida"` + Scopes []string `json:"scopes"` + Activa bool `json:"activa"` +} + +func (r apiKeyReq) validar() (scopes []string, err error) { + if strings.TrimSpace(r.Nombre) == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "nombre es requerido") + } + if strings.TrimSpace(r.IPPermitida) == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "ip_permitida es requerida (IP exacta o CIDR)") + } + for _, s := range r.Scopes { + s = strings.ToLower(strings.TrimSpace(s)) + if s == "" { + continue + } + if !scopesValidos[s] { + return nil, fiber.NewError(fiber.StatusBadRequest, "scope inválido: "+s) + } + scopes = append(scopes, s) + } + if len(scopes) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "selecciona al menos un scope") + } + return scopes, nil +} + +// CreateApiKeyHandler crea una nueva API key y devuelve el token en texto +// plano — es la única respuesta donde vendrá completo. +func CreateApiKeyHandler(c *fiber.Ctx) error { + var req apiKeyReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"}) + } + scopes, err := req.validar() + if err != nil { + return c.Status(err.(*fiber.Error).Code).JSON(fiber.Map{"error": err.Error()}) + } + + item := &models.ApiKey{ + Nombre: strings.TrimSpace(req.Nombre), + IPPermitida: strings.TrimSpace(req.IPPermitida), + Scopes: models.JoinModulos(scopes), + Activa: true, + CreadoPorID: extraerUserID(c), + } + tokenPlano, err := models.CreateApiKey(item) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(fiber.StatusCreated).JSON(fiber.Map{ + "ok": true, + "id": item.ID, + "token": tokenPlano, + "aviso": "Guarda este token ahora: no se volverá a mostrar completo.", + }) +} + +func UpdateApiKeyHandler(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"}) + } + var req apiKeyReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"}) + } + scopes, verr := req.validar() + if verr != nil { + return c.Status(verr.(*fiber.Error).Code).JSON(fiber.Map{"error": verr.Error()}) + } + + updates := map[string]interface{}{ + "nombre": strings.TrimSpace(req.Nombre), + "ip_permitida": strings.TrimSpace(req.IPPermitida), + "scopes": models.JoinModulos(scopes), + "activa": req.Activa, + } + if err := models.UpdateApiKey(uint(id), updates); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} + +func RegenerarApiKeyHandler(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"}) + } + tokenPlano, err := models.RegenerarApiKeyToken(uint(id)) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true, "token": tokenPlano, "aviso": "El token anterior dejó de funcionar. Guarda este ahora, no se volverá a mostrar."}) +} + +func DeleteApiKeyHandler(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"}) + } + if err := models.DeleteApiKey(uint(id)); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} diff --git a/rest/middlewares/hermes_auth.go b/rest/middlewares/hermes_auth.go index e1c3048..5d88fa2 100644 --- a/rest/middlewares/hermes_auth.go +++ b/rest/middlewares/hermes_auth.go @@ -5,15 +5,18 @@ import ( "strings" "github.com/gofiber/fiber/v2" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" ) +// AdminApiAuth protege /api/v2. Acepta dos tipos de credencial: +// 1. ADMIN_API_KEY de entorno — llave maestra, acceso total, la que ya usan +// las integraciones existentes (no se retira para no romperlas). +// 2. Una ApiKey emitida desde /app/api-keys — token propio, hasheado en DB, +// restringida a una IP/CIDR obligatoria y a los scopes que se le hayan +// habilitado. RequireScope() más adelante en la cadena decide si esa +// llave puede usar el grupo de endpoints puntual. func AdminApiAuth() fiber.Handler { return func(c *fiber.Ctx) error { - apiKey := os.Getenv("ADMIN_API_KEY") - if apiKey == "" { - return c.Status(503).JSON(fiber.Map{"error": "ADMIN_API_KEY not configured"}) - } - token := "" auth := c.Get("Authorization") if strings.HasPrefix(auth, "Bearer ") { @@ -22,11 +25,41 @@ func AdminApiAuth() fiber.Handler { if token == "" { token = c.Get("X-API-Key") } - - if token == "" || token != apiKey { + if token == "" { return c.Status(401).JSON(fiber.Map{"error": "unauthorized"}) } + if masterKey := os.Getenv("ADMIN_API_KEY"); masterKey != "" && token == masterKey { + c.Locals("api_key_master", true) + return c.Next() + } + + key, err := models.FindApiKeyActivaByToken(token) + if err != nil { + return c.Status(401).JSON(fiber.Map{"error": "unauthorized"}) + } + if !key.IPValida(c.IP()) { + return c.Status(403).JSON(fiber.Map{"error": "IP no autorizada para esta API key"}) + } + + go models.RegistrarUsoApiKey(key.ID, c.IP()) + c.Locals("api_key", key) + return c.Next() + } +} + +// RequireScope gatea un grupo de rutas a un scope puntual. La llave maestra +// (ADMIN_API_KEY de entorno) siempre pasa; una ApiKey scoped solo pasa si +// tiene ese scope habilitado. +func RequireScope(scope string) fiber.Handler { + return func(c *fiber.Ctx) error { + if master, _ := c.Locals("api_key_master").(bool); master { + return c.Next() + } + key, ok := c.Locals("api_key").(*models.ApiKey) + if !ok || key == nil || !key.TieneScope(scope) { + return c.Status(403).JSON(fiber.Map{"error": "Esta API key no tiene el scope '" + scope + "'"}) + } return c.Next() } } diff --git a/rest/routes/hermes.go b/rest/routes/hermes.go index 8fdb3bb..05d6462 100644 --- a/rest/routes/hermes.go +++ b/rest/routes/hermes.go @@ -12,30 +12,31 @@ func AdminApiRoutes(api fiber.Router) { // ─── Spec (documentación completa de la API) ───────────────────────────── h.Get("/spec", controllers.AdminApiSpec) - // ─── Usuarios ──────────────────────────────────────────────────────────── - h.Get("/users", controllers.GetUsers) - h.Get("/users/:id", controllers.GetUser) - h.Post("/users", controllers.CreateUser) - h.Put("/users/:id", controllers.UpdateUser) - h.Delete("/users/:id", controllers.DeleteUser) + // ─── Usuarios / Roles / Módulos / Submódulos ───────────────────────────── + // Todo el sistema de permisos queda bajo un único scope "usuarios": una + // ApiKey con este scope puede crear/editar cuentas y roles, así que es + // tan sensible como tener acceso administrativo completo al panel. + admin := h.Group("", middlewares.RequireScope("usuarios")) + admin.Get("/users", controllers.GetUsers) + admin.Get("/users/:id", controllers.GetUser) + admin.Post("/users", controllers.CreateUser) + admin.Put("/users/:id", controllers.UpdateUser) + admin.Delete("/users/:id", controllers.DeleteUser) - // ─── Roles ─────────────────────────────────────────────────────────────── - h.Get("/roles", controllers.GetRoles) - h.Post("/roles", controllers.CreateRole) - h.Put("/roles/:id", controllers.UpdateRole) - h.Delete("/roles/:id", controllers.DeleteRole) + admin.Get("/roles", controllers.GetRoles) + admin.Post("/roles", controllers.CreateRole) + admin.Put("/roles/:id", controllers.UpdateRole) + admin.Delete("/roles/:id", controllers.DeleteRole) - // ─── Módulos ───────────────────────────────────────────────────────────── - h.Get("/modules", controllers.GetModules) - h.Post("/modules", controllers.CreateModule) - h.Put("/modules/:id", controllers.UpdateModule) - h.Delete("/modules/:id", controllers.DeleteModule) + admin.Get("/modules", controllers.GetModules) + admin.Post("/modules", controllers.CreateModule) + admin.Put("/modules/:id", controllers.UpdateModule) + admin.Delete("/modules/:id", controllers.DeleteModule) - // ─── Submódulos ────────────────────────────────────────────────────────── - h.Get("/submodules", controllers.GetSubmodules) - h.Post("/submodules", controllers.CreateSubmodule) - h.Put("/submodules/:id", controllers.UpdateSubmodule) - h.Delete("/submodules/:id", controllers.DeleteSubmodule) + admin.Get("/submodules", controllers.GetSubmodules) + admin.Post("/submodules", controllers.CreateSubmodule) + admin.Put("/submodules/:id", controllers.UpdateSubmodule) + admin.Delete("/submodules/:id", controllers.DeleteSubmodule) // ─── Servidores ────────────────────────────────────────────────────────── h.Get("/servidores", controllers.GetServidor) @@ -270,6 +271,13 @@ func AdminApiRoutes(api fiber.Router) { h.Post("/pagos-externos/servicios/:id/regenerar-token", controllers.RegenerarTokenServicioPagoHandler) h.Get("/pagos-externos/solicitudes", controllers.GetSolicitudesPagoExternoHandler) + // ─── API Keys de /api/v2 ──────────────────────────────────────────────── + h.Get("/api-keys", controllers.GetApiKeys) + h.Post("/api-keys", controllers.CreateApiKeyHandler) + h.Put("/api-keys/:id", controllers.UpdateApiKeyHandler) + h.Delete("/api-keys/:id", controllers.DeleteApiKeyHandler) + h.Post("/api-keys/:id/regenerar-token", controllers.RegenerarApiKeyHandler) + // ─── uMind: chat con IA embebible por tenant ─────────────────────────────── h.Get("/umind/tenants", controllers.GetUmindTenants) h.Post("/umind/tenants", controllers.CreateUmindTenantHandler) @@ -282,15 +290,18 @@ func AdminApiRoutes(api fiber.Router) { h.Get("/umind/historial", controllers.GetUmindHistorialHandler) // ─── OSS API (almacenamiento) ──────────────────────────────────────────── + // La gestión de credenciales de storage (CRUD de config) queda solo para + // la llave maestra; el scope "oss" de una ApiKey solo permite operar + // archivos (subir/listar/borrar), no ver ni cambiar las credenciales. h.Get("/oss-api", controllers.GetOssApiConfigs) h.Get("/oss-api/active", controllers.GetActiveOssApiList) h.Post("/oss-api", controllers.CreateOssApiConfig) h.Put("/oss-api/:id", controllers.UpdateOssApiConfig) h.Delete("/oss-api/:id", controllers.DeleteOssApiConfig) - h.Get("/oss-api/browser", controllers.OssBrowserList) - h.Get("/oss-api/browser/url", controllers.OssBrowserSignedURL) - h.Delete("/oss-api/browser/object", controllers.OssBrowserDelete) - h.Post("/oss-api/browser/upload", controllers.OssBrowserUpload) + h.Get("/oss-api/browser", middlewares.RequireScope("oss"), controllers.OssBrowserList) + h.Get("/oss-api/browser/url", middlewares.RequireScope("oss"), controllers.OssBrowserSignedURL) + h.Delete("/oss-api/browser/object", middlewares.RequireScope("oss"), controllers.OssBrowserDelete) + h.Post("/oss-api/browser/upload", middlewares.RequireScope("oss"), controllers.OssBrowserUpload) // ─── Telegram ──────────────────────────────────────────────────────────── h.Get("/telegram", controllers.GetTelegramConfigs) @@ -340,25 +351,31 @@ func AdminApiRoutes(api fiber.Router) { h.Get("/websms/logs", controllers.GetWebSmsLogs) // ─── Pasarelas de pago ─────────────────────────────────────────────────── - h.Get("/pasarelas/bold/config", controllers.GetBoldConfigAPI) - h.Post("/pasarelas/bold/save", controllers.SaveBoldConfig) - h.Get("/pasarelas/bold/logs", controllers.BoldWebhookLogs) - h.Get("/pasarelas/dlocal/config", controllers.GetDlocalConfigAPI) - h.Post("/pasarelas/dlocal/save", controllers.SaveDlocalConfigWeb) - h.Get("/pasarelas/dlocal/logs", controllers.DlocalPaymentLogsPaginated) - h.Get("/pasarelas/paypal/config", controllers.GetPaypalConfigAPI) - h.Post("/pasarelas/paypal/save", controllers.SavePaypalConfigWeb) + // Credenciales de Bold/dLocal/PayPal: scope "pasarelas" propio. + pasarelas := h.Group("/pasarelas", middlewares.RequireScope("pasarelas")) + pasarelas.Get("/bold/config", controllers.GetBoldConfigAPI) + pasarelas.Post("/bold/save", controllers.SaveBoldConfig) + pasarelas.Get("/bold/logs", controllers.BoldWebhookLogs) + pasarelas.Get("/dlocal/config", controllers.GetDlocalConfigAPI) + pasarelas.Post("/dlocal/save", controllers.SaveDlocalConfigWeb) + pasarelas.Get("/dlocal/logs", controllers.DlocalPaymentLogsPaginated) + pasarelas.Get("/paypal/config", controllers.GetPaypalConfigAPI) + pasarelas.Post("/paypal/save", controllers.SavePaypalConfigWeb) // ─── Query Runner ──────────────────────────────────────────────────────── - h.Get("/query-runner/connections", controllers.GetConxDbList) - h.Get("/query-runner/databases", controllers.GetDatabases) - h.Get("/query-runner/tables", controllers.GetTables) - h.Get("/query-runner/relations", controllers.GetRelations) - h.Get("/query-runner/test", controllers.TestConnection) - h.Post("/query-runner/run", controllers.RunQuery) - h.Post("/query-runner/run-batch", controllers.RunBatchQuery) - h.Get("/query-runner/history", controllers.GetHistory) - h.Get("/query-runner/columns", controllers.GetTableColumnsHandler) + // SQL arbitrario contra cualquier conexión configurada: es de lo más + // sensible que expone /api/v2, así que una ApiKey scoped necesita el + // scope "query_runner" explícito para todo este grupo. + qr := h.Group("/query-runner", middlewares.RequireScope("query_runner")) + qr.Get("/connections", controllers.GetConxDbList) + qr.Get("/databases", controllers.GetDatabases) + qr.Get("/tables", controllers.GetTables) + qr.Get("/relations", controllers.GetRelations) + qr.Get("/test", controllers.TestConnection) + qr.Post("/run", controllers.RunQuery) + qr.Post("/run-batch", controllers.RunBatchQuery) + qr.Get("/history", controllers.GetHistory) + qr.Get("/columns", controllers.GetTableColumnsHandler) // ─── Hostinger ─────────────────────────────────────────────────────────── h.Get("/hostinger/vps", controllers.GetHostingerVPS) diff --git a/rest/routes/user.go b/rest/routes/user.go index 0b37351..a7148aa 100755 --- a/rest/routes/user.go +++ b/rest/routes/user.go @@ -341,6 +341,16 @@ func UserRoutes(app fiber.Router) { protected.Post("/pagos-externos/servicios/:id/regenerar-token", middlewares.SoloAdmin, controllers.RegenerarTokenServicioPagoHandler) protected.Get("/pagos-externos/solicitudes", controllers.GetSolicitudesPagoExternoHandler) + // ─── API Keys de /api/v2 (token + IP obligatoria + scopes) ──────────────── + // Muy sensible: un scope "usuarios" o "query_runner" filtrado equivale a + // dar acceso administrativo — solo un administrador puede administrarlas. + protected.Get("/api-keys", middlewares.MenuMiddleware, controllers.ApiKeysIndex) + protected.Get("/api-keys/list", controllers.GetApiKeys) + protected.Post("/api-keys", middlewares.SoloAdmin, controllers.CreateApiKeyHandler) + protected.Put("/api-keys/:id", middlewares.SoloAdmin, controllers.UpdateApiKeyHandler) + protected.Delete("/api-keys/:id", middlewares.SoloAdmin, controllers.DeleteApiKeyHandler) + protected.Post("/api-keys/:id/regenerar-token", middlewares.SoloAdmin, controllers.RegenerarApiKeyHandler) + // ─── uMind: chat con IA embebible por tenant ─────────────────────────────── // Sensible: un tenant queda ligado a una config de IA (API key) y controla // desde qué dominios se puede llamar al widget, así que crear/editar es