changed deployment
This commit is contained in:
parent
ea4e057b02
commit
70396c321a
22 changed files with 391 additions and 52 deletions
|
|
@ -13,6 +13,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/danyel/go-loose/client"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"gorm.io/gorm"
|
||||
|
||||
|
|
@ -641,7 +642,7 @@ func (h *Handler) FinishInterview(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func (h *Handler) frontendURLForRequest(r *http.Request) string {
|
||||
tenant := strings.ToLower(strings.TrimSpace(r.Header.Get("X-Tenant-Id")))
|
||||
tenant := strings.ToLower(strings.TrimSpace(r.Header.Get(client.XTenantId)))
|
||||
origin := strings.TrimRight(strings.TrimSpace(r.Header.Get("Origin")), "/")
|
||||
if tenant == "" || origin == "" {
|
||||
return h.frontendURL
|
||||
|
|
|
|||
|
|
@ -3,13 +3,15 @@ package handler
|
|||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/danyel/go-loose/client"
|
||||
)
|
||||
|
||||
func TestFrontendURLForTenantRequest(t *testing.T) {
|
||||
handler := &Handler{frontendURL: "http://localhost:5173"}
|
||||
request := httptest.NewRequest("GET", "/api/jobs/2/invitations", nil)
|
||||
request.Header.Set("Origin", "http://ypto.guess.local:5173")
|
||||
request.Header.Set("X-Tenant-Id", "ypto")
|
||||
request.Header.Set(client.XTenantId, "ypto")
|
||||
|
||||
if value := handler.frontendURLForRequest(request); value != "http://ypto.guess.local:5173" {
|
||||
t.Fatalf("unexpected frontend URL %q", value)
|
||||
|
|
@ -20,7 +22,7 @@ func TestFrontendURLRejectsDifferentTenantOrigin(t *testing.T) {
|
|||
handler := &Handler{frontendURL: "http://localhost:5173"}
|
||||
request := httptest.NewRequest("GET", "/api/jobs/2/invitations", nil)
|
||||
request.Header.Set("Origin", "http://nmbs.guess.local:5173")
|
||||
request.Header.Set("X-Tenant-Id", "ypto")
|
||||
request.Header.Set(client.XTenantId, "ypto")
|
||||
|
||||
if value := handler.frontendURLForRequest(request); value != handler.frontendURL {
|
||||
t.Fatalf("expected configured frontend URL, got %q", value)
|
||||
|
|
|
|||
99
backend/internal/web/middleware/GoLooseMiddleware.go
Normal file
99
backend/internal/web/middleware/GoLooseMiddleware.go
Normal 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})
|
||||
}
|
||||
97
backend/internal/web/middleware/GoLooseMiddleware_test.go
Normal file
97
backend/internal/web/middleware/GoLooseMiddleware_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import (
|
|||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/danyel/go-loose/client"
|
||||
)
|
||||
|
||||
type Operation struct {
|
||||
|
|
@ -86,7 +88,10 @@ func (b *Builder) Add(method, path string, operation Operation) {
|
|||
"responses": responses,
|
||||
}
|
||||
if operation.Protected {
|
||||
value["security"] = []map[string][]string{{"bearerAuth": {}}}
|
||||
value["security"] = []map[string][]string{{
|
||||
"apiKeyAuth": {},
|
||||
"bearerAuth": {},
|
||||
}}
|
||||
}
|
||||
if operation.Request != nil {
|
||||
requestContentType := operation.RequestContentType
|
||||
|
|
@ -126,6 +131,7 @@ func (b *Builder) Document() map[string]any {
|
|||
"paths": b.paths,
|
||||
"components": map[string]any{
|
||||
"securitySchemes": map[string]any{
|
||||
"apiKeyAuth": map[string]any{"type": "apiKey", "in": "header", "name": client.DefaultHeader},
|
||||
"bearerAuth": map[string]any{"type": "http", "scheme": "bearer", "bearerFormat": "JWT"},
|
||||
},
|
||||
"schemas": b.schemas,
|
||||
|
|
@ -137,7 +143,7 @@ func (b *Builder) parameters(path string, queries []string, tenantRequired bool)
|
|||
result := make([]map[string]any, 0)
|
||||
if tenantRequired {
|
||||
result = append(result, map[string]any{
|
||||
"name": "X-Tenant-Id", "in": "header", "required": true,
|
||||
"name": client.XTenantId, "in": "header", "required": true,
|
||||
"schema": map[string]any{"type": "string"},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ func New(h *handler.Handler, tokens security.ITokenManager, frontendURL string)
|
|||
register(router, spec, false, http.MethodGet, "/api/participant-meetings/{token}/events", h.ParticipantMeetingEvents,
|
||||
openapi.Operation{Summary: "Stream participant meeting events", Response: eventbus.InterviewEvent{}, ResponseContentType: "text/event-stream"})
|
||||
router.Group(func(protected chi.Router) {
|
||||
protected.Use(mw.GoLooseMiddleware())
|
||||
protected.Use(authenticate(tokens))
|
||||
register(protected, spec, true, http.MethodGet, "/api/jobs", h.ListJobs,
|
||||
openapi.Operation{Summary: "List job postings", Response: []webmodel.JobResponse{}})
|
||||
|
|
@ -161,7 +162,7 @@ func cors(origin string) func(http.Handler) http.Handler {
|
|||
if allowedCORSOrigin(requestOrigin, origin) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", requestOrigin)
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Tenant-Id")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-API-Key, X-Tenant-Id")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
w.Header().Add("Vary", "Origin")
|
||||
if r.Method == http.MethodOptions {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue