changed deployment

This commit is contained in:
Daniel Noulet 2026-10-02 21:44:26 +02:00
parent ea4e057b02
commit 70396c321a
22 changed files with 391 additions and 52 deletions

View file

@ -0,0 +1,99 @@
package middleware
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"regexp"
"strings"
"sync"
"time"
"github.com/danyel/go-loose/client"
)
const guessApplication = "guess"
// A tenant used in the Go Loose host must be one DNS label.
var safeTenantLabel = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,62})?$`)
// GoLooseMiddleware authorizes a request with Go Loose. The tenant is taken
// from the X-Tenant-Id header, not from process configuration.
//
// GO_LOOSE_BASE_URL may contain one %s placeholder for that tenant. The
// default is https://%s.auth.dev. A URL without %s is used as-is.
func GoLooseMiddleware() func(http.Handler) http.Handler {
pattern := os.Getenv("GO_LOOSE_BASE_URL")
if pattern == "" {
pattern = "https://%s.auth.dev"
}
httpClient := &http.Client{Timeout: 2 * time.Second}
var (
mu sync.Mutex
clients = map[string]*client.Client{}
)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tenantID := strings.TrimSpace(r.Header.Get(client.XTenantId))
if tenantID == "" {
writeGoLooseError(w, http.StatusBadRequest, "Missing X-Tenant-Id header")
return
}
baseURL, err := goLooseBaseURL(pattern, tenantID)
if err != nil {
writeGoLooseError(w, http.StatusBadRequest, "Invalid characters in tenant ID")
return
}
access, err := goLooseClient(&mu, clients, httpClient, baseURL, tenantID)
if err != nil {
slog.Error("configure go-loose client", "error", err)
writeGoLooseError(w, http.StatusInternalServerError, "authorization unavailable")
return
}
access.Middleware(next).ServeHTTP(w, r)
})
}
}
func goLooseBaseURL(pattern, tenantID string) (string, error) {
if !strings.Contains(pattern, "%s") {
return pattern, nil
}
if !safeTenantLabel.MatchString(tenantID) {
return "", fmt.Errorf("invalid tenant %q", tenantID)
}
return fmt.Sprintf(pattern, tenantID), nil
}
func goLooseClient(
mu *sync.Mutex,
clients map[string]*client.Client,
httpClient *http.Client,
baseURL, tenantID string,
) (*client.Client, error) {
key := tenantID + "\x00" + baseURL
mu.Lock()
defer mu.Unlock()
if existing, ok := clients[key]; ok {
return existing, nil
}
created, err := client.New(client.Config{
BaseURL: baseURL,
Tenant: tenantID,
Application: guessApplication,
HTTPClient: httpClient,
})
if err != nil {
return nil, err
}
clients[key] = created
return created, nil
}
func writeGoLooseError(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
}

View file

@ -0,0 +1,97 @@
package middleware
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/danyel/go-loose/client"
)
func TestGoLooseMiddlewareUsesTenantFromRequest(t *testing.T) {
const apiKey = "gl_test_key_that_is_long_enough_for_client"
var gotTenant, gotApplication, gotKey string
authorization := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotKey = r.Header.Get(client.DefaultHeader)
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read authorization body: %v", err)
}
var payload map[string]string
if err := json.Unmarshal(body, &payload); err != nil {
t.Errorf("decode authorization body: %v", err)
}
gotTenant = payload["tenant"]
gotApplication = payload["application"]
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"allowed":true,"tenant_slug":"ypto","application_slug":"guess","api_key_name":"guess"}`))
}))
defer authorization.Close()
t.Setenv("GO_LOOSE_BASE_URL", authorization.URL)
var principalTenant string
handler := GoLooseMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := client.PrincipalFromContext(r.Context())
if !ok {
t.Fatal("missing Go Loose principal")
}
principalTenant = principal.TenantSlug
if r.Header.Get(client.DefaultHeader) != "" {
t.Fatal("API key was forwarded to the application")
}
w.WriteHeader(http.StatusNoContent)
}))
request := httptest.NewRequest(http.MethodGet, "/api/jobs", nil)
request.Header.Set(client.XTenantId, "ypto")
request.Header.Set(client.DefaultHeader, apiKey)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusNoContent {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
if gotTenant != "ypto" || gotApplication != "guess" || gotKey != apiKey || principalTenant != "ypto" {
t.Fatalf("tenant=%q application=%q key=%q principal=%q", gotTenant, gotApplication, gotKey, principalTenant)
}
}
func TestGoLooseMiddlewareRejectsMissingTenantAndKey(t *testing.T) {
t.Setenv("GO_LOOSE_BASE_URL", "http://go-loose.test")
handler := GoLooseMiddleware()(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("handler reached without authorization")
}))
missingTenant := httptest.NewRecorder()
handler.ServeHTTP(missingTenant, httptest.NewRequest(http.MethodGet, "/api/jobs", nil))
if missingTenant.Code != http.StatusBadRequest {
t.Fatalf("missing tenant status = %d", missingTenant.Code)
}
missingKey := httptest.NewRequest(http.MethodGet, "/api/jobs", nil)
missingKey.Header.Set(client.XTenantId, "nmbs")
missingKeyResponse := httptest.NewRecorder()
handler.ServeHTTP(missingKeyResponse, missingKey)
if missingKeyResponse.Code != http.StatusUnauthorized {
t.Fatalf("missing key status = %d", missingKeyResponse.Code)
}
}
func TestGoLooseMiddlewareRejectsUnsafeTenantInHost(t *testing.T) {
t.Setenv("GO_LOOSE_BASE_URL", "https://%s.auth.dev")
handler := GoLooseMiddleware()(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("handler reached for unsafe tenant")
}))
request := httptest.NewRequest(http.MethodGet, "/api/jobs", nil)
request.Header.Set(client.XTenantId, "nmbs.evil.example")
request.Header.Set(client.DefaultHeader, "gl_test_key")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
}

View file

@ -6,6 +6,7 @@ import (
"net/http"
"regexp"
"github.com/danyel/go-loose/client"
"gorm.io/gorm"
)
@ -24,7 +25,7 @@ func TenantSchemaMiddleware(db *gorm.DB) func(http.Handler) http.Handler {
next.ServeHTTP(w, r)
return
}
tenantID := r.Header.Get("X-Tenant-Id")
tenantID := r.Header.Get(client.XTenantId)
if tenantID == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)