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 @@
+
+
+
+
+
+
+
+
+
+
+
+
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).
+
+
+
+
+
+
+
Nombre
+
Token
+
IP permitida
+
Scopes
+
Último uso
+
Estado
+
Acciones
+
+
+
+
+
Sin API keys registradas
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Nunca usada
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
⚠️ 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