refactor: dispatcher as dumb pipe + add gRPC detection for Temporal
CI / Vet, test, build (push) Canceled after 24s
CI / Build and push image (push) Canceled after 0s

BREAKING CHANGE: Gateway no longer validates JWTs at dispatcher level.
Each upstream service (MinIO, Authentik, Temporal) validates bearer
tokens independently. Gateway passes Authorization header through unchanged.

Changes:
- Removed JWT validation from Dispatcher
- Removed internal/auth JWT validator usage
- Added gRPC URL scheme detection (grpc://)
- Added temporal-frontend with gRPC config (returns 501 not-implemented)
- All adapters now auth: required: false (services validate own tokens)
- Gateway is now a transparent routing layer, not auth gateway

gRPC forwarding requires grpcproxy middleware (future Phase 9).
For now, gRPC clients should connect directly to temporal-frontend:7233.
This commit is contained in:
Admin Bot
2026-08-27 11:16:15 -07:00
parent 57d64039d5
commit 139bc80529
4 changed files with 122 additions and 104 deletions
+76 -60
View File
@@ -1,36 +1,33 @@
package serviceadapter
import (
"context"
"fmt"
"net"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"time"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/auth"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/problem"
)
// Dispatcher handles X-Service based routing to service adapters.
// Dispatcher routes X-Service requests to upstreams.
// Acts as a dumb pipe: passes Authorization header through unchanged.
// Each upstream service validates JWTs independently.
type Dispatcher struct {
registry *Registry
validators map[string]*auth.Validator // Per-service JWT validators
registry *Registry
}
// NewDispatcher creates a new service adapter dispatcher with Authentik JWT validators.
// NewDispatcher creates a new service adapter dispatcher.
func NewDispatcher(registry *Registry) *Dispatcher {
dispatcher := &Dispatcher{
registry: registry,
validators: make(map[string]*auth.Validator),
return &Dispatcher{
registry: registry,
}
// Create validators for all registered services
for _, adapter := range registry.List() {
dispatcher.validators[adapter.ServiceName] = auth.NewValidator(adapter.ServiceName)
}
return dispatcher
}
// Matches returns true if the request should be dispatched based on X-Service header.
@@ -43,7 +40,6 @@ func (d *Dispatcher) Matches(r *http.Request) bool {
func (d *Dispatcher) Dispatch(w http.ResponseWriter, r *http.Request) {
serviceName := r.Header.Get("X-Service")
if serviceName == "" {
// No X-Service header — this shouldn't happen if Matches() was called
d.writeError(w, problem.BadRequest("X-Service header required"))
return
}
@@ -93,61 +89,35 @@ func (d *Dispatcher) Dispatch(w http.ResponseWriter, r *http.Request) {
return
}
// Check auth requirements using JWT validation
requiredCapability := ""
auth := resource.Auth
if auth == nil {
auth = &adapter.Spec.Auth
}
if method.Auth != nil {
auth = method.Auth
// Detect protocol from upstream URL scheme
upstreamURL := adapter.Spec.Upstream.URL
if strings.HasPrefix(upstreamURL, "grpc://") {
// gRPC upstream (Temporal, etc.)
d.dispatchGRPC(w, r, upstreamURL, method, adapter)
} else {
// HTTP upstream (MinIO, Authentik, etc.)
d.dispatchHTTP(w, r, upstreamURL, method, adapter)
}
}
if auth != nil && auth.Required && auth.Capability != "" {
requiredCapability = auth.Capability
// Validate JWT token
validator := d.validators[serviceName]
if validator == nil {
p := problem.NewProblem(http.StatusInternalServerError, "about:blank#server-error",
"Internal Server Error", fmt.Sprintf("no validator for service '%s'", serviceName))
_ = p.Write(w)
return
}
claims, err := validator.ValidateToken(r.Header.Get("Authorization"))
if err != nil {
p := problem.NewProblem(http.StatusForbidden, "about:blank#forbidden",
"Forbidden", fmt.Sprintf("authentication failed: %v", err))
_ = p.Write(w)
return
}
// Check permission
if !validator.HasPermission(claims, requiredCapability) {
p := problem.NewProblem(http.StatusForbidden, "about:blank#forbidden",
"Forbidden", fmt.Sprintf("permission '%s' required", requiredCapability))
_ = p.Write(w)
return
}
}
// Build upstream URL
upstreamURL, err := url.Parse(adapter.Spec.Upstream.URL)
// dispatchHTTP forwards HTTP requests to upstream, passing Authorization header through.
func (d *Dispatcher) dispatchHTTP(w http.ResponseWriter, r *http.Request, upstreamURL string, method *Method, adapter *ServiceAdapter) {
parsedURL, err := url.Parse(upstreamURL)
if err != nil {
d.writeError(w, problem.NewProblem(http.StatusInternalServerError, "about:blank#server-error",
"Internal Server Error", fmt.Sprintf("invalid upstream URL: %v", err)))
return
}
// Create reverse proxy with director that rewrites the path
proxy := httputil.NewSingleHostReverseProxy(upstreamURL)
// Create reverse proxy
proxy := httputil.NewSingleHostReverseProxy(parsedURL)
proxy.Director = func(req *http.Request) {
req.URL.Scheme = upstreamURL.Scheme
req.URL.Host = upstreamURL.Host
req.URL.Scheme = parsedURL.Scheme
req.URL.Host = parsedURL.Host
req.URL.Path = method.UpstreamPath
req.RequestURI = ""
req.Host = upstreamURL.Host
req.Host = parsedURL.Host
// Authorization header passes through unchanged
}
// Set timeout
@@ -164,7 +134,53 @@ func (d *Dispatcher) Dispatch(w http.ResponseWriter, r *http.Request) {
proxy.ServeHTTP(w, r)
}
// dispatchGRPC forwards gRPC requests to upstream.
// gRPC URL format: grpc://host:port
func (d *Dispatcher) dispatchGRPC(w http.ResponseWriter, r *http.Request, upstreamURL string, method *Method, adapter *ServiceAdapter) {
// Extract host:port from grpc://host:port
host := strings.TrimPrefix(upstreamURL, "grpc://")
if host == upstreamURL {
d.writeError(w, problem.NewProblem(http.StatusInternalServerError, "about:blank#server-error",
"Internal Server Error", "invalid gRPC URL format"))
return
}
// Validate that this is a gRPC request
if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/grpc") {
d.writeError(w, problem.NewProblem(http.StatusBadRequest, "about:blank#bad-request",
"Bad Request", "gRPC service requires application/grpc content-type"))
return
}
// Set timeout
timeout := adapter.Spec.Upstream.TimeoutSeconds
if timeout <= 0 {
timeout = 30
}
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(timeout)*time.Second)
defer cancel()
// Dial gRPC upstream
conn, err := grpc.DialContext(ctx, host,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithDefaultCallOptions(
grpc.MaxCallRecvMsgSize(100 * 1024 * 1024), // 100MB
),
)
if err != nil {
d.writeError(w, problem.NewProblem(http.StatusBadGateway, "about:blank#bad-gateway",
"Bad Gateway", fmt.Sprintf("failed to dial gRPC upstream: %v", err)))
return
}
defer conn.Close()
// Forward gRPC request
// Note: Full gRPC forwarding requires grpcproxy or custom middleware.
// For now, return unimplemented (Temporal support coming in Phase 9)
d.writeError(w, problem.NewProblem(http.StatusNotImplemented, "about:blank#not-implemented",
"Not Implemented", "gRPC forwarding not yet implemented - use in-cluster gRPC clients directly"))
}
func (d *Dispatcher) writeError(w http.ResponseWriter, p *problem.Problem) {
_ = p.Write(w)