Seguridad e Integración
La seguridad es un requisito de primera clase en GM Hades Backend. El servicio procesa consultas SQL arbitrarias sobre bases de datos de producción multi-tenant, por lo que cada capa de la arquitectura aplica controles activos: desde la validación en el handler gRPC hasta la construcción de la cadena de conexión en el servicio de dominio.
Validación de input
RFC como identificador de tenant
Todo request que incluye un RFC pasa por utils.IsValidRFC antes de cualquier operación de base de datos. Esta función aplica una expresión regular contra el formato SAT (personas físicas y morales, con y sin homoclave). Un RFC inválido retorna codes.InvalidArgument de inmediato, sin consultar ADMON.
Restricción a SELECT
utils.IsSelectQuery normaliza la consulta recibida (elimina comentarios SQL, trim de whitespace) y verifica que la primera instrucción sea SELECT, SHOW, DESCRIBE o EXPLAIN. Cualquier otro statement retorna codes.InvalidArgument con el mensaje "solo se permiten consultas de lectura". Esta validación opera en el handler, antes de que la consulta llegue al repositorio.
Bounds checking
Los parámetros page_size, page_number, limit y offset tienen valores máximos configurables. Valores fuera de rango se rechazan con codes.InvalidArgument.
Consultas parametrizadas
GM Hades Backend no usa concatenación de strings para construir predicados SQL.
SQL Server (ADMON): las búsquedas dinámicas usan sql.Named de la librería database/sql. Los valores de búsqueda con LIKE se sanitizan con una función interna que escapa los caracteres %, _ y [.
query := `SELECT ... FROM tabla WHERE campo LIKE @search`rows, err := db.QueryContext(ctx, query, sql.Named("search", "%"+sanitizeLike(term)+"%"))MariaDB (Bóveda Fiscal): las consultas recibidas del cliente se aceptan solo si pasan IsSelectQuery, y los parámetros se pasan mediante ? placeholders del driver go-sql-driver/mysql.
Sin secretos en logs
Zap registra todos los eventos como campos estructurados (zap.Field). Las contraseñas de ADMON, MariaDB, SSH y SFTP nunca son campos de log. La cadena de conexión completa tampoco se loguea; solo se registra server y database en modo debug. ValidateNoHardcodedSecrets() verifica en startup que ninguna variable de entorno con sufijo _PASSWORD o _KEY tenga su valor codificado directamente en el binario.
Sanitización de errores hacia el cliente
El modelo de error diferencia entre lo que es seguro exponer y lo que debe quedar solo en los logs:
| Código gRPC | Condición | Mensaje al cliente |
|---|---|---|
codes.InvalidArgument | RFC inválido, query no-SELECT, parámetro fuera de rango | Descripción técnica del problema |
codes.NotFound | Tenant no encontrado en ADMON | "tenant not found" |
codes.DeadlineExceeded | Timeout de contexto superado | "query timeout" |
codes.Internal | Error de base de datos, error de red, pánico | "internal error" (detalle solo en logs) |
Los errores de backend se loguean con el stack trace completo usando zap.Error. El cliente solo recibe el código gRPC y un mensaje genérico para errores internos.
Credenciales desde el entorno
config.LoadConfig usa os.Getenv para todas las variables marcadas como requeridas. Si una variable está vacía y APP_ENV es production, el proceso llama os.Exit(1) antes de crear cualquier listener. No existen valores hardcodeados ni archivos de configuración de credenciales en el repositorio.
Tipos de error de dominio
El paquete internal/errors define tipos de error estructurados que implementan la interfaz error:
| Tipo | Campos | Descripción |
|---|---|---|
TimeoutError | Message, Timeout | Consulta que excede el timeout configurado |
ValidationError | Field, Message | Input inválido en un campo específico |
DatabaseError | Operation, Message, Err | Error de base de datos con contexto de operación |
InvalidRFCError | RFC | RFC que no pasa la validación de formato SAT |
ConnectionError | Server, Port, Message, Err | Fallo al establecer conexión de red |
BovedaError | Operation, RFC, Message, Err | Error en operaciones de Bóveda Fiscal; implementa Unwrap() para unwrapping de errores anidados |
Todos los tipos implementan Error() string con un mensaje descriptivo que incluye el contexto del error. Los handlers del servicio gRPC convierten estos tipos al código gRPC correspondiente usando un mapper central en internal/errors/grpc.go.
Observabilidad
Logs estructurados con Zap
Cada operación de query registra un evento con campos consistentes:
| Campo | Tipo | Descripción |
|---|---|---|
rfc | string | RFC del tenant (enmascarado en logs de producción) |
operation_tag | string | Identificador del tipo de operación |
execution_time_ms | int64 | Tiempo de ejecución en milisegundos |
row_count | int | Número de filas retornadas |
server | string | Host del servidor de base de datos |
database | string | Nombre de la base de datos |
Niveles de log
| Nivel | Cuándo se usa |
|---|---|
DEBUG | Detalles de construcción de query, selección de credenciales de caché |
INFO | Inicio/cierre del servidor, conexiones establecidas, operaciones completadas |
WARN | Reintentos, caché miss, parámetros de configuración subóptimos |
ERROR | Errores de base de datos, errores de red, validaciones fallidas |
FATAL | Error en ValidateSecurityRequirements, imposible continuar |
Health check
El servicio implementa el protocolo estándar de gRPC health check:
grpcurl -plaintext localhost:50051 grpc.health.v1.Health/CheckAdicionalmente, el listener HTTP en el mismo puerto expone el endpoint /health para load balancers HTTP como Azure Container Apps:
curl http://localhost:50051/healthLas métricas están disponibles en http://localhost:9090/metrics (Prometheus format) cuando ENABLE_METRICS=true.
GetServiceInfo
QueryGatewayService.GetServiceInfo retorna la versión del binario, commit hash, build time, versión de Go y lista de servicios gRPC registrados. Es útil para verificar que el binario desplegado corresponde al tag esperado.
Testing
Los tests unitarios cubren las capas de mayor riesgo:
| Archivo | Qué cubre |
|---|---|
internal/boveda_fiscal/config/loader_test.go | Parseo de connections.json, resolución de RFC |
internal/boveda_fiscal/db/mariadb_test.go | IsSelectQuery con variantes de input |
internal/services/services_validation_test.go | Validación de RFC, bounds checking, parámetros |
Ejecutar tests con detector de data races:
go test ./... -race -count=1 -timeout=120sGenerar reporte de cobertura:
go test ./... -coverprofile=coverage.out && go tool cover -html=coverage.outAnálisis estático:
go vet ./...staticcheck ./...gosec -quiet ./...golangci-lint runIntegración con grpcurl
Listar servicios
grpcurl -plaintext localhost:50051 listDescribir servicio
grpcurl -plaintext localhost:50051 describe QueryGatewayServiceEjecutar una consulta SQL
grpcurl -plaintext -d '{ "query": "SELECT TOP 10 Id, RFC FROM CatUsuarios", "tenant": { "rfc": "GMX123456789" }}' localhost:50051 QueryGatewayService/ExecuteQueryStreaming de resultados
grpcurl -plaintext -d '{ "query": "SELECT * FROM OrdenDeServicio", "tenant": { "rfc": "GMX123456789" }, "page_size": 1000}' localhost:50051 QueryGatewayService/StreamQueryHealth check gRPC
grpcurl -plaintext -d '{"service": "QueryGatewayService"}' \ localhost:50051 grpc.health.v1.Health/CheckListar directorio SFTP
grpcurl -plaintext -d '{ "rfc": "GMX123456789", "path": "/cfdis/2024"}' localhost:50051 BovedaFiscalSFTPService/ListDirectoryDescargar lote de CFDIs como ZIP
grpcurl -plaintext -d '{ "rfc": "GMX123456789", "uuids": ["uuid-1", "uuid-2", "uuid-3"]}' localhost:50051 BovedaFiscalSFTPService/DownloadBatchAsZip > cfdi_batch.zipIntegración con cliente Go
import ( "context" "log"
pb "github.com/gmtransport/gm-hades-backend/proto/gen/go/query/v2" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure")
func main() { conn, err := grpc.NewClient( "localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials()), ) if err != nil { log.Fatal(err) } defer conn.Close()
client := pb.NewQueryGatewayServiceClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel()
stream, err := client.StreamQuery(ctx, &pb.StreamQueryRequest{ Query: "SELECT * FROM OrdenDeServicio", Tenant: &pb.TenantContext{Rfc: "GMX123456789"}, PageSize: 1000, }) if err != nil { log.Fatal(err) }
for { chunk, err := stream.Recv() if err == io.EOF { break } if err != nil { log.Fatal(err) }
switch chunk.Type { case pb.StreamChunkType_METADATA: fmt.Printf("Columnas: %v\n", chunk.Columns) case pb.StreamChunkType_DATA: // chunk.Rows contiene los datos en formato JSON fmt.Printf("Filas en este chunk: %d\n", len(chunk.Rows)) case pb.StreamChunkType_END: fmt.Printf("Total filas: %d\n", chunk.TotalRows) } }}Resumen
- GM Hades Backend aplica controles de seguridad en cada capa: validación de RFC e IsSelectQuery en el handler, consultas parametrizadas en el repositorio, sin secretos en logs, errores sanitizados hacia el cliente.
- Los tipos de error de dominio en
internal/errorspermiten un mapeo claro entre errores de infraestructura y códigos gRPC. - Zap registra campos estructurados consistentes en todas las operaciones. Los errores internos tienen stack trace en logs pero el cliente recibe solo
codes.Internal. - Los tests unitarios cubren las capas de mayor riesgo (validación, parseo de configuración, IsSelectQuery). La cobertura se ejecuta con
-racepara detectar condiciones de carrera. - La integración puede verificarse sin cliente personalizado usando
grpcurlpara todos los servicios y métodos.