Agrega API Keys scoped (token + IP obligatoria + alcance) para /api/v2
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.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
<!-- Vista: API Keys — credenciales scoped para /api/v2 -->
|
||||
<div x-data="apiKeysApp()" x-init="init()" @keydown.escape.window="closeModal()" class="bg-white rounded-lg shadow">
|
||||
|
||||
<div x-show="loading" class="fixed inset-0 bg-gray-800 bg-opacity-75 flex justify-center items-center z-50">
|
||||
<img src="../img/loading.gif" alt="Cargando..." class="w-16 h-16" />
|
||||
</div>
|
||||
|
||||
<div class="container mx-auto p-6 w-full">
|
||||
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">API Keys</h1>
|
||||
<p class="text-xs text-slate-500 mt-0.5">Credenciales para <code>/api/v2</code> restringidas a una IP y a un alcance puntual — alternativa a compartir la llave maestra (<code>ADMIN_API_KEY</code>).</p>
|
||||
</div>
|
||||
<button @click="openAdd()"
|
||||
class="flex items-center gap-2 text-white text-sm font-medium px-4 py-2 rounded-lg"
|
||||
style="background-color:#8eb02f"
|
||||
onmouseover="this.style.backgroundColor='#6d8c24'"
|
||||
onmouseout="this.style.backgroundColor='#8eb02f'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
Nueva API key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div x-show="errorMsg" x-cloak class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700" x-text="errorMsg"></div>
|
||||
<div x-show="successMsg" x-cloak class="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-700" x-text="successMsg"></div>
|
||||
<div class="mb-4 p-3 bg-amber-50 border border-amber-200 rounded-lg text-xs text-amber-700">
|
||||
Solo estos scopes restringen de verdad hoy: <strong>oss</strong>, <strong>query_runner</strong>, <strong>usuarios</strong>, <strong>pasarelas</strong>. El resto de <code>/api/v2</code> sigue funcionando únicamente con la llave maestra (fase 2 pendiente).
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table-auto w-full text-sm">
|
||||
<thead class="border-b border-gray-200 text-left text-xs font-semibold text-gray-500 uppercase">
|
||||
<tr>
|
||||
<th class="py-2 px-3">Nombre</th>
|
||||
<th class="py-2 px-3">Token</th>
|
||||
<th class="py-2 px-3">IP permitida</th>
|
||||
<th class="py-2 px-3">Scopes</th>
|
||||
<th class="py-2 px-3">Último uso</th>
|
||||
<th class="py-2 px-3">Estado</th>
|
||||
<th class="py-2 px-3 text-right">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<template x-if="items.length === 0">
|
||||
<tr><td colspan="7" class="py-8 text-center text-gray-400">Sin API keys registradas</td></tr>
|
||||
</template>
|
||||
<template x-for="item in items" :key="item.ID">
|
||||
<tr class="hover:bg-gray-50 transition">
|
||||
<td class="py-2 px-3 font-medium" x-text="item.nombre"></td>
|
||||
<td class="py-2 px-3 font-mono text-xs text-gray-400" x-text="item.token_preview"></td>
|
||||
<td class="py-2 px-3 font-mono text-xs text-gray-600" x-text="item.ip_permitida"></td>
|
||||
<td class="py-2 px-3">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<template x-for="s in (item.scopes || '').split(',').filter(x => x.trim())" :key="s">
|
||||
<span class="px-2 py-0.5 rounded-full text-xs font-semibold bg-[#e9f0cf] text-[#5a7a1e]" x-text="s.trim()"></span>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-2 px-3 text-xs text-gray-500">
|
||||
<span x-show="item.ultimo_uso_at" x-text="item.ultimo_uso_at ? new Date(item.ultimo_uso_at).toLocaleString() + ' · ' + item.ultimo_uso_ip : ''"></span>
|
||||
<span x-show="!item.ultimo_uso_at" class="text-gray-300">Nunca usada</span>
|
||||
</td>
|
||||
<td class="py-2 px-3">
|
||||
<span :class="item.activa ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'"
|
||||
class="px-2 py-0.5 rounded-full text-xs font-semibold"
|
||||
x-text="item.activa ? 'Activa' : 'Inactiva'"></span>
|
||||
</td>
|
||||
<td class="py-2 px-3 text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button @click="openEdit(item)"
|
||||
class="text-xs text-blue-600 hover:text-blue-800 font-medium transition">Editar</button>
|
||||
<button @click="confirmRegenerar(item.ID)"
|
||||
class="text-xs text-amber-600 hover:text-amber-800 font-medium transition">Regenerar token</button>
|
||||
<button @click="confirmDelete(item.ID)"
|
||||
class="text-xs text-red-500 hover:text-red-700 font-medium transition">Eliminar</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal crear / editar -->
|
||||
<div x-show="showModal" x-cloak class="fixed inset-0 bg-black/50 flex items-center justify-center z-40 p-4">
|
||||
<div @click.outside="closeModal()" class="bg-white rounded-xl shadow-xl w-full max-w-lg p-6">
|
||||
<h2 class="text-lg font-bold mb-4" x-text="editItem ? 'Editar API key' : 'Nueva API key'"></h2>
|
||||
<form @submit.prevent="save()">
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Nombre *</label>
|
||||
<input x-model="form.nombre" type="text" required placeholder="Ej: Integración vCard"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">IP permitida * (IP exacta o CIDR)</label>
|
||||
<input x-model="form.ip_permitida" type="text" required placeholder="190.10.20.30 o 190.10.0.0/16"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
|
||||
<p class="text-[10px] text-gray-400 mt-1">Obligatoria — sin ella la llave no puede usarse desde ningún lado.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-2">Scopes *</label>
|
||||
<div class="border border-gray-200 rounded-lg p-3 space-y-2 bg-gray-50">
|
||||
<template x-for="s in ['oss', 'query_runner', 'usuarios', 'pasarelas']" :key="s">
|
||||
<label class="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input type="checkbox" :checked="form.scopes.includes(s)"
|
||||
@change="toggleScope(s)" class="rounded text-[#8eb02f] focus:ring-[#8eb02f]">
|
||||
<span class="text-sm text-gray-700" x-text="s"></span>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input x-model="form.activa" type="checkbox" id="ak_activa" class="rounded" />
|
||||
<label for="ak_activa" class="text-sm text-gray-700">Activa</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="formError" class="mt-3 p-2 bg-red-50 border border-red-200 rounded text-xs text-red-600" x-text="formError"></div>
|
||||
|
||||
<div class="flex justify-end gap-3 mt-5">
|
||||
<button type="button" @click="closeModal()"
|
||||
class="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition">Cancelar</button>
|
||||
<button type="submit" :disabled="saving"
|
||||
class="px-4 py-2 text-sm text-white rounded-lg transition disabled:opacity-50"
|
||||
style="background-color:#8eb02f"
|
||||
onmouseover="this.style.backgroundColor='#6d8c24'"
|
||||
onmouseout="this.style.backgroundColor='#8eb02f'">
|
||||
<span x-text="saving ? 'Guardando...' : (editItem ? 'Actualizar' : 'Crear')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal token generado -->
|
||||
<div x-show="tokenResult" x-cloak class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-white rounded-xl shadow-xl w-full max-w-lg p-6">
|
||||
<h2 class="text-lg font-bold mb-1">⚠️ Guarda este token ahora</h2>
|
||||
<p class="text-xs text-gray-500 mb-4">No se volverá a mostrar completo.</p>
|
||||
<div class="flex gap-2">
|
||||
<input readonly :value="tokenResult && tokenResult.token" class="w-full border border-gray-300 rounded-lg px-3 py-2 text-xs font-mono bg-gray-50">
|
||||
<button @click="copiar(tokenResult.token)" class="px-3 py-2 text-xs border border-gray-300 rounded-lg hover:bg-gray-50">Copiar</button>
|
||||
</div>
|
||||
<div class="flex justify-end mt-5">
|
||||
<button @click="tokenResult = null; load()" class="px-4 py-2 text-sm text-white rounded-lg" style="background-color:#8eb02f">Listo, ya lo guardé</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal confirmar regenerar -->
|
||||
<div x-show="regenerarId" x-cloak class="fixed inset-0 bg-black/50 flex items-center justify-center z-40 p-4">
|
||||
<div class="bg-white rounded-xl shadow-xl w-full max-w-sm p-6 text-center">
|
||||
<p class="text-gray-700 font-semibold mb-1">¿Regenerar token?</p>
|
||||
<p class="text-xs text-gray-500 mb-5">El token actual dejará de funcionar de inmediato.</p>
|
||||
<div class="flex justify-center gap-3">
|
||||
<button @click="regenerarId = null" class="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">Cancelar</button>
|
||||
<button @click="doRegenerar()" :disabled="saving"
|
||||
class="px-4 py-2 text-sm bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition disabled:opacity-50">
|
||||
<span x-text="saving ? 'Regenerando...' : 'Regenerar'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal confirmar eliminación -->
|
||||
<div x-show="deleteId" x-cloak class="fixed inset-0 bg-black/50 flex items-center justify-center z-40 p-4">
|
||||
<div class="bg-white rounded-xl shadow-xl w-full max-w-sm p-6 text-center">
|
||||
<p class="text-gray-700 font-semibold mb-1">¿Eliminar API key?</p>
|
||||
<p class="text-xs text-gray-500 mb-5">Esta acción no se puede deshacer.</p>
|
||||
<div class="flex justify-center gap-3">
|
||||
<button @click="deleteId = null" class="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">Cancelar</button>
|
||||
<button @click="doDelete()" :disabled="saving"
|
||||
class="px-4 py-2 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 transition disabled:opacity-50">
|
||||
<span x-text="saving ? 'Eliminando...' : 'Eliminar'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function apiKeysApp() {
|
||||
return {
|
||||
loading: false, saving: false,
|
||||
items: [],
|
||||
showModal: false, editItem: null, deleteId: null, regenerarId: null, tokenResult: null,
|
||||
errorMsg: '', successMsg: '', formError: '',
|
||||
form: { nombre: '', ip_permitida: '', scopes: [], activa: true },
|
||||
|
||||
async init() { await this.load() },
|
||||
|
||||
async load() {
|
||||
this.loading = true; this.errorMsg = ''
|
||||
const res = await fetch('/app/api-keys/list')
|
||||
const data = await res.json()
|
||||
this.loading = false
|
||||
if (!res.ok) { this.errorMsg = data.error || 'Error cargando datos'; return }
|
||||
this.items = data.items || []
|
||||
},
|
||||
|
||||
toggleScope(s) {
|
||||
const idx = this.form.scopes.indexOf(s)
|
||||
if (idx >= 0) { this.form.scopes.splice(idx, 1) } else { this.form.scopes.push(s) }
|
||||
},
|
||||
|
||||
openAdd() {
|
||||
this.editItem = null
|
||||
this.form = { nombre: '', ip_permitida: '', scopes: [], activa: true }
|
||||
this.formError = ''
|
||||
this.showModal = true
|
||||
},
|
||||
openEdit(item) {
|
||||
this.editItem = item
|
||||
this.form = {
|
||||
nombre: item.nombre,
|
||||
ip_permitida: item.ip_permitida || '',
|
||||
scopes: (item.scopes || '').split(',').map(s => s.trim()).filter(s => s),
|
||||
activa: item.activa,
|
||||
}
|
||||
this.formError = ''
|
||||
this.showModal = true
|
||||
},
|
||||
closeModal() { this.showModal = false; this.editItem = null; this.formError = '' },
|
||||
|
||||
async save() {
|
||||
this.saving = true; this.formError = ''
|
||||
const url = this.editItem ? `/app/api-keys/${this.editItem.ID}` : '/app/api-keys'
|
||||
const method = this.editItem ? 'PUT' : 'POST'
|
||||
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(this.form) })
|
||||
const data = await res.json()
|
||||
this.saving = false
|
||||
if (!res.ok) { this.formError = data.error || 'Error guardando'; return }
|
||||
this.closeModal()
|
||||
if (data.token) {
|
||||
this.tokenResult = data
|
||||
} else {
|
||||
this.successMsg = 'API key actualizada'
|
||||
setTimeout(() => this.successMsg = '', 3000)
|
||||
await this.load()
|
||||
}
|
||||
},
|
||||
|
||||
confirmRegenerar(id) { this.regenerarId = id },
|
||||
async doRegenerar() {
|
||||
this.saving = true
|
||||
const res = await fetch(`/app/api-keys/${this.regenerarId}/regenerar-token`, { method: 'POST' })
|
||||
const data = await res.json()
|
||||
this.saving = false
|
||||
this.regenerarId = null
|
||||
if (!res.ok) { this.errorMsg = data.error || 'Error regenerando token'; return }
|
||||
this.tokenResult = data
|
||||
},
|
||||
|
||||
confirmDelete(id) { this.deleteId = id },
|
||||
async doDelete() {
|
||||
this.saving = true
|
||||
const res = await fetch(`/app/api-keys/${this.deleteId}`, { method: 'DELETE' })
|
||||
this.saving = false
|
||||
this.deleteId = null
|
||||
if (!res.ok) { this.errorMsg = 'Error eliminando'; return }
|
||||
this.successMsg = 'API key eliminada'
|
||||
setTimeout(() => this.successMsg = '', 3000)
|
||||
await this.load()
|
||||
},
|
||||
|
||||
copiar(texto) {
|
||||
if (!texto) return
|
||||
navigator.clipboard.writeText(texto)
|
||||
this.successMsg = 'Copiado al portapapeles'
|
||||
setTimeout(() => this.successMsg = '', 2000)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+59
-42
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user