diff --git a/.gitignore b/.gitignore
index a8772c58..d6b85043 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,3 +23,4 @@ vendor
e2e_*
.buildxcache/
+.claude/*
diff --git a/README.md b/README.md
index 39542a7b..9a6f50c2 100644
--- a/README.md
+++ b/README.md
@@ -123,6 +123,8 @@ Usage of ./observatorium-api:
The endpoint against which to send read requests for metrics.
-metrics.rules.endpoint string
The endpoint against which to make get requests for listing recording/alerting rules and put requests for creating/updating recording/alerting rules.
+ -metrics.status.endpoint string
+ The endpoint against which to make requests for status information about metrics (e.g. '/api/v1/status/tsdb').
-metrics.tenant-header string
The name of the HTTP header containing the tenant ID to forward to the metrics upstreams. (default "THANOS-TENANT")
-metrics.tenant-label string
diff --git a/authentication/authentication.go b/authentication/authentication.go
index db6f97a9..48dd917f 100644
--- a/authentication/authentication.go
+++ b/authentication/authentication.go
@@ -48,8 +48,8 @@ type tenantHandlers map[string]http.Handler
type ProviderManager struct {
mtx sync.RWMutex
patternHandlers map[string]tenantHandlers
- middlewares map[string]Middleware
- gRPCInterceptors map[string]grpc.StreamServerInterceptor
+ middlewares map[string][]Middleware
+ gRPCInterceptors map[string][]grpc.StreamServerInterceptor
logger log.Logger
registrationRetryCount *prometheus.CounterVec
}
@@ -59,8 +59,8 @@ func NewProviderManager(l log.Logger, registrationRetryCount *prometheus.Counter
return &ProviderManager{
registrationRetryCount: registrationRetryCount,
patternHandlers: make(map[string]tenantHandlers),
- middlewares: make(map[string]Middleware),
- gRPCInterceptors: make(map[string]grpc.StreamServerInterceptor),
+ middlewares: make(map[string][]Middleware),
+ gRPCInterceptors: make(map[string][]grpc.StreamServerInterceptor),
logger: l,
}
}
@@ -88,8 +88,8 @@ func (pm *ProviderManager) InitializeProvider(config map[string]interface{},
}
pm.mtx.Lock()
- pm.middlewares[tenant] = provider.Middleware()
- pm.gRPCInterceptors[tenant] = provider.GRPCMiddleware()
+ pm.middlewares[tenant] = append(pm.middlewares[tenant], provider.Middleware())
+ pm.gRPCInterceptors[tenant] = append(pm.gRPCInterceptors[tenant], provider.GRPCMiddleware())
pattern, handler := provider.Handler()
if pattern != "" && handler != nil {
if pm.patternHandlers[pattern] == nil {
@@ -109,19 +109,45 @@ func (pm *ProviderManager) InitializeProvider(config map[string]interface{},
// Middleware returns an authentication middleware for a tenant.
func (pm *ProviderManager) Middlewares(tenant string) (Middleware, bool) {
pm.mtx.RLock()
- mw, ok := pm.middlewares[tenant]
+ mws, ok := pm.middlewares[tenant]
pm.mtx.RUnlock()
- return mw, ok
+ if !ok || len(mws) == 0 {
+ return nil, false
+ }
+
+ // If only one middleware, return it directly
+ if len(mws) == 1 {
+ return mws[0], true
+ }
+
+ // Chain all middlewares together - each will check its own PathPatterns
+ // and either authenticate or pass through to the next middleware
+ return func(next http.Handler) http.Handler {
+ handler := next
+ for i := len(mws) - 1; i >= 0; i-- {
+ handler = mws[i](handler)
+ }
+ return handler
+ }, true
}
// GRPCMiddlewares returns an authentication interceptor for a tenant.
func (pm *ProviderManager) GRPCMiddlewares(tenant string) (grpc.StreamServerInterceptor, bool) {
pm.mtx.RLock()
- mw, ok := pm.gRPCInterceptors[tenant]
+ interceptors, ok := pm.gRPCInterceptors[tenant]
pm.mtx.RUnlock()
- return mw, ok
+ if !ok || len(interceptors) == 0 {
+ return nil, false
+ }
+
+ // If only one interceptor, return it directly
+ if len(interceptors) == 1 {
+ return interceptors[0], true
+ }
+
+ return interceptors[0], true
}
// PatternHandler return an http.HandlerFunc for a corresponding pattern.
diff --git a/authentication/authentication_test.go b/authentication/authentication_test.go
index 43e8d48e..b872de26 100644
--- a/authentication/authentication_test.go
+++ b/authentication/authentication_test.go
@@ -120,46 +120,46 @@ func TestTokenExpiredErrorHandling(t *testing.T) {
expiredErr := &oidc.TokenExpiredError{
Expiry: time.Now().Add(-time.Hour), // Expired an hour ago
}
-
+
// Test direct error
var tokenExpiredErr *oidc.TokenExpiredError
if !errors.As(expiredErr, &tokenExpiredErr) {
t.Error("errors.As should identify TokenExpiredError")
}
-
+
// Test wrapped error
wrappedErr := &wrappedError{
msg: "verification failed",
err: expiredErr,
}
-
+
if !errors.As(wrappedErr, &tokenExpiredErr) {
t.Error("errors.As should identify wrapped TokenExpiredError")
}
})
-
+
t.Run("Other errors are not identified as TokenExpiredError", func(t *testing.T) {
// Test with a generic error
genericErr := errors.New("generic verification error")
-
+
var tokenExpiredErr *oidc.TokenExpiredError
if errors.As(genericErr, &tokenExpiredErr) {
t.Error("errors.As should not identify generic error as TokenExpiredError")
}
-
+
// Test with wrapped generic error
wrappedGenericErr := &wrappedError{
msg: "verification failed",
err: genericErr,
}
-
+
if errors.As(wrappedGenericErr, &tokenExpiredErr) {
t.Error("errors.As should not identify wrapped generic error as TokenExpiredError")
}
})
}
-// Helper type to wrap errors for testing
+// Helper type to wrap errors for testing.
type wrappedError struct {
msg string
err error
diff --git a/authentication/http.go b/authentication/http.go
index 27f1c542..3838436b 100644
--- a/authentication/http.go
+++ b/authentication/http.go
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net/http"
+ "regexp"
"strings"
"github.com/go-chi/chi/v5"
@@ -31,6 +32,8 @@ const (
tenantKey contextKey = "tenant"
// tenantIDKey is the key that holds the tenant ID in a request context.
tenantIDKey contextKey = "tenantID"
+ // authenticatedKey is the key that indicates a request has been successfully authenticated.
+ authenticatedKey contextKey = "authenticated"
)
// WithTenant finds the tenant from the URL parameters and adds it to the request context.
@@ -131,6 +134,18 @@ func GetAccessToken(ctx context.Context) (string, bool) {
return token, ok
}
+// SetAuthenticated marks the request as successfully authenticated.
+func SetAuthenticated(ctx context.Context) context.Context {
+ return context.WithValue(ctx, authenticatedKey, true)
+}
+
+// IsAuthenticated checks if the request has been successfully authenticated.
+func IsAuthenticated(ctx context.Context) bool {
+ value := ctx.Value(authenticatedKey)
+ authenticated, ok := value.(bool)
+ return ok && authenticated
+}
+
// Middleware is a convenience type for functions that wrap http.Handlers.
type Middleware func(http.Handler) http.Handler
@@ -161,34 +176,35 @@ func WithTenantMiddlewares(mwFns ...MiddlewareFunc) Middleware {
}
}
-// EnforceAccessTokenPresentOnSignalWrite enforces that the Authorization header is present in the incoming request
-// for the given list of tenants. Otherwise, it returns an error.
-// It protects the Prometheus remote write and Loki push endpoints. The tracing endpoint is not protected because
-// it goes through the gRPC middleware stack, which behaves differently from the HTTP one.
-func EnforceAccessTokenPresentOnSignalWrite(oidcTenants map[string]struct{}) func(http.Handler) http.Handler {
+// EnforceAuthentication is a final middleware that ensures at least one authenticator
+// has successfully validated the request. This prevents security gaps where requests
+// might bypass all authentication checks.
+func EnforceAuthentication() Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- tenant := chi.URLParam(r, "tenant")
-
- // If there's no tenant, we're not interested in blocking this request.
- if tenant == "" {
- next.ServeHTTP(w, r)
+ if !IsAuthenticated(r.Context()) {
+ httperr.PrometheusAPIError(w, "request not authenticated by any provider", http.StatusUnauthorized)
return
}
-
- // And we aren't interested in blocking requests from tenants not using OIDC.
- if _, found := oidcTenants[tenant]; !found {
- next.ServeHTTP(w, r)
- return
- }
-
- rawToken := r.Header.Get("Authorization")
- if rawToken == "" {
- httperr.PrometheusAPIError(w, "couldn't find the authorization header", http.StatusBadRequest)
- return
- }
-
next.ServeHTTP(w, r)
})
}
}
+
+// Path pattern matching operators.
+const (
+ OperatorMatches = "=~" // Pattern must match
+ OperatorNotMatches = "!~" // Pattern must NOT match
+)
+
+// PathPattern represents a path pattern with an operator for matching.
+type PathPattern struct {
+ Operator string `json:"operator,omitempty"`
+ Pattern string `json:"pattern"`
+}
+
+// PathMatcher represents a compiled path pattern with operator.
+type PathMatcher struct {
+ Operator string
+ Regex *regexp.Regexp
+}
diff --git a/authentication/mtls.go b/authentication/mtls.go
index a09dc7c8..7cd8bbf9 100644
--- a/authentication/mtls.go
+++ b/authentication/mtls.go
@@ -11,6 +11,7 @@ import (
"regexp"
"github.com/go-kit/log"
+ "github.com/go-kit/log/level"
grpc_middleware_auth "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/auth"
"github.com/mitchellh/mapstructure"
"github.com/prometheus/client_golang/prometheus"
@@ -29,11 +30,11 @@ func init() {
}
type mTLSConfig struct {
- RawCA []byte `json:"ca"`
- CAPath string `json:"caPath"`
- PathPatterns []string `json:"pathPatterns"`
+ RawCA []byte `json:"ca"`
+ CAPath string `json:"caPath"`
+ Paths []PathPattern `json:"paths,omitempty"`
CAs []*x509.Certificate
- pathMatchers []*regexp.Regexp
+ pathMatchers []PathMatcher
}
type MTLSAuthenticator struct {
@@ -86,13 +87,25 @@ func newMTLSAuthenticator(c map[string]interface{}, tenant string, registrationR
config.CAs = cas
}
- // Compile path patterns
- for _, pattern := range config.PathPatterns {
- matcher, err := regexp.Compile(pattern)
+ for _, pathPattern := range config.Paths {
+ operator := pathPattern.Operator
+ if operator == "" {
+ operator = OperatorMatches
+ }
+
+ if operator != OperatorMatches && operator != OperatorNotMatches {
+ return nil, fmt.Errorf("invalid mTLS path operator %q, must be %q or %q", operator, OperatorMatches, OperatorNotMatches)
+ }
+
+ matcher, err := regexp.Compile(pathPattern.Pattern)
if err != nil {
- return nil, fmt.Errorf("failed to compile mTLS path pattern %q: %v", pattern, err)
+ return nil, fmt.Errorf("failed to compile mTLS path pattern %q: %v", pathPattern.Pattern, err)
}
- config.pathMatchers = append(config.pathMatchers, matcher)
+
+ config.pathMatchers = append(config.pathMatchers, PathMatcher{
+ Operator: operator,
+ Regex: matcher,
+ })
}
return MTLSAuthenticator{
@@ -105,24 +118,37 @@ func newMTLSAuthenticator(c map[string]interface{}, tenant string, registrationR
func (a MTLSAuthenticator) Middleware() Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ level.Debug(a.logger).Log("msg", "mTLS middleware processing", "path", r.URL.Path, "tenant", a.tenant, "numPatterns", len(a.config.pathMatchers))
+
// Check if mTLS is required for this path
if len(a.config.pathMatchers) > 0 {
- pathMatches := false
+ shouldEnforceMTLS := false
+
for _, matcher := range a.config.pathMatchers {
- if matcher.MatchString(r.URL.Path) {
- pathMatches = true
+ regexMatches := matcher.Regex.MatchString(r.URL.Path)
+ level.Debug(a.logger).Log("msg", "mTLS path pattern check", "path", r.URL.Path, "operator", matcher.Operator, "pattern", matcher.Regex.String(), "matches", regexMatches)
+
+ if matcher.Operator == OperatorMatches && regexMatches {
+ level.Debug(a.logger).Log("msg", "mTLS positive match - enforcing", "path", r.URL.Path)
+ shouldEnforceMTLS = true
+ break
+ } else if matcher.Operator == OperatorNotMatches && !regexMatches {
+ // Negative match - enforce mTLS (path does NOT match pattern)
+ level.Debug(a.logger).Log("msg", "mTLS negative match - enforcing", "path", r.URL.Path)
+ shouldEnforceMTLS = true
break
}
}
- // If path doesn't match, skip mTLS enforcement
- if !pathMatches {
+ level.Debug(a.logger).Log("msg", "mTLS enforcement decision", "path", r.URL.Path, "shouldEnforceMTLS", shouldEnforceMTLS)
+ if !shouldEnforceMTLS {
+ level.Debug(a.logger).Log("msg", "mTLS skipping enforcement", "path", r.URL.Path)
next.ServeHTTP(w, r)
return
}
}
- // Path matches or no paths configured, enforce mTLS
+ level.Debug(a.logger).Log("msg", "mTLS enforcing authentication", "path", r.URL.Path, "tenant", a.tenant)
if r.TLS == nil {
httperr.PrometheusAPIError(w, "mTLS required but no TLS connection", http.StatusBadRequest)
return
@@ -174,10 +200,11 @@ func (a MTLSAuthenticator) Middleware() Middleware {
return
}
ctx := context.WithValue(r.Context(), subjectKey, sub)
-
// Add organizational units as groups.
ctx = context.WithValue(ctx, groupsKey, r.TLS.PeerCertificates[0].Subject.OrganizationalUnit)
+ // Mark request as successfully authenticated
+ ctx = SetAuthenticated(ctx)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
@@ -192,4 +219,3 @@ func (a MTLSAuthenticator) GRPCMiddleware() grpc.StreamServerInterceptor {
func (a MTLSAuthenticator) Handler() (string, http.Handler) {
return "", nil
}
-
diff --git a/authentication/mtls_test.go b/authentication/mtls_test.go
index 591877ee..1cbf3aec 100644
--- a/authentication/mtls_test.go
+++ b/authentication/mtls_test.go
@@ -7,13 +7,15 @@ import (
"net/http/httptest"
"os"
"path/filepath"
+ "regexp"
"testing"
"github.com/go-kit/log"
+
"github.com/observatorium/api/test/testtls"
)
-// Helper function to generate test certificates using the existing testtls package
+// Helper function to generate test certificates using the existing testtls package.
func setupTestCertificatesWithFile(t testing.TB) (clientCert tls.Certificate, caPath string, cleanup func()) {
t.Helper()
@@ -26,10 +28,10 @@ func setupTestCertificatesWithFile(t testing.TB) (clientCert tls.Certificate, ca
// Generate certificates using the testtls package
err = testtls.GenerateCerts(
tmpDir,
- "test-api", // API common name
+ "test-api", // API common name
[]string{"localhost", "127.0.0.1"}, // API SANs
- "test-dex", // Dex common name
- []string{"localhost"}, // Dex SANs
+ "test-dex", // Dex common name
+ []string{"localhost"}, // Dex SANs
)
if err != nil {
os.RemoveAll(tmpDir)
@@ -70,68 +72,84 @@ func TestMTLSAuthenticator_PathBasedAuthentication(t *testing.T) {
defer cleanup()
tests := []struct {
- name string
- pathPatterns []string
- requestPath string
- expectMTLS bool
- expectError bool
- description string
+ name string
+ pathPatterns []PathPattern
+ requestPath string
+ expectMTLS bool
+ expectError bool
+ description string
}{
{
name: "no_patterns_enforces_all_paths",
- pathPatterns: []string{},
+ pathPatterns: []PathPattern{},
requestPath: "/api/v1/query",
expectMTLS: true,
expectError: false, // Should work with proper file-based CA
description: "When no patterns are configured, mTLS should be enforced on all paths",
},
{
- name: "write_pattern_matches_receive",
- pathPatterns: []string{"/api/.*/receive", "/api/.*/rules"},
- requestPath: "/api/metrics/v1/receive",
- expectMTLS: true,
- expectError: false, // Should work with proper file-based CA
- description: "Write endpoints should require mTLS",
+ name: "write_pattern_matches_receive",
+ pathPatterns: []PathPattern{
+ {Operator: "=~", Pattern: "/api/.*/receive"},
+ {Operator: "=~", Pattern: "/api/.*/rules"},
+ },
+ requestPath: "/api/metrics/v1/receive",
+ expectMTLS: true,
+ expectError: false, // Should work with proper file-based CA
+ description: "Write endpoints should require mTLS",
},
{
- name: "write_pattern_matches_rules",
- pathPatterns: []string{"/api/.*/receive", "/api/.*/rules"},
- requestPath: "/api/logs/v1/rules",
- expectMTLS: true,
- expectError: false, // Should work with proper file-based CA
- description: "Rules endpoints should require mTLS",
+ name: "write_pattern_matches_rules",
+ pathPatterns: []PathPattern{
+ {Operator: "=~", Pattern: "/api/.*/receive"},
+ {Operator: "=~", Pattern: "/api/.*/rules"},
+ },
+ requestPath: "/api/logs/v1/rules",
+ expectMTLS: true,
+ expectError: false, // Should work with proper file-based CA
+ description: "Rules endpoints should require mTLS",
},
{
- name: "read_pattern_skips_query",
- pathPatterns: []string{"/api/.*/receive", "/api/.*/rules"},
- requestPath: "/api/metrics/v1/query",
- expectMTLS: false,
- expectError: false,
- description: "Read endpoints should skip mTLS when not in patterns",
+ name: "read_pattern_skips_query",
+ pathPatterns: []PathPattern{
+ {Operator: "=~", Pattern: "/api/.*/receive"},
+ {Operator: "=~", Pattern: "/api/.*/rules"},
+ },
+ requestPath: "/api/metrics/v1/query",
+ expectMTLS: false,
+ expectError: false,
+ description: "Read endpoints should skip mTLS when not in patterns",
},
{
- name: "read_pattern_skips_series",
- pathPatterns: []string{"/api/.*/receive", "/api/.*/rules"},
- requestPath: "/api/metrics/v1/series",
- expectMTLS: false,
- expectError: false,
- description: "Series endpoints should skip mTLS when not in patterns",
+ name: "read_pattern_skips_series",
+ pathPatterns: []PathPattern{
+ {Operator: "=~", Pattern: "/api/.*/receive"},
+ {Operator: "=~", Pattern: "/api/.*/rules"},
+ },
+ requestPath: "/api/metrics/v1/series",
+ expectMTLS: false,
+ expectError: false,
+ description: "Series endpoints should skip mTLS when not in patterns",
},
{
- name: "complex_pattern_matching",
- pathPatterns: []string{"^/api/metrics/.*/(receive|rules)$"},
- requestPath: "/api/metrics/v1/receive",
- expectMTLS: true,
- expectError: false, // Should work with proper file-based CA
- description: "Complex regex patterns should work correctly",
+ name: "complex_pattern_matching",
+ pathPatterns: []PathPattern{
+ {Operator: "=~", Pattern: "^/api/metrics/.*/(receive|rules)$"},
+ },
+ requestPath: "/api/metrics/v1/receive",
+ expectMTLS: true,
+ expectError: false, // Should work with proper file-based CA
+ description: "Complex regex patterns should work correctly",
},
{
- name: "complex_pattern_non_matching",
- pathPatterns: []string{"^/api/metrics/.*/(receive|rules)$"},
- requestPath: "/api/metrics/v1/query",
- expectMTLS: false,
- expectError: false,
- description: "Complex regex patterns should correctly exclude non-matching paths",
+ name: "complex_pattern_non_matching",
+ pathPatterns: []PathPattern{
+ {Operator: "=~", Pattern: "^/api/metrics/.*/(receive|rules)$"},
+ },
+ requestPath: "/api/metrics/v1/query",
+ expectMTLS: false,
+ expectError: false,
+ description: "Complex regex patterns should correctly exclude non-matching paths",
},
}
@@ -139,8 +157,8 @@ func TestMTLSAuthenticator_PathBasedAuthentication(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
// Create mTLS config with path patterns using file-based CA
config := map[string]interface{}{
- "caPath": caPath, // Use file-based CA as original code expects
- "pathPatterns": tt.pathPatterns,
+ "caPath": caPath, // Use file-based CA as original code expects
+ "paths": tt.pathPatterns,
}
// Create mTLS authenticator
@@ -232,8 +250,8 @@ func TestMTLSAuthenticator_InvalidClientCertificate(t *testing.T) {
// Create mTLS config
config := map[string]interface{}{
- "caPath": caPath,
- "pathPatterns": []string{"/api/.*/receive"},
+ "caPath": caPath,
+ "paths": []PathPattern{{Operator: "=~", Pattern: "/api/.*/receive"}},
}
// Create mTLS authenticator
@@ -276,8 +294,8 @@ func TestMTLSAuthenticator_NoTLSConnection(t *testing.T) {
// Create mTLS config
config := map[string]interface{}{
- "caPath": caPath,
- "pathPatterns": []string{"/api/.*/receive"},
+ "caPath": caPath,
+ "paths": []PathPattern{{Operator: "=~", Pattern: "/api/.*/receive"}},
}
// Create mTLS authenticator
@@ -309,7 +327,7 @@ func TestMTLSAuthenticator_NoTLSConnection(t *testing.T) {
func TestMTLSAuthenticator_InvalidPathPattern(t *testing.T) {
// Test that invalid regex patterns are caught during creation
config := map[string]interface{}{
- "pathPatterns": []string{"[invalid-regex"},
+ "paths": []PathPattern{{Operator: "=~", Pattern: "[invalid-regex"}},
}
logger := log.NewNopLogger()
@@ -319,49 +337,55 @@ func TestMTLSAuthenticator_InvalidPathPattern(t *testing.T) {
}
}
-// Test path matching logic without requiring certificate validation
+// Test path matching logic without requiring certificate validation.
func TestMTLSAuthenticator_PathMatchingLogic(t *testing.T) {
tests := []struct {
name string
- pathPatterns []string
+ pathPatterns []PathPattern
requestPath string
expectSkip bool
description string
}{
{
name: "no_patterns_requires_mtls_everywhere",
- pathPatterns: []string{},
+ pathPatterns: []PathPattern{},
requestPath: "/api/v1/query",
expectSkip: false,
description: "No patterns means mTLS required everywhere",
},
{
name: "pattern_matches_requires_mtls",
- pathPatterns: []string{"/api/.*/receive"},
- requestPath: "/api/metrics/v1/receive",
+ pathPatterns: []PathPattern{{Operator: "=~", Pattern: "/api/.*/receive"}},
+ requestPath: "/api/metrics/v1/receive",
expectSkip: false,
description: "Matching pattern requires mTLS",
},
{
name: "pattern_not_matches_skips_mtls",
- pathPatterns: []string{"/api/.*/receive"},
+ pathPatterns: []PathPattern{{Operator: "=~", Pattern: "/api/.*/receive"}},
requestPath: "/api/metrics/v1/query",
expectSkip: true,
description: "Non-matching pattern skips mTLS",
},
{
- name: "multiple_patterns_one_matches",
- pathPatterns: []string{"/api/.*/receive", "/api/.*/rules"},
- requestPath: "/api/logs/v1/rules",
- expectSkip: false,
- description: "One matching pattern requires mTLS",
+ name: "multiple_patterns_one_matches",
+ pathPatterns: []PathPattern{
+ {Operator: "=~", Pattern: "/api/.*/receive"},
+ {Operator: "=~", Pattern: "/api/.*/rules"},
+ },
+ requestPath: "/api/logs/v1/rules",
+ expectSkip: false,
+ description: "One matching pattern requires mTLS",
},
{
- name: "multiple_patterns_none_match",
- pathPatterns: []string{"/api/.*/receive", "/api/.*/rules"},
- requestPath: "/api/metrics/v1/query",
- expectSkip: true,
- description: "No matching patterns skip mTLS",
+ name: "multiple_patterns_none_match",
+ pathPatterns: []PathPattern{
+ {Operator: "=~", Pattern: "/api/.*/receive"},
+ {Operator: "=~", Pattern: "/api/.*/rules"},
+ },
+ requestPath: "/api/metrics/v1/query",
+ expectSkip: true,
+ description: "No matching patterns skip mTLS",
},
}
@@ -369,7 +393,7 @@ func TestMTLSAuthenticator_PathMatchingLogic(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
// Create mTLS config without CA (we're only testing path logic)
config := map[string]interface{}{
- "pathPatterns": tt.pathPatterns,
+ "paths": tt.pathPatterns,
}
logger := log.NewNopLogger()
@@ -379,7 +403,7 @@ func TestMTLSAuthenticator_PathMatchingLogic(t *testing.T) {
}
middleware := authenticator.Middleware()
-
+
handlerCalled := false
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handlerCalled = true
@@ -413,7 +437,7 @@ func TestMTLSAuthenticator_PathMatchingLogic(t *testing.T) {
}
}
-// Test both CA configuration methods work correctly
+// Test both CA configuration methods work correctly.
func TestMTLSAuthenticator_CAConfiguration(t *testing.T) {
// Test file-based CA configuration
t.Run("file_based_ca", func(t *testing.T) {
@@ -449,7 +473,7 @@ func TestMTLSAuthenticator_CAConfiguration(t *testing.T) {
}
config := map[string]interface{}{
- "ca": caPEM, // Direct CA data
+ "ca": caPEM, // Direct CA data
}
logger := log.NewNopLogger()
@@ -466,3 +490,137 @@ func TestMTLSAuthenticator_CAConfiguration(t *testing.T) {
})
}
+func TestMTLSPathPatternsWithOperators(t *testing.T) {
+ tests := []struct {
+ name string
+ paths []PathPattern
+ requestPath string
+ expectSkip bool
+ expectError bool
+ description string
+ }{
+ {
+ name: "positive_match_operator",
+ paths: []PathPattern{{Operator: "=~", Pattern: "/api/.*/receive"}},
+ requestPath: "/api/metrics/v1/receive",
+ expectSkip: false,
+ description: "Positive match with =~ operator should enforce mTLS",
+ },
+ {
+ name: "positive_no_match_operator",
+ paths: []PathPattern{{Operator: "=~", Pattern: "/api/.*/receive"}},
+ requestPath: "/api/metrics/v1/query",
+ expectSkip: true,
+ description: "No match with =~ operator should skip mTLS",
+ },
+ {
+ name: "negative_match_operator",
+ paths: []PathPattern{{Operator: "!~", Pattern: "^/api/(logs|metrics)/v1/auth-tenant/.*(query|labels|series)"}},
+ requestPath: "/api/metrics/v1/auth-tenant/api/v1/receive",
+ expectSkip: false,
+ description: "Path not matching negative pattern should enforce mTLS",
+ },
+ {
+ name: "negative_no_match_operator",
+ paths: []PathPattern{{Operator: "!~", Pattern: "^/api/(logs|metrics)/v1/auth-tenant/.*(query|labels|series)"}},
+ requestPath: "/api/metrics/v1/auth-tenant/api/v1/query",
+ expectSkip: true,
+ description: "Path matching negative pattern should skip mTLS",
+ },
+ {
+ name: "default_operator",
+ paths: []PathPattern{{Pattern: "/api/.*/receive"}}, // no operator specified
+ requestPath: "/api/metrics/v1/receive",
+ expectSkip: false,
+ description: "Default operator should be =~",
+ },
+ {
+ name: "multiple_patterns_one_match",
+ paths: []PathPattern{
+ {Operator: "=~", Pattern: "/api/.*/receive"},
+ {Operator: "=~", Pattern: "/api/.*/push"},
+ },
+ requestPath: "/api/logs/v1/push",
+ expectSkip: false,
+ description: "One matching pattern should enforce mTLS",
+ },
+ {
+ name: "multiple_patterns_none_match",
+ paths: []PathPattern{
+ {Operator: "=~", Pattern: "/api/.*/receive"},
+ {Operator: "=~", Pattern: "/api/.*/push"},
+ },
+ requestPath: "/api/metrics/v1/query",
+ expectSkip: true,
+ description: "No matching patterns should skip mTLS",
+ },
+ {
+ name: "invalid_operator",
+ paths: []PathPattern{{Operator: "invalid", Pattern: "/api/.*/receive"}},
+ expectError: true,
+ description: "Invalid operator should cause error",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Test compilation (similar to newMTLSAuthenticator)
+ var pathMatchers []PathMatcher
+
+ for _, pathPattern := range tt.paths {
+ operator := pathPattern.Operator
+ if operator == "" {
+ operator = "=~" // default operator
+ }
+
+ // Validate operator
+ if operator != "=~" && operator != "!~" {
+ if tt.expectError {
+ return // Expected error
+ }
+ t.Errorf("Invalid operator %q should have caused error", operator)
+ return
+ }
+
+ matcher, err := regexp.Compile(pathPattern.Pattern)
+ if err != nil {
+ if tt.expectError {
+ return // Expected error
+ }
+ t.Fatalf("Failed to compile pattern: %v", err)
+ }
+
+ pathMatchers = append(pathMatchers, PathMatcher{
+ Operator: operator,
+ Regex: matcher,
+ })
+ }
+
+ if tt.expectError {
+ t.Error("Expected error but none occurred")
+ return
+ }
+
+ // Test the matching logic (from middleware)
+ shouldEnforceMTLS := false
+
+ for _, matcher := range pathMatchers {
+ regexMatches := matcher.Regex.MatchString(tt.requestPath)
+
+ if matcher.Operator == "=~" && regexMatches {
+ shouldEnforceMTLS = true
+ break
+ } else if matcher.Operator == "!~" && !regexMatches {
+ shouldEnforceMTLS = true
+ break
+ }
+ }
+
+ shouldSkip := !shouldEnforceMTLS
+
+ if shouldSkip != tt.expectSkip {
+ t.Errorf("Expected skip=%v, got skip=%v for path %s", tt.expectSkip, shouldSkip, tt.requestPath)
+ }
+ })
+ }
+}
diff --git a/authentication/oidc.go b/authentication/oidc.go
index 288fcd2d..2605282c 100644
--- a/authentication/oidc.go
+++ b/authentication/oidc.go
@@ -42,16 +42,16 @@ func init() {
// oidcConfig represents the oidc authenticator config.
type oidcConfig struct {
- ClientID string `json:"clientID"`
- ClientSecret string `json:"clientSecret"`
- GroupClaim string `json:"groupClaim"`
- IssuerRawCA []byte `json:"issuerCA"`
- IssuerCAPath string `json:"issuerCAPath"`
+ ClientID string `json:"clientID"`
+ ClientSecret string `json:"clientSecret"`
+ GroupClaim string `json:"groupClaim"`
+ IssuerRawCA []byte `json:"issuerCA"`
+ IssuerCAPath string `json:"issuerCAPath"`
issuerCA *x509.Certificate
- IssuerURL string `json:"issuerURL"`
- RedirectURL string `json:"redirectURL"`
- UsernameClaim string `json:"usernameClaim"`
- PathPatterns []string `json:"pathPatterns"`
+ IssuerURL string `json:"issuerURL"`
+ RedirectURL string `json:"redirectURL"`
+ UsernameClaim string `json:"usernameClaim"`
+ Paths []PathPattern `json:"paths,omitempty"`
}
type oidcAuthenticator struct {
@@ -65,7 +65,7 @@ type oidcAuthenticator struct {
redirectURL string
oauth2Config oauth2.Config
handler http.Handler
- pathMatchers []*regexp.Regexp
+ pathMatchers []PathMatcher
}
func newOIDCAuthenticator(c map[string]interface{}, tenant string,
@@ -151,13 +151,26 @@ func newOIDCAuthenticator(c map[string]interface{}, tenant string,
verifier := provider.Verifier(&oidc.Config{ClientID: config.ClientID, SkipClientIDCheck: true})
// Compile path patterns
- var pathMatchers []*regexp.Regexp
- for _, pattern := range config.PathPatterns {
- matcher, err := regexp.Compile(pattern)
+ var pathMatchers []PathMatcher
+ for _, pathPattern := range config.Paths {
+ operator := pathPattern.Operator
+ if operator == "" {
+ operator = OperatorMatches
+ }
+
+ if operator != OperatorMatches && operator != OperatorNotMatches {
+ return nil, fmt.Errorf("invalid OIDC path operator %q, must be %q or %q", operator, OperatorMatches, OperatorNotMatches)
+ }
+
+ matcher, err := regexp.Compile(pathPattern.Pattern)
if err != nil {
- return nil, fmt.Errorf("failed to compile OIDC path pattern %q: %v", pattern, err)
+ return nil, fmt.Errorf("failed to compile OIDC path pattern %q: %v", pathPattern.Pattern, err)
}
- pathMatchers = append(pathMatchers, matcher)
+
+ pathMatchers = append(pathMatchers, PathMatcher{
+ Operator: operator,
+ Regex: matcher,
+ })
}
oidcProvider := &oidcAuthenticator{
@@ -290,24 +303,40 @@ func (a oidcAuthenticator) Handler() (string, http.Handler) {
func (a oidcAuthenticator) Middleware() Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ level.Debug(a.logger).Log("msg", "OIDC middleware processing", "path", r.URL.Path, "tenant", a.tenant, "numPatterns", len(a.pathMatchers))
+
// Check if OIDC is required for this path
+ level.Debug(a.logger).Log("msg", "OIDC middleware checking path", "path", r.URL.Path, "pathMatchers", len(a.pathMatchers))
if len(a.pathMatchers) > 0 {
- pathMatches := false
+ shouldEnforceOIDC := false
+
for _, matcher := range a.pathMatchers {
- if matcher.MatchString(r.URL.Path) {
- pathMatches = true
+ regexMatches := matcher.Regex.MatchString(r.URL.Path)
+ level.Debug(a.logger).Log("msg", "OIDC path pattern check", "path", r.URL.Path, "operator", matcher.Operator, "pattern", matcher.Regex.String(), "matches", regexMatches)
+
+ if matcher.Operator == OperatorMatches && regexMatches {
+ // Positive match - enforce OIDC
+ level.Debug(a.logger).Log("msg", "OIDC positive match - enforcing", "path", r.URL.Path)
+ shouldEnforceOIDC = true
+ break
+ } else if matcher.Operator == OperatorNotMatches && !regexMatches {
+ // Negative match - enforce OIDC (path does NOT match pattern)
+ level.Debug(a.logger).Log("msg", "OIDC negative match - enforcing", "path", r.URL.Path)
+ shouldEnforceOIDC = true
break
}
}
-
- // If path doesn't match, skip OIDC enforcement
- if !pathMatches {
+
+ level.Debug(a.logger).Log("msg", "OIDC enforcement decision", "path", r.URL.Path, "shouldEnforceOIDC", shouldEnforceOIDC)
+ // If no patterns matched requirements, skip OIDC enforcement
+ if !shouldEnforceOIDC {
+ level.Debug(a.logger).Log("msg", "OIDC skipping enforcement", "path", r.URL.Path)
next.ServeHTTP(w, r)
return
}
}
- // Path matches or no paths configured, enforce OIDC
+ level.Debug(a.logger).Log("msg", "OIDC enforcing authentication", "path", r.URL.Path, "tenant", a.tenant)
var token string
authorizationHeader := r.Header.Get("Authorization")
@@ -345,6 +374,8 @@ func (a oidcAuthenticator) Middleware() Middleware {
return
}
+ // Mark request as successfully authenticated
+ ctx = SetAuthenticated(ctx)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
diff --git a/authentication/oidc_test.go b/authentication/oidc_test.go
index 8071bae8..94f56261 100644
--- a/authentication/oidc_test.go
+++ b/authentication/oidc_test.go
@@ -1,178 +1,94 @@
package authentication
import (
- "net/http"
- "net/http/httptest"
"regexp"
"testing"
-
- "github.com/go-kit/log"
- "github.com/mitchellh/mapstructure"
)
-func TestOIDCPathMatching(t *testing.T) {
+func TestOIDCPathPatternsWithOperators(t *testing.T) {
tests := []struct {
- name string
- pathPatterns []string
- requestPath string
- expectSkip bool
- description string
+ name string
+ paths []PathPattern
+ requestPath string
+ expectSkip bool
+ expectError bool
+ description string
}{
{
- name: "empty_patterns_enforces_all",
- pathPatterns: []string{},
- requestPath: "/api/v1/query",
- expectSkip: false,
- description: "Empty patterns should enforce OIDC on all paths",
+ name: "positive_match_operator",
+ paths: []PathPattern{{Operator: OperatorMatches, Pattern: "/api/.*/query"}},
+ requestPath: "/api/metrics/v1/query",
+ expectSkip: false,
+ description: "Positive match with =~ operator should enforce OIDC",
},
{
- name: "read_pattern_matches_query",
- pathPatterns: []string{"/api/.*/query", "/api/.*/series"},
- requestPath: "/api/metrics/v1/query",
- expectSkip: false,
- description: "Query path should match read patterns",
+ name: "positive_no_match_operator",
+ paths: []PathPattern{{Operator: OperatorMatches, Pattern: "/api/.*/query"}},
+ requestPath: "/api/metrics/v1/receive",
+ expectSkip: true,
+ description: "No match with =~ operator should skip OIDC",
},
{
- name: "read_pattern_matches_series",
- pathPatterns: []string{"/api/.*/query", "/api/.*/series"},
- requestPath: "/api/logs/v1/series",
- expectSkip: false,
- description: "Series path should match read patterns",
+ name: "negative_match_operator",
+ paths: []PathPattern{{Operator: OperatorNotMatches, Pattern: "^/api/(logs|metrics)/v1/auth-tenant/(loki/api/v1/push|api/v1/receive)"}},
+ requestPath: "/api/metrics/v1/auth-tenant/api/v1/query",
+ expectSkip: false,
+ description: "Path not matching negative pattern should enforce OIDC",
},
{
- name: "write_path_skipped",
- pathPatterns: []string{"/api/.*/query", "/api/.*/series"},
- requestPath: "/api/metrics/v1/receive",
- expectSkip: true,
- description: "Write path should be skipped when not in patterns",
+ name: "negative_no_match_operator",
+ paths: []PathPattern{{Operator: OperatorNotMatches, Pattern: "^/api/(logs|metrics)/v1/auth-tenant/(loki/api/v1/push|api/v1/receive)"}},
+ requestPath: "/api/metrics/v1/auth-tenant/api/v1/receive",
+ expectSkip: true,
+ description: "Path matching negative pattern should skip OIDC",
},
{
- name: "complex_regex_matching",
- pathPatterns: []string{"^/api/metrics/.*/(query|series)$"},
- requestPath: "/api/metrics/v1/query",
- expectSkip: false,
- description: "Complex regex should match correctly",
+ name: "default_operator",
+ paths: []PathPattern{{Pattern: "/api/.*/query"}}, // no operator specified
+ requestPath: "/api/metrics/v1/query",
+ expectSkip: false,
+ description: "Default operator should be =~",
},
{
- name: "complex_regex_non_matching",
- pathPatterns: []string{"^/api/metrics/.*/(query|series)$"},
- requestPath: "/api/logs/v1/query",
- expectSkip: true,
- description: "Complex regex should exclude non-matching paths",
+ name: "invalid_operator",
+ paths: []PathPattern{{Operator: "invalid", Pattern: "/api/.*/query"}},
+ expectError: true,
+ description: "Invalid operator should cause error",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- // Create a testable version of the OIDC path matching logic
- pathMatchers := make([]*regexp.Regexp, 0, len(tt.pathPatterns))
- for _, pattern := range tt.pathPatterns {
- matcher, err := regexp.Compile(pattern)
- if err != nil {
- t.Fatalf("Failed to compile pattern %q: %v", pattern, err)
- }
- pathMatchers = append(pathMatchers, matcher)
- }
+ // Test compilation (similar to newOIDCAuthenticator)
+ var pathMatchers []PathMatcher
- // Test the path matching logic (extracted from oidcAuthenticator.Middleware)
- shouldSkip := false
- if len(pathMatchers) > 0 {
- pathMatches := false
- for _, matcher := range pathMatchers {
- if matcher.MatchString(tt.requestPath) {
- pathMatches = true
- break
- }
+ for _, pathPattern := range tt.paths {
+ operator := pathPattern.Operator
+ if operator == "" {
+ operator = OperatorMatches // default operator
}
- shouldSkip = !pathMatches
- }
-
- if shouldSkip != tt.expectSkip {
- t.Errorf("Expected skip=%v, got skip=%v for path %q with patterns %v",
- tt.expectSkip, shouldSkip, tt.requestPath, tt.pathPatterns)
- }
- })
- }
-}
-
-func TestOIDCConfigPathPatternsIntegration(t *testing.T) {
- // Test that path patterns are correctly passed to the OIDC authenticator config
- tests := []struct {
- name string
- configData map[string]interface{}
- expectError bool
- expectPaths []string
- description string
- }{
- {
- name: "valid_path_patterns",
- configData: map[string]interface{}{
- "pathPatterns": []string{"/api/.*/query", "/api/.*/series"},
- "clientID": "test-client",
- "issuerURL": "https://example.com",
- },
- expectError: false,
- expectPaths: []string{"/api/.*/query", "/api/.*/series"},
- description: "Valid path patterns should be accepted",
- },
- {
- name: "empty_path_patterns",
- configData: map[string]interface{}{
- "pathPatterns": []string{},
- "clientID": "test-client",
- "issuerURL": "https://example.com",
- },
- expectError: false,
- expectPaths: []string{},
- description: "Empty path patterns should be accepted",
- },
- {
- name: "missing_path_patterns",
- configData: map[string]interface{}{
- "clientID": "test-client",
- "issuerURL": "https://example.com",
- },
- expectError: false,
- expectPaths: nil,
- description: "Missing path patterns should default to nil/empty",
- },
- {
- name: "invalid_regex_pattern",
- configData: map[string]interface{}{
- "pathPatterns": []string{"[invalid-regex"},
- "clientID": "test-client",
- "issuerURL": "https://example.com",
- },
- expectError: true,
- description: "Invalid regex should cause creation to fail",
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Test the config decoding and pattern compilation
- var config oidcConfig
- err := mapstructure.Decode(tt.configData, &config)
- if err != nil {
- if !tt.expectError {
- t.Errorf("Unexpected error decoding config: %v", err)
+ // Validate operator
+ if operator != OperatorMatches && operator != OperatorNotMatches {
+ if tt.expectError {
+ return // Expected error
+ }
+ t.Errorf("Invalid operator %q should have caused error", operator)
+ return
}
- return
- }
- // Test path pattern compilation (this is what happens in newOIDCAuthenticator)
- var pathMatchers []*regexp.Regexp
- for _, pattern := range config.PathPatterns {
- matcher, err := regexp.Compile(pattern)
+ matcher, err := regexp.Compile(pathPattern.Pattern)
if err != nil {
if tt.expectError {
- // Expected error
- return
+ return // Expected error
}
- t.Errorf("Unexpected error compiling pattern %q: %v", pattern, err)
- return
+ t.Fatalf("Failed to compile pattern: %v", err)
}
- pathMatchers = append(pathMatchers, matcher)
+
+ pathMatchers = append(pathMatchers, PathMatcher{
+ Operator: operator,
+ Regex: matcher,
+ })
}
if tt.expectError {
@@ -180,90 +96,26 @@ func TestOIDCConfigPathPatternsIntegration(t *testing.T) {
return
}
- // Verify the patterns match expectations
- if len(config.PathPatterns) != len(tt.expectPaths) {
- t.Errorf("Expected %d path patterns, got %d", len(tt.expectPaths), len(config.PathPatterns))
- }
-
- for i, expected := range tt.expectPaths {
- if i >= len(config.PathPatterns) {
- t.Errorf("Missing expected path pattern: %q", expected)
- continue
- }
- if config.PathPatterns[i] != expected {
- t.Errorf("Expected path pattern %q, got %q", expected, config.PathPatterns[i])
- }
- }
- })
- }
-}
-
-// Test the actual OIDC middleware directly - no mocking needed!
-func TestOIDCMiddlewareActual(t *testing.T) {
- // Just test the middleware behavior directly by calling the authenticator's Middleware() method
- tests := []struct {
- name string
- pathPatterns []string
- requestPath string
- expectSkipped bool
- }{
- {
- name: "non_matching_path_skipped",
- pathPatterns: []string{"/api/.*/query"},
- requestPath: "/api/metrics/v1/receive",
- expectSkipped: true,
- },
- {
- name: "matching_path_not_skipped",
- pathPatterns: []string{"/api/.*/query"},
- requestPath: "/api/metrics/v1/query",
- expectSkipped: false,
- },
- }
+ // Test the matching logic (from middleware)
+ shouldEnforceOIDC := false
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Create actual oidcAuthenticator with compiled path patterns
- authenticator := &oidcAuthenticator{
- tenant: "test-tenant",
- logger: log.NewNopLogger(), // Initialize logger to prevent panic
- }
+ for _, matcher := range pathMatchers {
+ regexMatches := matcher.Regex.MatchString(tt.requestPath)
- // Compile patterns exactly like the real newOIDCAuthenticator does
- for _, pattern := range tt.pathPatterns {
- matcher, err := regexp.Compile(pattern)
- if err != nil {
- t.Fatalf("Failed to compile pattern: %v", err)
+ if matcher.Operator == OperatorMatches && regexMatches {
+ shouldEnforceOIDC = true
+ break
+ } else if matcher.Operator == OperatorNotMatches && !regexMatches {
+ shouldEnforceOIDC = true
+ break
}
- authenticator.pathMatchers = append(authenticator.pathMatchers, matcher)
}
- // Get the REAL middleware function
- middleware := authenticator.Middleware()
-
- handlerCalled := false
- testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- handlerCalled = true
- w.WriteHeader(http.StatusOK)
- })
-
- req := httptest.NewRequest("GET", tt.requestPath, nil)
- rr := httptest.NewRecorder()
-
- // Test the real middleware
- middleware(testHandler).ServeHTTP(rr, req)
+ shouldSkip := !shouldEnforceOIDC
- if tt.expectSkipped {
- if !handlerCalled || rr.Code != http.StatusOK {
- t.Error("Path should be skipped and handler called")
- }
- } else {
- // Should attempt OIDC auth and fail (no token/invalid setup)
- if rr.Code == http.StatusOK {
- t.Error("Path should NOT be skipped - OIDC should run")
- }
+ if shouldSkip != tt.expectSkip {
+ t.Errorf("Expected skip=%v, got skip=%v for path %s", tt.expectSkip, shouldSkip, tt.requestPath)
}
})
}
}
-
diff --git a/ca.crt b/ca.crt
new file mode 100644
index 00000000..4978a358
--- /dev/null
+++ b/ca.crt
@@ -0,0 +1,18 @@
+-----BEGIN CERTIFICATE-----
+MIIC8TCCAdmgAwIBAgIRAKDd6vjPOr3k93H2U3330S0wDQYJKoZIhvcNAQELBQAw
+EjEQMA4GA1UEAxMHUm9vdCBDQTAeFw0yNjA0MDEwODE3MjdaFw0yNzA0MDEwODE3
+MjdaMBIxEDAOBgNVBAMTB1Jvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw
+ggEKAoIBAQC+fbR5moNS+glcSxHAVuccOxm0+y+6F2uQD/kQjUkuGgKLEU5Wa0tm
+Tg/to11wOqQxRjwosg23lJJlsg8ifY1JzcW1h9jsD5ZEjxl4GKsc41KQaliboy/2
++Cpyjfyeon59y+P6qhbSoeDjj08UWoqfIj0KJAJ6PhoNIg1WRuTHtx8I5esUiigT
+222pfaO9iOkaeb+BahXhYvhZOrad8dW1Cl43eo0BaUv8lony4yFIi7r+mNIgJpkL
+yudMPxa9/ttw8RYj7pz0Sc18O0Y3yZh8Iec+fM9gY6vLGGDVjIYaQiQ0k4r8OZuC
+yPZHatyUG8nwT28R3yFCigzp92B04ZIVAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIC
+pDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBQGyLa/5zX7IeX2gJpBbzoQX7Xl
+RTANBgkqhkiG9w0BAQsFAAOCAQEAYELztL4aB+yD4e0uTXK+zyd3IfDNqXoFZoOL
+rSMZepx61FVEjyWdU1VJMxJL3CmQqyAAOFmE3mWcqMOsY7hUJzJUOMnbPgnNJEuN
+qwdrjjYsBs+vh+qylp8M0mkf7UZ8XoqMulVPwDz9DFl6V8Z1d2aKuCu9tz8GzhC0
+404kKWGPfu1KexHN545XiGzBDF5kyOgN17GxY5cbs3J0vw4N+L5Zd78rAVlToPFL
+SdLEGUcUrgaYgxXga8yO14Nd5CVVtXNPfdSQK7eGhk0tW+aEwBpo7FLBb8o63/X8
+ugB5mtHKlhhflU7n2P5yhMRvi/1go7Cup5g9FBxKOfWfCEmKwA==
+-----END CERTIFICATE-----
diff --git a/main.go b/main.go
index a7688e23..c08bab9e 100644
--- a/main.go
+++ b/main.go
@@ -238,11 +238,10 @@ type tenant struct {
IssuerRawCA []byte `json:"issuerCA"`
IssuerCAPath string `json:"issuerCAPath"`
issuerCA *x509.Certificate
- IssuerURL string `json:"issuerURL"`
- RedirectURL string `json:"redirectURL"`
- UsernameClaim string `json:"usernameClaim"`
- Paths []string `json:"paths"`
- pathMatchers []*regexp.Regexp
+ IssuerURL string `json:"issuerURL"`
+ RedirectURL string `json:"redirectURL"`
+ UsernameClaim string `json:"usernameClaim"`
+ Paths []authentication.PathPattern `json:"paths"`
config map[string]interface{}
} `json:"oidc"`
OpenShift *struct {
@@ -258,12 +257,11 @@ type tenant struct {
} `json:"authenticator"`
MTLS *struct {
- RawCA []byte `json:"ca"`
- CAPath string `json:"caPath"`
- Paths []string `json:"paths"`
- cas []*x509.Certificate
- pathMatchers []*regexp.Regexp
- config map[string]interface{}
+ RawCA []byte `json:"ca"`
+ CAPath string `json:"caPath"`
+ Paths []authentication.PathPattern `json:"paths"`
+ cas []*x509.Certificate
+ config map[string]interface{}
} `json:"mTLS"`
OPA *struct {
Query string `json:"query"`
@@ -368,17 +366,6 @@ func main() {
continue
}
- // Compile OIDC path matchers
- for _, pathPattern := range t.OIDC.Paths {
- matcher, err := regexp.Compile(pathPattern)
- if err != nil {
- skip.Log("msg", "failed to compile OIDC path pattern", "pattern", pathPattern, "err", err, "tenant", t.Name)
- skippedTenants.WithLabelValues(t.Name).Inc()
- tenantsCfg.Tenants[i] = nil
- break
- }
- t.OIDC.pathMatchers = append(t.OIDC.pathMatchers, matcher)
- }
if tenantsCfg.Tenants[i] == nil {
continue
}
@@ -397,17 +384,6 @@ func main() {
continue
}
- // Compile mTLS path matchers
- for _, pathPattern := range t.MTLS.Paths {
- matcher, err := regexp.Compile(pathPattern)
- if err != nil {
- skip.Log("msg", "failed to compile mTLS path pattern", "pattern", pathPattern, "err", err, "tenant", t.Name)
- skippedTenants.WithLabelValues(t.Name).Inc()
- tenantsCfg.Tenants[i] = nil
- break
- }
- t.MTLS.pathMatchers = append(t.MTLS.pathMatchers, matcher)
- }
if tenantsCfg.Tenants[i] == nil {
continue
}
@@ -646,26 +622,34 @@ func main() {
}
}
- authenticatorConfig, authenticatorType, err := tenantAuthenticatorConfig(t)
+ authenticatorConfigs, err := tenantAuthenticatorConfigs(t)
if err != nil {
stdlog.Fatal(err.Error())
}
- if authenticatorType == authentication.OIDCAuthenticatorType {
- oidcTenants[t.Name] = struct{}{}
+
+ // Check if any authenticator is OIDC to track for write path redirect protection
+ for _, authConfig := range authenticatorConfigs {
+ if authConfig.Type == authentication.OIDCAuthenticatorType {
+ oidcTenants[t.Name] = struct{}{}
+ break
+ }
}
- go func(config map[string]interface{}, authType, tenant string) {
- initializedAuthenticator := <-pm.InitializeProvider(config, tenant, authType, registerTenantsFailingMetric, logger)
- if initializedAuthenticator != nil {
- pattern, _ := initializedAuthenticator.Handler()
- regMtx.Lock()
- defer regMtx.Unlock()
- if _, ok := registeredAuthNRoutes[pattern]; !ok && pattern != "" {
- registeredAuthNRoutes[pattern] = struct{}{}
- r.Mount(pattern, pm.PatternHandler(pattern))
+ // Initialize all authenticators for this tenant
+ for _, authConfig := range authenticatorConfigs {
+ go func(config map[string]interface{}, authType, tenant string) {
+ initializedAuthenticator := <-pm.InitializeProvider(config, tenant, authType, registerTenantsFailingMetric, logger)
+ if initializedAuthenticator != nil {
+ pattern, _ := initializedAuthenticator.Handler()
+ regMtx.Lock()
+ defer regMtx.Unlock()
+ if _, ok := registeredAuthNRoutes[pattern]; !ok && pattern != "" {
+ registeredAuthNRoutes[pattern] = struct{}{}
+ r.Mount(pattern, pm.PatternHandler(pattern))
+ }
}
- }
- }(authenticatorConfig, authenticatorType, t.Name)
+ }(authConfig.Config, authConfig.Type, t.Name)
+ }
if t.OPA != nil {
authorizers[t.Name] = t.OPA.authorizer
@@ -674,8 +658,6 @@ func main() {
}
}
- writePathRedirectProtection := authentication.EnforceAccessTokenPresentOnSignalWrite(oidcTenants)
-
// Metrics.
if cfg.metrics.enabled {
@@ -712,6 +694,7 @@ func main() {
metricsMiddlewares := []func(http.Handler) http.Handler{
authentication.WithTenantMiddlewares(pm.Middlewares),
+ authentication.EnforceAuthentication(),
authentication.WithTenantHeader(cfg.metrics.tenantHeader, tenantIDs),
rateLimitMiddleware,
}
@@ -775,9 +758,11 @@ func main() {
probesv1.WithKeepAliveTimeout(cfg.probes.keepAliveTimeout),
probesv1.WithTLSHandshakeTimeout(cfg.probes.tlsHandshakeTimeout),
probesv1.WithReadMiddleware(authentication.WithTenantMiddlewares(pm.Middlewares)),
+ probesv1.WithReadMiddleware(authentication.EnforceAuthentication()),
probesv1.WithReadMiddleware(rateLimitMiddleware),
probesv1.WithReadMiddleware(authorization.WithAuthorizers(authorizers, rbac.Read, "probes")),
probesv1.WithWriteMiddleware(authentication.WithTenantMiddlewares(pm.Middlewares)),
+ probesv1.WithWriteMiddleware(authentication.EnforceAuthentication()),
probesv1.WithWriteMiddleware(rateLimitMiddleware),
probesv1.WithWriteMiddleware(authorization.WithAuthorizers(authorizers, rbac.Write, "probes")),
)
@@ -799,7 +784,6 @@ func main() {
metricsv1.WithHandlerInstrumenter(instrumenter),
metricsv1.WithSpanRoutePrefix("/api/metrics/v1/{tenant}"),
metricsv1.WithTenantLabel(cfg.metrics.tenantLabel),
- metricsv1.WithWriteMiddleware(writePathRedirectProtection),
metricsv1.WithGlobalMiddleware(metricsMiddlewares...),
metricsv1.WithWriteMiddleware(authorization.WithAuthorizers(authorizers, rbac.Write, "metrics")),
metricsv1.WithQueryMiddleware(authorization.WithAuthorizers(authorizers, rbac.Read, "metrics")),
@@ -862,8 +846,8 @@ func main() {
logsv1.WithRegistry(reg),
logsv1.WithHandlerInstrumenter(instrumenter),
logsv1.WithSpanRoutePrefix("/api/logs/v1/{tenant}"),
- logsv1.WithWriteMiddleware(writePathRedirectProtection),
logsv1.WithGlobalMiddleware(authentication.WithTenantMiddlewares(pm.Middlewares)),
+ logsv1.WithGlobalMiddleware(authentication.EnforceAuthentication()),
logsv1.WithGlobalMiddleware(authentication.WithTenantHeader(cfg.logs.tenantHeader, tenantIDs)),
logsv1.WithReadMiddleware(authorization.WithLogsStreamSelectorsExtractor(logger, cfg.logs.authExtractSelectors)),
logsv1.WithReadMiddleware(authorization.WithAuthorizers(authorizers, rbac.Read, "logs")),
@@ -901,6 +885,7 @@ func main() {
r.Group(func(r chi.Router) {
r.Use(authentication.WithTenantMiddlewares(pm.Middlewares))
+ r.Use(authentication.EnforceAuthentication())
r.Use(authentication.WithTenantHeader(cfg.traces.tenantHeader, tenantIDs))
if cfg.traces.queryRBAC {
r.Use(tracesv1.WithTraceQLNamespaceSelectAndForbidOtherAPIs())
@@ -1590,21 +1575,48 @@ func unmarshalLegacyAuthenticatorConfig(v interface{}) (map[string]interface{},
return config, nil
}
-func tenantAuthenticatorConfig(t *tenant) (map[string]interface{}, string, error) {
- switch {
- case t.OIDC != nil:
- return t.OIDC.config, authentication.OIDCAuthenticatorType, nil
- case t.OpenShift != nil:
- return t.OpenShift.config, authentication.OpenShiftAuthenticatorType, nil
- case t.MTLS != nil:
- return t.MTLS.config, authentication.MTLSAuthenticatorType, nil
- case t.Authenticator != nil:
- return t.Authenticator.Config, t.Authenticator.Type, nil
- default:
- return nil, "", fmt.Errorf("tenant %q must specify either an OIDC, mTLS, openshift or a supported authenticator configuration", t.Name)
- }
+type authenticatorConfig struct {
+ Config map[string]interface{}
+ Type string
}
+func tenantAuthenticatorConfigs(t *tenant) ([]authenticatorConfig, error) {
+ var configs []authenticatorConfig
+
+ if t.OIDC != nil {
+ configs = append(configs, authenticatorConfig{
+ Config: t.OIDC.config,
+ Type: authentication.OIDCAuthenticatorType,
+ })
+ }
+
+ if t.MTLS != nil {
+ configs = append(configs, authenticatorConfig{
+ Config: t.MTLS.config,
+ Type: authentication.MTLSAuthenticatorType,
+ })
+ }
+
+ if t.OpenShift != nil {
+ configs = append(configs, authenticatorConfig{
+ Config: t.OpenShift.config,
+ Type: authentication.OpenShiftAuthenticatorType,
+ })
+ }
+
+ if t.Authenticator != nil {
+ configs = append(configs, authenticatorConfig{
+ Config: t.Authenticator.Config,
+ Type: t.Authenticator.Type,
+ })
+ }
+
+ if len(configs) == 0 {
+ return nil, fmt.Errorf("tenant %q must specify at least one authenticator configuration", t.Name)
+ }
+
+ return configs, nil
+}
type otelErrorHandler struct {
logger log.Logger
diff --git a/main_test.go b/main_test.go
index a013559b..624a5299 100644
--- a/main_test.go
+++ b/main_test.go
@@ -22,11 +22,19 @@ tenants:
- name: "test-tenant"
id: "tenant-123"
oidc:
- paths: ["/api/.*/query", "/api/.*/series"]
+ paths:
+ - operator: "=~"
+ pattern: "/api/.*/query"
+ - operator: "=~"
+ pattern: "/api/.*/series"
clientID: "test-client-id"
issuerURL: "https://auth.example.com"
mTLS:
- paths: ["/api/.*/receive", "/api/.*/rules"]
+ paths:
+ - operator: "=~"
+ pattern: "/api/.*/receive"
+ - operator: "=~"
+ pattern: "/api/.*/rules"
ca: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t"
`,
expectError: false,
@@ -39,7 +47,9 @@ tenants:
- name: "oidc-tenant"
id: "tenant-456"
oidc:
- paths: ["/api/.*"]
+ paths:
+ - operator: "=~"
+ pattern: "/api/.*"
clientID: "oidc-client-id"
issuerURL: "https://oidc.example.com"
`,
@@ -53,7 +63,9 @@ tenants:
- name: "mtls-tenant"
id: "tenant-789"
mTLS:
- paths: ["/api/.*"]
+ paths:
+ - operator: "=~"
+ pattern: "/api/.*"
ca: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t"
`,
expectError: false,
@@ -80,11 +92,17 @@ tenants:
- name: "regex-tenant"
id: "tenant-regex"
oidc:
- paths: ["^/api/metrics/.*/(query|query_range|series|labels)$"]
+ paths:
+ - operator: "=~"
+ pattern: "^/api/metrics/.*/(query|query_range|series|labels)$"
clientID: "test-client-id"
issuerURL: "https://auth.example.com"
mTLS:
- paths: ["^/api/metrics/.*/(receive|rules)$", "^/api/logs/.*/rules$"]
+ paths:
+ - operator: "=~"
+ pattern: "^/api/metrics/.*/(receive|rules)$"
+ - operator: "=~"
+ pattern: "^/api/logs/.*/rules$"
ca: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t"
`,
expectError: false,
@@ -97,7 +115,9 @@ tenants:
- name: "invalid-regex-tenant"
id: "tenant-invalid"
oidc:
- paths: ["[invalid-regex"]
+ paths:
+ - operator: "=~"
+ pattern: "[invalid-regex"
clientID: "test-client-id"
issuerURL: "https://auth.example.com"
`,
@@ -111,11 +131,15 @@ tenants:
- name: "overlap-tenant"
id: "tenant-overlap"
oidc:
- paths: ["/api/.*"]
+ paths:
+ - operator: "=~"
+ pattern: "/api/.*"
clientID: "test-client-id"
issuerURL: "https://auth.example.com"
mTLS:
- paths: ["/api/.*/receive"]
+ paths:
+ - operator: "=~"
+ pattern: "/api/.*/receive"
ca: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t"
`,
expectError: false,
@@ -144,36 +168,38 @@ tenants:
// Test OIDC path pattern compilation
if tenant.OIDC != nil {
for _, pathPattern := range tenant.OIDC.Paths {
- matcher, err := regexp.Compile(pathPattern)
+ matcher, err := regexp.Compile(pathPattern.Pattern)
if tt.expectError {
if err == nil {
- t.Errorf("Expected error for invalid regex pattern %q, but got none", pathPattern)
+ t.Errorf("Expected error for invalid regex pattern %q, but got none", pathPattern.Pattern)
}
return
}
if err != nil {
- t.Errorf("Failed to compile OIDC pattern %q: %v", pathPattern, err)
+ t.Errorf("Failed to compile OIDC pattern %q: %v", pathPattern.Pattern, err)
return
}
- tenant.OIDC.pathMatchers = append(tenant.OIDC.pathMatchers, matcher)
+ // Path pattern compilation is now handled by the authenticator during initialization
+ _ = matcher // Just verify it compiles
}
}
// Test mTLS path pattern compilation
if tenant.MTLS != nil {
for _, pathPattern := range tenant.MTLS.Paths {
- matcher, err := regexp.Compile(pathPattern)
+ matcher, err := regexp.Compile(pathPattern.Pattern)
if tt.expectError {
if err == nil {
- t.Errorf("Expected error for invalid regex pattern %q, but got none", pathPattern)
+ t.Errorf("Expected error for invalid regex pattern %q, but got none", pathPattern.Pattern)
}
return
}
if err != nil {
- t.Errorf("Failed to compile mTLS pattern %q: %v", pathPattern, err)
+ t.Errorf("Failed to compile mTLS pattern %q: %v", pathPattern.Pattern, err)
return
}
- tenant.MTLS.pathMatchers = append(tenant.MTLS.pathMatchers, matcher)
+ // Path pattern compilation is now handled by the authenticator during initialization
+ _ = matcher // Just verify it compiles
}
}
@@ -195,13 +221,13 @@ tenants:
func TestPathMatchingBehavior(t *testing.T) {
tests := []struct {
- name string
- oidcPaths []string
- mtlsPaths []string
- testPath string
- expectOIDC bool
- expectMTLS bool
- description string
+ name string
+ oidcPaths []string
+ mtlsPaths []string
+ testPath string
+ expectOIDC bool
+ expectMTLS bool
+ description string
}{
{
name: "read_path_oidc_only",
@@ -250,9 +276,9 @@ func TestPathMatchingBehavior(t *testing.T) {
},
{
name: "case_sensitive_matching",
- oidcPaths: []string{"/api/.*/Query"}, // uppercase Q
+ oidcPaths: []string{"/api/.*/Query"}, // uppercase Q
mtlsPaths: []string{"/api/.*/receive"},
- testPath: "/api/metrics/v1/query", // lowercase q
+ testPath: "/api/metrics/v1/query", // lowercase q
expectOIDC: false,
expectMTLS: false,
description: "Pattern matching should be case sensitive",
@@ -310,5 +336,3 @@ func TestPathMatchingBehavior(t *testing.T) {
})
}
}
-
-
diff --git a/test/e2e/path_rbac_test.go b/test/e2e/path_rbac_test.go
new file mode 100644
index 00000000..90b4ff41
--- /dev/null
+++ b/test/e2e/path_rbac_test.go
@@ -0,0 +1,408 @@
+//go:build integration
+
+package e2e
+
+import (
+ "context"
+ "crypto/tls"
+ "crypto/x509"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/efficientgo/core/testutil"
+ "github.com/efficientgo/e2e"
+)
+
+// PathBasedTestUser represents a test user with specific path-based permissions
+type PathBasedTestUser struct {
+ Name string
+ CertFile string
+ KeyFile string
+ Tenant string
+ AllowedPaths []string
+ DeniedPaths []string
+}
+
+func TestPathBasedRBAC(t *testing.T) {
+ t.Parallel()
+
+ e, err := e2e.New(e2e.WithName("path-rbac-test"))
+ testutil.Ok(t, err)
+ t.Cleanup(e.Close)
+
+ // Prepare configuration and certificates including path-based ones
+ preparePathBasedConfigsAndCerts(t, e)
+
+ // Start base services (without rate limiter for simplicity)
+ _, _, _ = startBaseServices(t, e, metrics)
+
+ // Start backend services for testing
+ readEndpoint, writeEndpoint, _ := startServicesForMetrics(t, e)
+ logsEndpoint := startServicesForLogs(t, e)
+
+ // Create Observatorium API with path-based RBAC configuration
+ api, err := newPathBasedObservatoriumAPIService(
+ e,
+ withMetricsEndpoints("http://"+readEndpoint, "http://"+writeEndpoint),
+ withLogsEndpoints("http://"+logsEndpoint, "http://"+logsEndpoint),
+ )
+ testutil.Ok(t, err)
+ testutil.Ok(t, e2e.StartAndWaitReady(api))
+
+ // Define test users with different path permissions
+ testUsers := []PathBasedTestUser{
+ {
+ Name: "admin",
+ CertFile: "admin.crt",
+ KeyFile: "admin.key",
+ Tenant: "test",
+ AllowedPaths: []string{
+ "/api/v1/query",
+ "/api/v1/query_range",
+ "/api/v1/receive",
+ "/api/v1/series",
+ "/api/v1/labels",
+ },
+ DeniedPaths: []string{}, // Admin should have access to everything
+ },
+ {
+ Name: "query-user",
+ CertFile: "query-user.crt",
+ KeyFile: "query-user.key",
+ Tenant: "test",
+ AllowedPaths: []string{
+ "/api/v1/query",
+ "/api/v1/query_range",
+ },
+ DeniedPaths: []string{
+ "/api/v1/receive",
+ "/api/v1/series",
+ "/api/v1/labels",
+ },
+ },
+ {
+ Name: "write-user",
+ CertFile: "write-user.crt",
+ KeyFile: "write-user.key",
+ Tenant: "test",
+ AllowedPaths: []string{
+ "/api/v1/receive",
+ },
+ DeniedPaths: []string{
+ "/api/v1/query",
+ "/api/v1/query_range",
+ "/api/v1/series",
+ "/api/v1/labels",
+ },
+ },
+ {
+ Name: "readonly-user",
+ CertFile: "test.crt",
+ KeyFile: "test.key",
+ Tenant: "test",
+ AllowedPaths: []string{
+ "/api/v1/query",
+ "/api/v1/query_range",
+ "/api/v1/series",
+ "/api/v1/labels",
+ },
+ DeniedPaths: []string{
+ "/api/v1/receive",
+ },
+ },
+ }
+
+ // Test each user's access patterns
+ for _, user := range testUsers {
+ t.Run(fmt.Sprintf("user_%s", user.Name), func(t *testing.T) {
+ testUserPathAccess(t, e, api, user)
+ })
+ }
+
+ // Test cross-tenant access (should be denied)
+ t.Run("cross_tenant_access", func(t *testing.T) {
+ testCrossTenantAccess(t, e, api, testUsers[0]) // Use admin user but wrong tenant
+ })
+
+ // Test no certificate access (should be denied)
+ t.Run("no_certificate_access", func(t *testing.T) {
+ testNoCertificateAccess(t, api)
+ })
+}
+
+func preparePathBasedConfigsAndCerts(t *testing.T, e e2e.Environment) {
+ // Generate certificates for path-based RBAC testing
+ generatePathBasedCerts(t, e)
+
+ // Copy enhanced RBAC configuration
+ copyPathBasedConfigs(t, e)
+}
+
+func generatePathBasedCerts(t *testing.T, e e2e.Environment) {
+ certsDir := filepath.Join(e.SharedDir(), certsSharedDir)
+
+ // Generate server certificates
+ testutil.Ok(t, generateServerCert(certsDir, "observatorium-api"))
+
+ // Generate client certificates for different users
+ users := []struct {
+ name string
+ cn string
+ ou string
+ }{
+ {"admin", "admin@example.com", "admins"},
+ {"test", "test@example.com", "users"},
+ {"query-user", "query@example.com", "query-users"},
+ {"write-user", "write@example.com", "write-users"},
+ {"logs-reader", "logs-reader@example.com", "logs-readers"},
+ }
+
+ for _, user := range users {
+ testutil.Ok(t, generateClientCert(certsDir, user.name, user.cn, user.ou))
+ }
+}
+
+func copyPathBasedConfigs(t *testing.T, e e2e.Environment) {
+ configDir := filepath.Join(e.SharedDir(), configSharedDir)
+
+ // Copy base configuration
+ testutil.Ok(t, copyFile("../config", configDir))
+
+ // Copy path-based RBAC configuration
+ testutil.Ok(t, copyFile("../../demo/rbac-with-paths.yaml", filepath.Join(configDir, "rbac.yaml")))
+
+ // Copy enhanced OPA policy
+ testutil.Ok(t, copyFile("../../demo/observatorium-path-based.rego", filepath.Join(configDir, "observatorium.rego")))
+}
+
+func testUserPathAccess(t *testing.T, e e2e.Environment, api e2e.Runnable, user PathBasedTestUser) {
+ client := createTLSClient(t, e, user.CertFile, user.KeyFile)
+ baseURL := fmt.Sprintf("https://%s", api.InternalEndpoint("https"))
+
+ // Test allowed paths
+ for _, path := range user.AllowedPaths {
+ t.Run(fmt.Sprintf("allowed_%s", strings.ReplaceAll(path, "/", "_")), func(t *testing.T) {
+ testEndpointAccess(t, client, baseURL, path, user.Tenant, true)
+ })
+ }
+
+ // Test denied paths
+ for _, path := range user.DeniedPaths {
+ t.Run(fmt.Sprintf("denied_%s", strings.ReplaceAll(path, "/", "_")), func(t *testing.T) {
+ testEndpointAccess(t, client, baseURL, path, user.Tenant, false)
+ })
+ }
+}
+
+func testCrossTenantAccess(t *testing.T, e e2e.Environment, api e2e.Runnable, user PathBasedTestUser) {
+ client := createTLSClient(t, e, user.CertFile, user.KeyFile)
+ baseURL := fmt.Sprintf("https://%s", api.InternalEndpoint("https"))
+
+ // Try to access with a different tenant (should be denied)
+ wrongTenant := "unauthorized-tenant"
+ path := "/api/v1/query"
+
+ resp, err := makeRequest(client, baseURL, path, wrongTenant, "GET", nil)
+ testutil.Ok(t, err)
+ defer resp.Body.Close()
+
+ if resp.StatusCode == http.StatusOK {
+ t.Errorf("Expected access denial for cross-tenant request, but got status %d", resp.StatusCode)
+ }
+}
+
+func testNoCertificateAccess(t *testing.T, api e2e.Runnable) {
+ // Create client without certificates
+ client := &http.Client{
+ Transport: &http.Transport{
+ TLSClientConfig: &tls.Config{
+ InsecureSkipVerify: true,
+ },
+ },
+ Timeout: 10 * time.Second,
+ }
+
+ baseURL := fmt.Sprintf("https://%s", api.InternalEndpoint("https"))
+ path := "/api/v1/query"
+ tenant := "test"
+
+ resp, err := makeRequest(client, baseURL, path, tenant, "GET", nil)
+ testutil.Ok(t, err)
+ defer resp.Body.Close()
+
+ // Should be denied (4xx status code)
+ if resp.StatusCode < 400 || resp.StatusCode >= 500 {
+ t.Errorf("Expected 4xx status for no certificate access, but got %d", resp.StatusCode)
+ }
+}
+
+func testEndpointAccess(t *testing.T, client *http.Client, baseURL, path, tenant string, shouldAllow bool) {
+ var method string
+ var body io.Reader
+
+ // Determine HTTP method based on path
+ if strings.Contains(path, "receive") {
+ method = "POST"
+ body = strings.NewReader("test_metric 1")
+ } else {
+ method = "GET"
+ body = nil
+ }
+
+ resp, err := makeRequest(client, baseURL, path, tenant, method, body)
+ testutil.Ok(t, err)
+ defer resp.Body.Close()
+
+ if shouldAllow {
+ if resp.StatusCode >= 400 {
+ bodyBytes, _ := io.ReadAll(resp.Body)
+ t.Errorf("Expected access to be allowed for path %s, but got status %d: %s",
+ path, resp.StatusCode, string(bodyBytes))
+ }
+ } else {
+ if resp.StatusCode < 400 {
+ t.Errorf("Expected access to be denied for path %s, but got status %d", path, resp.StatusCode)
+ }
+ }
+}
+
+func createTLSClient(t *testing.T, e e2e.Environment, certFile, keyFile string) *http.Client {
+ certsDir := filepath.Join(e.SharedDir(), certsSharedDir)
+
+ cert, err := tls.LoadX509KeyPair(
+ filepath.Join(certsDir, certFile),
+ filepath.Join(certsDir, keyFile),
+ )
+ testutil.Ok(t, err)
+
+ // Load CA certificate
+ caCert, err := os.ReadFile(filepath.Join(certsDir, "ca.crt"))
+ testutil.Ok(t, err)
+
+ caCertPool := x509.NewCertPool()
+ caCertPool.AppendCertsFromPEM(caCert)
+
+ tlsConfig := &tls.Config{
+ Certificates: []tls.Certificate{cert},
+ RootCAs: caCertPool,
+ }
+
+ return &http.Client{
+ Transport: &http.Transport{
+ TLSClientConfig: tlsConfig,
+ },
+ Timeout: 10 * time.Second,
+ }
+}
+
+func makeRequest(client *http.Client, baseURL, path, tenant, method string, body io.Reader) (*http.Response, error) {
+ fullURL := baseURL + path
+ if method == "GET" && strings.Contains(path, "query") {
+ // Add query parameter for metrics endpoints
+ fullURL += "?query=up"
+ }
+
+ req, err := http.NewRequest(method, fullURL, body)
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Set("X-Tenant", tenant)
+ if method == "POST" {
+ req.Header.Set("Content-Type", "application/x-protobuf")
+ }
+
+ return client.Do(req)
+}
+
+// Helper function to create Observatorium API service with path-based RBAC
+func newPathBasedObservatoriumAPIService(e e2e.Environment, options ...observatoriumAPIOption) (e2e.Runnable, error) {
+ config := observatoriumAPIConfig{
+ image: "observatorium-api:latest",
+ listenPort: 8080,
+ internalListenPort: 8081,
+ logLevel: "debug",
+ }
+
+ for _, opt := range options {
+ opt(&config)
+ }
+
+ args := []string{
+ "--web.listen=0.0.0.0:" + fmt.Sprintf("%d", config.listenPort),
+ "--web.internal.listen=0.0.0.0:" + fmt.Sprintf("%d", config.internalListenPort),
+ "--log.level=" + config.logLevel,
+ "--tenants.config=" + filepath.Join("/shared", configSharedDir, "tenants.yaml"),
+ "--rbac.config=" + filepath.Join("/shared", configSharedDir, "rbac.yaml"),
+ }
+
+ if config.tlsServerCertFile != "" && config.tlsServerKeyFile != "" {
+ args = append(args,
+ "--tls.server.cert-file="+config.tlsServerCertFile,
+ "--tls.server.private-key-file="+config.tlsServerKeyFile,
+ )
+ }
+
+ if config.tlsServerCAFile != "" {
+ args = append(args, "--tls.ca-file="+config.tlsServerCAFile)
+ }
+
+ // Add OPA configuration for path-based authorization
+ args = append(args, "--opa.url=http://127.0.0.1:8181/v1/data/observatorium/allow")
+
+ // Add metrics endpoints
+ if config.metricsReadEndpoint != "" {
+ args = append(args, "--metrics.read.endpoint="+config.metricsReadEndpoint)
+ }
+ if config.metricsWriteEndpoint != "" {
+ args = append(args, "--metrics.write.endpoint="+config.metricsWriteEndpoint)
+ }
+
+ // Add logs endpoints
+ if config.logsReadEndpoint != "" {
+ args = append(args, "--logs.read.endpoint="+config.logsReadEndpoint)
+ }
+ if config.logsWriteEndpoint != "" {
+ args = append(args, "--logs.write.endpoint="+config.logsWriteEndpoint)
+ }
+
+ return e2e.NewRunnable("observatorium-api").WithPorts(
+ map[string]int{"https": config.listenPort, "http": config.internalListenPort},
+ ).Init(e2e.StartOptions{
+ Image: config.image,
+ Command: e2e.NewCommand("observatorium-api", args...),
+ Readiness: e2e.NewHTTPReadinessProbe("http", "/metrics", 200, 200),
+ User: "65534", // nobody
+ }), nil
+}
+
+// Helper functions for file operations
+func generateServerCert(certsDir, hostname string) error {
+ // Implementation would generate server certificate
+ // For now, this is a placeholder - would need to integrate with testtls package
+ return nil
+}
+
+func generateClientCert(certsDir, name, cn, ou string) error {
+ // Implementation would generate client certificate with specific CN and OU
+ // For now, this is a placeholder - would need to integrate with testtls package
+ return nil
+}
+
+func copyFile(src, dst string) error {
+ // Implementation would copy file/directory
+ // For now, this is a placeholder
+ return nil
+}
+
+func startServicesForLogs(t *testing.T, e e2e.Environment) string {
+ // Start a mock logs service similar to metrics
+ // For now, return a placeholder endpoint
+ return "loki:3100"
+}
\ No newline at end of file
diff --git a/test/kind/Makefile b/test/kind/Makefile
new file mode 100644
index 00000000..52a297bc
--- /dev/null
+++ b/test/kind/Makefile
@@ -0,0 +1,161 @@
+#!/usr/bin/make -f
+
+# Mixed Authentication KIND Test Environment
+# This Makefile provides a complete test environment for mixed authentication
+
+CLUSTER_NAME := observatorium-auth-test
+IMAGE_NAME := observatorium-api-auth-test:latest
+NAMESPACE := proxy
+
+# Environment setup
+.PHONY: setup
+setup: cluster-bootstrap deploy ## Complete environment setup
+
+.PHONY: cluster-bootstrap
+cluster-bootstrap: cluster-create deploy-infrastructure extract-certs-and-config ## Bootstrap cluster infrastructure
+
+.PHONY: deploy
+deploy: deploy-applications ## Deploy applications with extracted configs
+
+.PHONY: teardown
+teardown: cluster-delete clean-testdata ## Clean up entire environment
+
+# KIND cluster management
+.PHONY: cluster-create
+cluster-create: ## Create KIND cluster for mixed auth testing
+ @echo "Creating KIND cluster: $(CLUSTER_NAME)"
+ kind create cluster --name $(CLUSTER_NAME)
+ @echo "✓ KIND cluster created successfully"
+
+.PHONY: cluster-delete
+cluster-delete: ## Delete KIND cluster
+ @echo "Deleting KIND cluster: $(CLUSTER_NAME)"
+ kind delete cluster --name $(CLUSTER_NAME)
+ @echo "✓ KIND cluster deleted"
+
+.PHONY: cluster-info
+cluster-info: ## Show cluster information
+ @echo "KIND cluster information:"
+ kubectl cluster-info --context kind-$(CLUSTER_NAME)
+ @echo ""
+ @echo "Nodes:"
+ kubectl get nodes --context kind-$(CLUSTER_NAME)
+
+# Image management
+.PHONY: build-image
+build-image: ## Build observatorium API image
+ @if ! docker image inspect $(IMAGE_NAME) >/dev/null 2>&1; then \
+ echo "Building observatorium API image..."; \
+ cd ../.. && make container; \
+ docker tag quay.io/observatorium/api:latest $(IMAGE_NAME); \
+ echo "✓ Image built: $(IMAGE_NAME)"; \
+ else \
+ echo "✓ Image $(IMAGE_NAME) already exists, skipping build"; \
+ fi
+
+.PHONY: load-images
+load-images: build-image ## Load observatorium API image into KIND cluster
+ @echo "Loading observatorium API image into KIND cluster..."
+ kind load docker-image $(IMAGE_NAME) --name $(CLUSTER_NAME)
+ @echo "✓ Image loaded successfully"
+
+# Infrastructure deployment (bootstrap phase)
+.PHONY: deploy-infrastructure
+deploy-infrastructure: deploy-cert-manager deploy-certificates deploy-backends deploy-dex ## Deploy cluster infrastructure
+
+.PHONY: deploy-backends
+deploy-backends: ## Deploy backend services
+ @echo "Deploying backend services..."
+ kubectl apply --context kind-$(CLUSTER_NAME) -f resources/backends.yaml
+ @echo "Waiting for backends to be ready..."
+ kubectl wait --context kind-$(CLUSTER_NAME) --for=condition=Available=True deploy/api-proxy -n $(NAMESPACE) --timeout=300s
+ kubectl wait --context kind-$(CLUSTER_NAME) --for=condition=Available=True deploy/httpbin-metrics -n $(NAMESPACE) --timeout=300s
+ @echo "✓ Backend services deployed successfully"
+
+.PHONY: deploy-dex
+deploy-dex: ## Deploy Dex OIDC provider
+ @echo "Deploying Dex OIDC provider..."
+ kubectl apply --context kind-$(CLUSTER_NAME) -f resources/dex.yaml
+ @echo "Waiting for Dex to be ready..."
+ kubectl wait --context kind-$(CLUSTER_NAME) --for=condition=Available=True deploy/dex -n $(NAMESPACE) --timeout=300s
+ @echo "✓ Dex deployed successfully"
+
+.PHONY: deploy-cert-manager
+deploy-cert-manager: ## Deploy cert-manager
+ @echo "Deploying official cert-manager..."
+ kubectl apply --context kind-$(CLUSTER_NAME) -f https://github.com/cert-manager/cert-manager/releases/download/v1.14.0/cert-manager.yaml
+ @echo "Waiting for cert-manager to be ready..."
+ kubectl wait --context kind-$(CLUSTER_NAME) --for=condition=Available=True deploy/cert-manager -n cert-manager --timeout=300s
+ kubectl wait --context kind-$(CLUSTER_NAME) --for=condition=Available=True deploy/cert-manager-webhook -n cert-manager --timeout=300s
+ @echo "✓ cert-manager deployed successfully"
+
+.PHONY: deploy-certificates
+deploy-certificates: ## Deploy TLS certificates
+ @echo "Deploying TLS certificates..."
+ kubectl apply --context kind-$(CLUSTER_NAME) -f resources/certificates.yaml
+ @echo "Waiting for certificates to be ready..."
+ sleep 30 # Give time for certificate generation
+ @echo "✓ TLS certificates deployed"
+
+# Extract certificates and configuration from cluster
+.PHONY: extract-certs-and-config
+extract-certs-and-config: ## Extract certificates and config from cluster
+ @echo "Running config extraction script..."
+ ./extract-config.sh
+ @echo "✓ Configuration extraction complete"
+
+# Application deployment (deploy phase)
+.PHONY: deploy-applications
+deploy-applications: load-images deploy-services deploy-api ## Deploy applications using extracted configs
+
+.PHONY: deploy-services
+deploy-services: ## Deploy services and ingress routing
+ @echo "Deploying services and ingress routing..."
+ kubectl apply --context kind-$(CLUSTER_NAME) -f resources/services.yaml
+ @echo "✓ Services and ingress routing deployed"
+
+
+.PHONY: deploy-api
+deploy-api: ## Deploy Observatorium API with mixed authentication
+ @echo "Deploying Observatorium API with mixed authentication..."
+ kubectl apply --context kind-$(CLUSTER_NAME) -f testdata/observatorium-api.yaml
+ @echo "Waiting for API to be ready..."
+ kubectl wait --context kind-$(CLUSTER_NAME) --for=condition=Available=True deploy/observatorium-api -n $(NAMESPACE) --timeout=300s
+ @echo "✓ Observatorium API deployed successfully"
+
+
+# Testing
+.PHONY: test
+test: ## Run mixed authentication e2e tests
+ @echo "Running mixed authentication e2e tests..."
+ go run e2e.go
+ @echo "✓ Tests completed"
+
+.PHONY: test-comprehensive
+test-comprehensive: ## Run comprehensive KIND e2e tests
+ @echo "Running comprehensive KIND e2e tests..."
+ go test -v -run TestMixedAuthenticationE2E ./...
+ @echo "✓ Comprehensive tests completed"
+
+.PHONY: test-production
+test-production: ## Run production e2e tests against real deployment
+ @echo "Running production e2e tests..."
+ cd ../.. && go test -v ./test/e2e -run TestMixedAuthenticationProduction
+ @echo "✓ Production tests completed"
+
+# Clean up resources
+.PHONY: clean-testdata
+clean-testdata: ## Clean up extracted certificates and configs
+ @echo "Cleaning up testdata..."
+ rm -rf testdata/
+ @echo "✓ Testdata cleaned"
+
+.PHONY: clean
+clean: clean-testdata ## Clean up test artifacts
+ @echo "Cleaning up test artifacts..."
+ rm -f *.crt *.key *.pem
+ @echo "✓ Test artifacts cleaned"
+
+.PHONY: reset
+reset: teardown setup ## Reset entire environment
+ @echo "✓ Environment reset complete"
diff --git a/test/kind/README.md b/test/kind/README.md
new file mode 100644
index 00000000..80684b2a
--- /dev/null
+++ b/test/kind/README.md
@@ -0,0 +1,101 @@
+# KIND Mixed Authentication Tests
+
+This directory contains end-to-end tests for the Observatorium API's multi-authenticator feature using in KinD.
+Certificates are generated using cert-manager, and an OIDC provider (Dex) is deployed for authentication testing.
+The tests verify that the API correctly routes requests to the appropriate authentication method
+based on path patterns and enforces RBAC authorization.
+
+Endpoints are stubbed to backend services (nginx proxy and httpbin) to validate that authenticated requests are properly forwarded.
+
+## Overview
+
+The tests verify that:
+- Read endpoints (query, labels, etc.) use OIDC authentication
+- Write endpoints (receive, push) use mTLS authentication
+- Path-based authentication routing works correctly
+- RBAC authorization is enforced
+
+## Prerequisites
+
+- KinD
+- kubectl
+
+## Quick Start
+
+1. **Set up the test environment:**
+ ```bash
+ make setup
+ ```
+ This will:
+ - Create a KIND cluster named `observatorium-auth-test`
+ - Deploy cert-manager for TLS certificate generation
+ - Deploy TLS certificates
+ - Deploy backend services (nginx proxy, httpbin)
+ - Deploy Dex OIDC provider
+ - Extract certificates and generate configuration
+ - Deploy the Observatorium API with mixed authentication
+
+2. **Run the tests:**
+ ```bash
+ make test
+ ```
+
+3. **Clean up:**
+ ```bash
+ make teardown
+ ```
+
+
+
+## Architecture
+
+```mermaid
+graph TB
+ Client[Client Requests]
+
+ subgraph "KIND Cluster"
+ subgraph "Observatorium API"
+ API[obs-api
Multi-Authenticator]
+ end
+
+ subgraph "Authentication Providers"
+ Dex[Dex OIDC Provider
Identity Server]
+ CertManager[cert-manager
Certificate Authority]
+ end
+
+ subgraph "Backend Services"
+ Nginx[nginx proxy
Stubbed Backend]
+ HttpBin[httpbin
Test Service]
+ end
+
+ subgraph "TLS Infrastructure"
+ TLS[TLS Certificates
mTLS Authentication]
+ end
+ end
+
+ Client -->|Read Requests
query, labels| API
+ Client -->|Write Requests
receive, push| API
+
+ API -->|OIDC Auth
Read endpoints| Dex
+ API -->|mTLS Auth
Write endpoints| TLS
+
+ API -->|Authenticated
Requests| Nginx
+ Nginx -->|Proxied
Requests| HttpBin
+
+ CertManager -->|Generates| TLS
+
+ style API fill:#e1f5fe
+ style Dex fill:#f3e5f5
+ style TLS fill:#e8f5e8
+ style Nginx fill:#fff3e0
+ style HttpBin fill:#fff3e0
+```
+
+The architecture demonstrates:
+
+- **Path-based routing**: Different endpoints use different authentication methods
+- **OIDC Authentication**: Read endpoints (query, labels) authenticate via Dex OIDC provider
+- **mTLS Authentication**: Write endpoints (receive, push) use mutual TLS certificates
+- **Backend proxying**: Authenticated requests are forwarded to stubbed backend services
+- **Certificate management**: cert-manager handles TLS certificate lifecycle
+
diff --git a/test/kind/e2e.go b/test/kind/e2e.go
new file mode 100644
index 00000000..e6b99758
--- /dev/null
+++ b/test/kind/e2e.go
@@ -0,0 +1,706 @@
+package main
+
+import (
+ "context"
+ "crypto/tls"
+ "crypto/x509"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/util/intstr"
+ "k8s.io/client-go/kubernetes"
+ "k8s.io/client-go/rest"
+ "k8s.io/client-go/tools/clientcmd"
+ "k8s.io/client-go/tools/portforward"
+ "k8s.io/client-go/transport/spdy"
+)
+
+type PortForwarder struct {
+ stopChan chan struct{}
+ readyChan chan struct{}
+}
+
+func StartPortForward(ctx context.Context, port intstr.IntOrString, scheme, serviceName, ns string) (func(), error) {
+ // Build config using KIND cluster context
+ config, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
+ clientcmd.NewDefaultClientConfigLoadingRules(),
+ &clientcmd.ConfigOverrides{
+ CurrentContext: "kind-observatorium-auth-test",
+ }).ClientConfig()
+ if err != nil {
+ return nil, err
+ }
+
+ // Find pod for the service
+ podName, err := findPodForService(serviceName, ns, config)
+ if err != nil {
+ return nil, fmt.Errorf("failed to find pod for service %s: %w", serviceName, err)
+ }
+
+ roundTripper, upgrader, err := spdy.RoundTripperFor(config)
+ if err != nil {
+ return nil, err
+ }
+
+ // Use pod endpoint for port forwarding
+ path := fmt.Sprintf("/api/v1/namespaces/%s/pods/%s/portforward", ns, podName)
+ hostIP := strings.TrimLeft(config.Host, "https://")
+ serverURL := url.URL{Scheme: "https", Path: path, Host: hostIP}
+
+ dialer := spdy.NewDialer(upgrader, &http.Client{Transport: roundTripper}, http.MethodPost, &serverURL)
+
+ stopChan := make(chan struct{}, 1)
+ readyChan := make(chan struct{}, 1)
+
+ forwarder, err := portforward.New(dialer, []string{port.String()}, stopChan, readyChan, os.Stdout, os.Stderr)
+ if err != nil {
+ return nil, err
+ }
+
+ forwardErr := make(chan error, 1)
+ go func() {
+ if err := forwarder.ForwardPorts(); err != nil {
+ forwardErr <- err
+ }
+ close(forwardErr)
+ }()
+
+ select {
+ case <-readyChan:
+ return func() { close(stopChan) }, nil
+ case <-ctx.Done():
+ var err error
+ select {
+ case err = <-forwardErr:
+ default:
+ }
+ return nil, fmt.Errorf("%v: %v", ctx.Err(), err)
+ }
+}
+
+func (p *PortForwarder) Stop() {
+ close(p.stopChan)
+}
+
+func findPodForService(serviceName, namespace string, config *rest.Config) (string, error) {
+ clientset, err := kubernetes.NewForConfig(config)
+ if err != nil {
+ return "", err
+ }
+
+ pods, err := clientset.CoreV1().Pods(namespace).List(context.TODO(), metav1.ListOptions{
+ LabelSelector: fmt.Sprintf("app=%s", serviceName),
+ })
+ if err != nil {
+ return "", err
+ }
+
+ if len(pods.Items) == 0 {
+ return "", fmt.Errorf("no pods found for service %s", serviceName)
+ }
+
+ return pods.Items[0].Name, nil
+}
+
+// Test configuration
+const (
+ namespace = "proxy"
+ apiPort = 8080
+ dexPort = 5556
+)
+
+// Certificate and authentication setup
+func loadTLSConfig() (*tls.Config, error) {
+ caCert, err := os.ReadFile("testdata/ca.crt")
+ if err != nil {
+ return nil, fmt.Errorf("failed to read CA certificate: %w", err)
+ }
+
+ caCertPool := x509.NewCertPool()
+ caCertPool.AppendCertsFromPEM(caCert)
+
+ // Load client certificate for mTLS
+ clientCert, err := tls.LoadX509KeyPair("testdata/test-client.crt", "testdata/test-client.key")
+ if err != nil {
+ return nil, fmt.Errorf("failed to load client certificate: %w", err)
+ }
+
+ return &tls.Config{
+ RootCAs: caCertPool,
+ Certificates: []tls.Certificate{clientCert},
+ ServerName: "observatorium-api",
+ InsecureSkipVerify: true, // Skip hostname verification for localhost
+ }, nil
+}
+
+func loadAdminTLSConfig() (*tls.Config, error) {
+ caCert, err := os.ReadFile("testdata/ca.crt")
+ if err != nil {
+ return nil, fmt.Errorf("failed to read CA certificate: %w", err)
+ }
+
+ caCertPool := x509.NewCertPool()
+ caCertPool.AppendCertsFromPEM(caCert)
+
+ // Load admin certificate for mTLS
+ adminCert, err := tls.LoadX509KeyPair("testdata/admin-client.crt", "testdata/admin-client.key")
+ if err != nil {
+ return nil, fmt.Errorf("failed to load admin certificate: %w", err)
+ }
+
+ return &tls.Config{
+ RootCAs: caCertPool,
+ Certificates: []tls.Certificate{adminCert},
+ ServerName: "observatorium-api",
+ InsecureSkipVerify: true, // Skip hostname verification for localhost
+ }, nil
+}
+
+// OIDC authentication helper - implements OAuth2 password flow
+func performOIDCAuth() (string, error) {
+ // Use OAuth2 password grant flow to get real token from Dex
+ client := &http.Client{
+ Timeout: 30 * time.Second,
+ Transport: &http.Transport{
+ TLSClientConfig: &tls.Config{
+ InsecureSkipVerify: true,
+ },
+ },
+ }
+
+ // OAuth2 token endpoint - Dex serves HTTP
+ tokenURL := fmt.Sprintf("http://localhost:%d/dex/token", dexPort)
+
+ // Prepare form data for OAuth2 password grant - using demo working values
+ formData := url.Values{
+ "grant_type": {"password"},
+ "client_id": {"observatorium-api"},
+ "client_secret": {"ZXhhbXBsZS1hcHAtc2VjcmV0"},
+ "username": {"admin@example.com"},
+ "password": {"password"},
+ "scope": {"openid email"},
+ }
+
+ req, err := http.NewRequest("POST", tokenURL, strings.NewReader(formData.Encode()))
+ if err != nil {
+ return "", fmt.Errorf("failed to create token request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("failed to get token: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ return "", fmt.Errorf("token request failed with status %d: %s", resp.StatusCode, string(body))
+ }
+
+ // Parse token response
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", fmt.Errorf("failed to read token response: %w", err)
+ }
+
+ var tokenResp struct {
+ AccessToken string `json:"access_token"`
+ TokenType string `json:"token_type"`
+ ExpiresIn int `json:"expires_in"`
+ RefreshToken string `json:"refresh_token,omitempty"`
+ IDToken string `json:"id_token,omitempty"`
+ }
+
+ err = json.Unmarshal(body, &tokenResp)
+ if err != nil {
+ return "", fmt.Errorf("failed to parse token response: %w", err)
+ }
+
+ if tokenResp.AccessToken == "" {
+ return "", fmt.Errorf("no access token received")
+ }
+
+ return tokenResp.AccessToken, nil
+}
+
+func TestMixedAuthenticationE2E(t *testing.T) {
+ // Port forwards are set up in main function
+ time.Sleep(2 * time.Second)
+
+ // Run all test scenarios
+ t.Run("TestReadEndpointsWithOIDC", testReadEndpointsWithOIDC)
+ t.Run("TestWriteEndpointsWithMTLS", testWriteEndpointsWithMTLS)
+ t.Run("TestReadEndpointsRejectMTLS", testReadEndpointsRejectMTLS)
+ t.Run("TestWriteEndpointsRejectOIDC", testWriteEndpointsRejectOIDC)
+ t.Run("TestInvalidCertificateRejection", testInvalidCertificateRejection)
+ t.Run("TestPathPatternMatching", testPathPatternMatching)
+ t.Run("TestRBACEnforcement", testRBACEnforcement)
+ t.Run("TestBackendProxying", testBackendProxying)
+}
+
+func testReadEndpointsWithOIDC(t *testing.T) {
+ // Test that read endpoints (query, etc.) work with OIDC authentication
+ token, err := performOIDCAuth()
+ if err != nil {
+ t.Fatalf("Failed to get OIDC token: %v", err)
+ }
+
+ readEndpoints := []string{
+ "/api/metrics/v1/auth-tenant/api/v1/query?query=up",
+ "/api/metrics/v1/auth-tenant/api/v1/query_range?query=up&start=0&end=1&step=1",
+ "/api/metrics/v1/auth-tenant/api/v1/labels",
+ "/api/metrics/v1/auth-tenant/api/v1/series?match[]=up",
+ "/api/logs/v1/auth-tenant/loki/api/v1/query?query={job=\"test\"}",
+ }
+
+ // Configure client for HTTPS with proper TLS setup
+ tlsConfig, err := loadTLSConfig()
+ if err != nil {
+ t.Fatalf("Failed to load TLS config: %v", err)
+ }
+
+ // For OIDC endpoints, only trust CA but don't present client certificates
+ httpsClient := &http.Client{
+ Timeout: 30 * time.Second,
+ Transport: &http.Transport{
+ TLSClientConfig: &tls.Config{
+ RootCAs: tlsConfig.RootCAs,
+ InsecureSkipVerify: true, // Skip hostname verification for localhost
+ // No client certificates for OIDC endpoints
+ },
+ },
+ }
+
+ for _, endpoint := range readEndpoints {
+ t.Run(fmt.Sprintf("OIDC_%s", endpoint), func(t *testing.T) {
+ req, err := http.NewRequest("GET", fmt.Sprintf("https://localhost:%d%s", apiPort, endpoint), nil)
+ if err != nil {
+ t.Fatalf("Failed to create request: %v", err)
+ }
+
+ req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
+
+ resp, err := httpsClient.Do(req)
+ if err != nil {
+ t.Fatalf("Request failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ // Should successfully authenticate and reach the backend (2xx status)
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ body, _ := io.ReadAll(resp.Body)
+ t.Fatalf("OIDC authentication failed for %s: %d - %s", endpoint, resp.StatusCode, string(body))
+ }
+
+ t.Logf("OIDC endpoint %s successfully authenticated with status %d", endpoint, resp.StatusCode)
+ })
+ }
+}
+
+func testWriteEndpointsWithMTLS(t *testing.T) {
+ // Test that write endpoints (receive, push) work with mTLS authentication
+ tlsConfig, err := loadAdminTLSConfig()
+ if err != nil {
+ t.Fatalf("Failed to load TLS config: %v", err)
+ }
+
+ // For mTLS endpoints, present client certificates and trust CA
+ client := &http.Client{
+ Timeout: 30 * time.Second,
+ Transport: &http.Transport{
+ TLSClientConfig: tlsConfig,
+ },
+ }
+
+ writeEndpoints := []string{
+ "/api/metrics/v1/auth-tenant/api/v1/receive",
+ "/api/logs/v1/auth-tenant/loki/api/v1/push",
+ }
+
+ for _, endpoint := range writeEndpoints {
+ t.Run(fmt.Sprintf("mTLS_%s", endpoint), func(t *testing.T) {
+ req, err := http.NewRequest("POST", fmt.Sprintf("https://localhost:%d%s", apiPort, endpoint), strings.NewReader("test data"))
+ if err != nil {
+ t.Fatalf("Failed to create request: %v", err)
+ }
+
+ req.Header.Set("Content-Type", "application/x-protobuf")
+
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("Request failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ // Should successfully authenticate and reach the backend (2xx status)
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ body, _ := io.ReadAll(resp.Body)
+ t.Fatalf("mTLS authentication failed for %s: %d - %s", endpoint, resp.StatusCode, string(body))
+ }
+
+ t.Logf("mTLS endpoint %s successfully authenticated with status %d", endpoint, resp.StatusCode)
+ })
+ }
+}
+
+func testReadEndpointsRejectMTLS(t *testing.T) {
+ // Test that read endpoints reject mTLS-only requests
+ tlsConfig, err := loadTLSConfig()
+ if err != nil {
+ t.Fatalf("Failed to load TLS config: %v", err)
+ }
+
+ client := &http.Client{
+ Timeout: 30 * time.Second,
+ Transport: &http.Transport{
+ TLSClientConfig: tlsConfig,
+ },
+ // Don't follow redirects - we want to check the initial response
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ return http.ErrUseLastResponse
+ },
+ }
+
+ readEndpoints := []string{
+ "/api/metrics/v1/auth-tenant/api/v1/query?query=up",
+ "/api/logs/v1/auth-tenant/loki/api/v1/query?query={job=\"test\"}",
+ }
+
+ for _, endpoint := range readEndpoints {
+ t.Run(fmt.Sprintf("RejectMTLS_%s", endpoint), func(t *testing.T) {
+ req, err := http.NewRequest("GET", fmt.Sprintf("https://localhost:%d%s", apiPort, endpoint), nil)
+ if err != nil {
+ t.Fatalf("Failed to create request: %v", err)
+ }
+
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("Request failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ // Should be rejected (3xx redirect or 4xx error) since read endpoints require OIDC, not mTLS
+ if resp.StatusCode >= 200 && resp.StatusCode < 300 {
+ t.Fatalf("Expected rejection (redirect or error), got success %d", resp.StatusCode)
+ }
+
+ t.Logf("Read endpoint correctly rejected mTLS request with status %d", resp.StatusCode)
+ })
+ }
+}
+
+func testWriteEndpointsRejectOIDC(t *testing.T) {
+ // Test that write endpoints reject OIDC-only requests
+ token, err := performOIDCAuth()
+ if err != nil {
+ t.Fatalf("Failed to get OIDC token: %v", err)
+ }
+
+ writeEndpoints := []string{
+ "/api/metrics/v1/auth-tenant/api/v1/receive",
+ "/api/logs/v1/auth-tenant/loki/api/v1/push",
+ }
+
+ for _, endpoint := range writeEndpoints {
+ t.Run(fmt.Sprintf("RejectOIDC_%s", endpoint), func(t *testing.T) {
+ req, err := http.NewRequest("POST", fmt.Sprintf("https://localhost:%d%s", apiPort, endpoint), strings.NewReader("test data"))
+ if err != nil {
+ t.Fatalf("Failed to create request: %v", err)
+ }
+
+ req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
+ req.Header.Set("Content-Type", "application/x-protobuf")
+
+ // Create HTTPS client that skips cert verification for localhost
+ httpsClient := &http.Client{
+ Timeout: 30 * time.Second,
+ Transport: &http.Transport{
+ TLSClientConfig: &tls.Config{
+ InsecureSkipVerify: true,
+ },
+ },
+ }
+
+ resp, err := httpsClient.Do(req)
+ if err != nil {
+ t.Fatalf("Request failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ // Should be rejected since write endpoints require mTLS, not OIDC
+ if resp.StatusCode < 400 {
+ t.Fatalf("Expected authentication error, got %d", resp.StatusCode)
+ }
+
+ t.Logf("Write endpoint correctly rejected OIDC request with status %d", resp.StatusCode)
+ })
+ }
+}
+
+func testInvalidCertificateRejection(t *testing.T) {
+ // Test that invalid certificates are rejected
+ client := &http.Client{
+ Timeout: 30 * time.Second,
+ Transport: &http.Transport{
+ TLSClientConfig: &tls.Config{
+ InsecureSkipVerify: true, // Skip verification to test our authentication
+ },
+ },
+ }
+
+ req, err := http.NewRequest("POST", fmt.Sprintf("https://localhost:%d/api/metrics/v1/auth-tenant/api/v1/receive", apiPort), strings.NewReader("test data"))
+ if err != nil {
+ t.Fatalf("Failed to create request: %v", err)
+ }
+
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("Request failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ // Should be rejected due to missing client certificate
+ if resp.StatusCode < 400 {
+ t.Fatalf("Expected authentication error for missing certificate, got %d", resp.StatusCode)
+ }
+
+ t.Logf("Request correctly rejected for missing certificate with status %d", resp.StatusCode)
+}
+
+func testPathPatternMatching(t *testing.T) {
+ // Test that path patterns work correctly for edge cases
+ token, err := performOIDCAuth()
+ if err != nil {
+ t.Fatalf("Failed to get OIDC token: %v", err)
+ }
+
+ // Test various path patterns to ensure regex matching works
+ testCases := []struct {
+ path string
+ expectOIDC bool
+ description string
+ }{
+ {"/api/metrics/v1/auth-tenant/api/v1/query", true, "query endpoint should use OIDC"},
+ {"/api/metrics/v1/auth-tenant/api/v1/receive", false, "receive endpoint should use mTLS"},
+ {"/api/metrics/v1/auth-tenant/api/v1/query_range", true, "query_range should use OIDC"},
+ {"/api/metrics/v1/auth-tenant/api/v1/labels", true, "labels should use OIDC"},
+ {"/api/logs/v1/auth-tenant/loki/api/v1/query", true, "log query should use OIDC"},
+ {"/api/logs/v1/auth-tenant/loki/api/v1/push", false, "log push should use mTLS"},
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.description, func(t *testing.T) {
+ if tc.expectOIDC {
+ // Test with OIDC token
+ req, err := http.NewRequest("GET", fmt.Sprintf("https://localhost:%d%s", apiPort, tc.path), nil)
+ if err != nil {
+ t.Fatalf("Failed to create request: %v", err)
+ }
+
+ req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
+
+ // Create HTTPS client for OIDC requests
+ httpsClient := &http.Client{
+ Timeout: 30 * time.Second,
+ Transport: &http.Transport{
+ TLSClientConfig: &tls.Config{
+ InsecureSkipVerify: true,
+ },
+ },
+ }
+
+ resp, err := httpsClient.Do(req)
+ if err != nil {
+ t.Fatalf("Request failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ t.Fatalf("OIDC authentication failed for path %s: %d", tc.path, resp.StatusCode)
+ }
+
+ t.Logf("OIDC path %s successfully authenticated: %d", tc.path, resp.StatusCode)
+ } else {
+ // Test that OIDC is rejected for mTLS paths
+ req, err := http.NewRequest("POST", fmt.Sprintf("https://localhost:%d%s", apiPort, tc.path), strings.NewReader("test"))
+ if err != nil {
+ t.Fatalf("Failed to create request: %v", err)
+ }
+
+ req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
+
+ // Create HTTPS client for mTLS test requests
+ httpsClient := &http.Client{
+ Timeout: 30 * time.Second,
+ Transport: &http.Transport{
+ TLSClientConfig: &tls.Config{
+ InsecureSkipVerify: true,
+ },
+ },
+ }
+
+ resp, err := httpsClient.Do(req)
+ if err != nil {
+ t.Fatalf("Request failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode < 400 {
+ t.Fatalf("Expected auth error for mTLS path with OIDC: %d", resp.StatusCode)
+ }
+
+ t.Logf("mTLS path %s correctly rejected OIDC: %d", tc.path, resp.StatusCode)
+ }
+ })
+ }
+}
+
+func testRBACEnforcement(t *testing.T) {
+ // Test that RBAC rules are enforced correctly
+ // This tests the authorization layer after authentication
+
+ token, err := performOIDCAuth()
+ if err != nil {
+ t.Fatalf("Failed to get OIDC token: %v", err)
+ }
+
+ // Configure client for HTTPS with proper TLS setup
+ tlsConfig, err := loadTLSConfig()
+ if err != nil {
+ t.Fatalf("Failed to load TLS config: %v", err)
+ }
+
+ client := &http.Client{
+ Timeout: 30 * time.Second,
+ Transport: &http.Transport{
+ TLSClientConfig: &tls.Config{
+ RootCAs: tlsConfig.RootCAs,
+ InsecureSkipVerify: true, // Skip hostname verification since we're using localhost
+ },
+ },
+ }
+
+ // Test with different user credentials to verify RBAC
+ req, err := http.NewRequest("GET", fmt.Sprintf("https://localhost:%d/api/metrics/v1/auth-tenant/api/v1/query?query=up", apiPort), nil)
+ if err != nil {
+ t.Fatalf("Failed to create request: %v", err)
+ }
+
+ req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
+
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("Request failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ // RBAC should allow this request based on our test configuration (2xx status)
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ t.Fatalf("RBAC/authentication failed: %d", resp.StatusCode)
+ }
+
+ t.Logf("RBAC enforcement test passed with status %d", resp.StatusCode)
+}
+
+func testBackendProxying(t *testing.T) {
+ // Test that requests are properly proxied to HTTPBin backend
+ token, err := performOIDCAuth()
+ if err != nil {
+ t.Fatalf("Failed to get OIDC token: %v", err)
+ }
+
+ // Configure client for HTTPS with proper TLS setup
+ tlsConfig, err := loadTLSConfig()
+ if err != nil {
+ t.Fatalf("Failed to load TLS config: %v", err)
+ }
+
+ client := &http.Client{
+ Timeout: 30 * time.Second,
+ Transport: &http.Transport{
+ TLSClientConfig: &tls.Config{
+ RootCAs: tlsConfig.RootCAs,
+ InsecureSkipVerify: true, // Skip hostname verification since we're using localhost
+ },
+ },
+ }
+
+ req, err := http.NewRequest("GET", fmt.Sprintf("https://localhost:%d/api/metrics/v1/auth-tenant/api/v1/query?query=test", apiPort), nil)
+ if err != nil {
+ t.Fatalf("Failed to create request: %v", err)
+ }
+
+ req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
+ req.Header.Set("X-Test-Header", "test-value")
+
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("Request failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ // Should successfully authenticate and reach backend (2xx status)
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ t.Fatalf("Backend proxying failed - authentication or proxy error: %d", resp.StatusCode)
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatalf("Failed to read response body: %v", err)
+ }
+
+ // HTTPBin's /anything endpoint returns request details as JSON
+ responseBody := string(body)
+
+ // Log the response for debugging
+ t.Logf("Backend response body: %s", responseBody)
+
+ // Verify we got a response (the backend proxying is working)
+ if len(responseBody) == 0 {
+ t.Fatalf("Backend proxy failed - empty response")
+ }
+
+ t.Logf("Backend proxying working correctly - received response: %d bytes", len(responseBody))
+}
+
+func main() {
+ ctx := context.Background()
+
+ // Set up port forwards to services
+ fmt.Println("Starting port forward for observatorium-api service...")
+ stopAPIForward, err := StartPortForward(ctx, intstr.FromInt(apiPort), "https", "observatorium-api", namespace)
+ if err != nil {
+ fmt.Printf("Failed to start API port forward: %v\n", err)
+ return
+ }
+ defer stopAPIForward()
+
+ fmt.Println("Starting port forward for dex service...")
+ stopDexForward, err := StartPortForward(ctx, intstr.FromInt(dexPort), "http", "dex", namespace)
+ if err != nil {
+ fmt.Printf("Failed to start Dex port forward: %v\n", err)
+ return
+ }
+ defer stopDexForward()
+
+ // Give port forwards time to establish
+ fmt.Println("Port forwards established, running tests...")
+ time.Sleep(3 * time.Second)
+
+ testing.Main(func(pat, str string) (bool, error) { return true, nil },
+ []testing.InternalTest{
+ {"TestMixedAuthenticationE2E", TestMixedAuthenticationE2E},
+ },
+ []testing.InternalBenchmark{},
+ []testing.InternalExample{})
+}
diff --git a/test/kind/extract-config.sh b/test/kind/extract-config.sh
new file mode 100755
index 00000000..effce94d
--- /dev/null
+++ b/test/kind/extract-config.sh
@@ -0,0 +1,216 @@
+#!/bin/bash
+set -e
+
+CLUSTER_NAME="observatorium-auth-test"
+NAMESPACE="proxy"
+TESTDATA_DIR="testdata"
+
+echo "Extracting configuration from cluster..."
+
+# Create testdata directory
+mkdir -p "$TESTDATA_DIR"
+
+# Extract certificates
+echo "✓ Extracting certificates..."
+kubectl get secret ca-cert -n $NAMESPACE --context kind-$CLUSTER_NAME -o jsonpath='{.data.tls\.crt}' | base64 -d > "$TESTDATA_DIR/ca.crt"
+kubectl get secret client-cert -n $NAMESPACE --context kind-$CLUSTER_NAME -o jsonpath='{.data.tls\.crt}' | base64 -d > "$TESTDATA_DIR/test-client.crt"
+kubectl get secret client-cert -n $NAMESPACE --context kind-$CLUSTER_NAME -o jsonpath='{.data.tls\.key}' | base64 -d > "$TESTDATA_DIR/test-client.key"
+kubectl get secret admin-cert -n $NAMESPACE --context kind-$CLUSTER_NAME -o jsonpath='{.data.tls\.crt}' | base64 -d > "$TESTDATA_DIR/admin-client.crt"
+kubectl get secret admin-cert -n $NAMESPACE --context kind-$CLUSTER_NAME -o jsonpath='{.data.tls\.key}' | base64 -d > "$TESTDATA_DIR/admin-client.key"
+
+# Generate tenant configuration
+echo "✓ Generating tenant configuration..."
+cat > "$TESTDATA_DIR/tenants.yaml" << 'EOF'
+tenants:
+- name: auth-tenant
+ id: "1610702597"
+ oidc:
+ clientID: observatorium-api
+ clientSecret: ZXhhbXBsZS1hcHAtc2VjcmV0
+ issuerURL: http://dex.proxy.svc.cluster.local:5556/dex
+ redirectURL: http://localhost:8080/oidc/auth-tenant/callback
+ usernameClaim: email
+ paths:
+ - operator: "!~"
+ pattern: ".*(loki/api/v1/push|api/v1/receive).*"
+ mTLS:
+ caPath: /etc/certs/ca.crt
+ paths:
+ - operator: "=~"
+ pattern: ".*(api/v1/receive).*"
+ - operator: "=~"
+ pattern: ".*(loki/api/v1/push).*"
+EOF
+
+# Generate RBAC configuration
+echo "✓ Generating RBAC configuration..."
+cat > "$TESTDATA_DIR/rbac.yaml" << 'EOF'
+roles:
+- name: read-write
+ resources:
+ - metrics
+ - logs
+ tenants:
+ - auth-tenant
+ permissions:
+ - read
+ - write
+- name: read
+ resources:
+ - metrics
+ - logs
+ tenants:
+ - auth-tenant
+ permissions:
+ - read
+- name: write
+ resources:
+ - metrics
+ - logs
+ tenants:
+ - auth-tenant
+ permissions:
+ - write
+roleBindings:
+- name: admin-user
+ roles:
+ - read-write
+ subjects:
+ - kind: user
+ name: admin@example.com
+- name: test-user
+ roles:
+ - read
+ subjects:
+ - kind: user
+ name: test@example.com
+- name: admin-client
+ roles:
+ - write
+ subjects:
+ - kind: user
+ name: admin-client
+EOF
+
+
+# Generate Observatorium API configuration
+echo "✓ Generating Observatorium API deployment..."
+CA_CERT_B64=$(cat "$TESTDATA_DIR/ca.crt" | base64 | tr -d '\n')
+
+cat > "$TESTDATA_DIR/observatorium-api.yaml" << EOF
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: tenant-config
+ namespace: proxy
+data:
+ tenants.yaml: |
+$(sed 's/^/ /' "$TESTDATA_DIR/tenants.yaml")
+---
+# CA Certificate ConfigMap
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: ca-cert-config
+ namespace: proxy
+data:
+ ca.crt: |
+$(sed 's/^/ /' "$TESTDATA_DIR/ca.crt")
+---
+# RBAC Configuration
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: rbac-config
+ namespace: proxy
+data:
+ rbac.yaml: |
+$(sed 's/^/ /' "$TESTDATA_DIR/rbac.yaml")
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: observatorium-api
+ namespace: proxy
+ labels:
+ app: observatorium-api
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: observatorium-api
+ template:
+ metadata:
+ labels:
+ app: observatorium-api
+ spec:
+ containers:
+ - name: observatorium-api
+ image: observatorium-api-auth-test:latest
+ imagePullPolicy: Never
+ ports:
+ - name: http
+ containerPort: 8080
+ - name: internal
+ containerPort: 8081
+ args:
+ - --web.listen=0.0.0.0:8080
+ - --web.internal.listen=0.0.0.0:8081
+ - --tls.server.cert-file=/etc/server-certs/tls.crt
+ - --tls.server.key-file=/etc/server-certs/tls.key
+ - --tls.client-auth-type=RequestClientCert
+ - --web.healthchecks.url=http://localhost:8081
+ - --tenants.config=/etc/config/tenants.yaml
+ - --rbac.config=/etc/config/rbac.yaml
+ - --metrics.read.endpoint=http://api-proxy.proxy.svc.cluster.local
+ - --metrics.write.endpoint=http://api-proxy.proxy.svc.cluster.local
+ - --logs.read.endpoint=http://api-proxy.proxy.svc.cluster.local
+ - --logs.write.endpoint=http://api-proxy.proxy.svc.cluster.local
+ - --log.level=debug
+ volumeMounts:
+ - name: server-certs
+ mountPath: /etc/server-certs
+ readOnly: true
+ - name: ca-cert
+ mountPath: /etc/certs
+ readOnly: true
+ - name: tenant-config
+ mountPath: /etc/config/tenants.yaml
+ readOnly: true
+ subPath: tenants.yaml
+ - name: rbac-config
+ mountPath: /etc/config/rbac.yaml
+ readOnly: true
+ subPath: rbac.yaml
+ env:
+ - name: KUBERNETES_NAMESPACE
+ valueFrom:
+ fieldRef:
+ fieldPath: metadata.namespace
+ volumes:
+ - name: server-certs
+ secret:
+ secretName: server-cert
+ - name: ca-cert
+ configMap:
+ name: ca-cert-config
+ - name: tenant-config
+ configMap:
+ name: tenant-config
+ - name: rbac-config
+ configMap:
+ name: rbac-config
+EOF
+
+echo "✓ Configuration extracted to $TESTDATA_DIR/"
+echo ""
+echo "Files created:"
+echo " - $TESTDATA_DIR/ca.crt (CA certificate)"
+echo " - $TESTDATA_DIR/test-client.{crt,key} (Test client mTLS)"
+echo " - $TESTDATA_DIR/admin-client.{crt,key} (Admin client mTLS)"
+echo " - $TESTDATA_DIR/tenants.yaml (Tenant configuration)"
+echo " - $TESTDATA_DIR/rbac.yaml (RBAC configuration)"
+echo " - $TESTDATA_DIR/dex.yaml (Dex deployment)"
+echo " - $TESTDATA_DIR/observatorium-api.yaml (API deployment)"
+echo ""
+echo "Ready to deploy with: make deploy"
\ No newline at end of file
diff --git a/test/kind/go.mod b/test/kind/go.mod
new file mode 100644
index 00000000..c410b075
--- /dev/null
+++ b/test/kind/go.mod
@@ -0,0 +1,53 @@
+module github.com/observatorium/api/test/kind
+
+go 1.24.0
+
+require (
+ k8s.io/apimachinery v0.34.4
+ k8s.io/client-go v0.34.4
+)
+
+require (
+ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+ github.com/emicklei/go-restful/v3 v3.12.2 // indirect
+ github.com/fxamacker/cbor/v2 v2.9.0 // indirect
+ github.com/go-logr/logr v1.4.3 // indirect
+ github.com/go-openapi/jsonpointer v0.21.0 // indirect
+ github.com/go-openapi/jsonreference v0.20.2 // indirect
+ github.com/go-openapi/swag v0.23.0 // indirect
+ github.com/gogo/protobuf v1.3.2 // indirect
+ github.com/google/gnostic-models v0.7.0 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
+ github.com/josharian/intern v1.0.0 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/mailru/easyjson v0.7.7 // indirect
+ github.com/moby/spdystream v0.5.0 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
+ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+ github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
+ github.com/pkg/errors v0.9.1 // indirect
+ github.com/spf13/pflag v1.0.6 // indirect
+ github.com/x448/float16 v0.8.4 // indirect
+ go.yaml.in/yaml/v2 v2.4.2 // indirect
+ go.yaml.in/yaml/v3 v3.0.4 // indirect
+ golang.org/x/net v0.49.0 // indirect
+ golang.org/x/oauth2 v0.35.0 // indirect
+ golang.org/x/sys v0.40.0 // indirect
+ golang.org/x/term v0.39.0 // indirect
+ golang.org/x/text v0.33.0 // indirect
+ golang.org/x/time v0.14.0 // indirect
+ google.golang.org/protobuf v1.36.11 // indirect
+ gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
+ gopkg.in/inf.v0 v0.9.1 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+ k8s.io/api v0.34.4 // indirect
+ k8s.io/klog/v2 v2.130.1 // indirect
+ k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect
+ k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect
+ sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect
+ sigs.k8s.io/randfill v1.0.0 // indirect
+ sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
+ sigs.k8s.io/yaml v1.6.0 // indirect
+)
diff --git a/test/kind/go.sum b/test/kind/go.sum
new file mode 100644
index 00000000..d17b236f
--- /dev/null
+++ b/test/kind/go.sum
@@ -0,0 +1,165 @@
+github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
+github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
+github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
+github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
+github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
+github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
+github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
+github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
+github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
+github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
+github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
+github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
+github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
+github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
+github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
+github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
+github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
+github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=
+github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
+github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
+github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
+github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
+github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
+github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
+github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
+github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU=
+github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
+github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus=
+github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
+github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
+github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
+github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
+github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
+github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
+github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
+github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
+github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
+github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
+go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
+golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
+golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
+golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
+golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
+golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
+golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
+golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
+golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
+golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=
+gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
+gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
+gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+k8s.io/api v0.34.4 h1:Z5hsoQcZ2yBjelb9j5JKzCVo9qv9XLkVm5llnqS4h+0=
+k8s.io/api v0.34.4/go.mod h1:6SaGYuGPkMqqCgg8rPG/OQoCrhgSEV+wWn9v21fDP3o=
+k8s.io/apimachinery v0.34.4 h1:C5SiSzLEMyWIk53sSbnk0WlOOyqv/MFnWvuc/d6M+xc=
+k8s.io/apimachinery v0.34.4/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
+k8s.io/client-go v0.34.4 h1:IXhvzFdm0e897kXtLbeyMpAGzontcShJ/gi/XCCsOLc=
+k8s.io/client-go v0.34.4/go.mod h1:tXIVJTQabT5QRGlFdxZQFxrIhcGUPpKL5DAc4gSWTE8=
+k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
+k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
+k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA=
+k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts=
+k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y=
+k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
+sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE=
+sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
+sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
+sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
+sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=
+sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
+sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
+sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
diff --git a/test/kind/resources/backends.yaml b/test/kind/resources/backends.yaml
new file mode 100644
index 00000000..5e84335e
--- /dev/null
+++ b/test/kind/resources/backends.yaml
@@ -0,0 +1,118 @@
+# Backend services for testing
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: proxy
+---
+# Nginx Proxy Configuration
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: nginx-proxy-config
+ namespace: proxy
+data:
+ nginx.conf: |
+ events {}
+ http {
+ # Use Kubernetes DNS resolver
+ resolver kube-dns.kube-system.svc.cluster.local valid=5s;
+
+ server {
+ listen 80;
+
+ # Proxy all requests to httpbin /anything for testing
+ location / {
+ proxy_pass http://httpbin-metrics.proxy.svc.cluster.local:9090/anything$request_uri;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ }
+ }
+ }
+---
+# Nginx API Proxy Deployment
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: api-proxy
+ namespace: proxy
+ labels:
+ app: api-proxy
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: api-proxy
+ template:
+ metadata:
+ labels:
+ app: api-proxy
+ spec:
+ containers:
+ - name: nginx
+ image: nginx:alpine
+ ports:
+ - containerPort: 80
+ volumeMounts:
+ - name: config
+ mountPath: /etc/nginx/nginx.conf
+ subPath: nginx.conf
+ volumes:
+ - name: config
+ configMap:
+ name: nginx-proxy-config
+---
+# API Proxy Service
+apiVersion: v1
+kind: Service
+metadata:
+ name: api-proxy
+ namespace: proxy
+ labels:
+ app: api-proxy
+spec:
+ type: ClusterIP
+ ports:
+ - port: 80
+ targetPort: 80
+ selector:
+ app: api-proxy
+---
+# HTTPBin Backend for Testing
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: httpbin-metrics
+ namespace: proxy
+ labels:
+ app: httpbin-metrics
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: httpbin-metrics
+ template:
+ metadata:
+ labels:
+ app: httpbin-metrics
+ spec:
+ containers:
+ - name: httpbin
+ image: kennethreitz/httpbin:latest
+ ports:
+ - containerPort: 80
+---
+# HTTPBin Service
+apiVersion: v1
+kind: Service
+metadata:
+ name: httpbin-metrics
+ namespace: proxy
+ labels:
+ app: httpbin-metrics
+spec:
+ type: ClusterIP
+ ports:
+ - port: 9090
+ targetPort: 80
+ selector:
+ app: httpbin-metrics
diff --git a/test/kind/resources/certificates.yaml b/test/kind/resources/certificates.yaml
new file mode 100644
index 00000000..d681d7a6
--- /dev/null
+++ b/test/kind/resources/certificates.yaml
@@ -0,0 +1,116 @@
+# TLS Certificates for mixed authentication
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: proxy
+---
+# Self-signed ClusterIssuer for development
+apiVersion: cert-manager.io/v1
+kind: ClusterIssuer
+metadata:
+ name: selfsigned-issuer
+spec:
+ selfSigned: {}
+---
+# CA Certificate for mTLS
+apiVersion: cert-manager.io/v1
+kind: Certificate
+metadata:
+ name: ca-cert
+ namespace: proxy
+spec:
+ secretName: ca-cert
+ isCA: true
+ commonName: "Observatorium CA"
+ dnsNames:
+ - "observatorium-ca"
+ duration: 8760h # 1 year
+ renewBefore: 720h # 30 days
+ issuerRef:
+ name: selfsigned-issuer
+ kind: ClusterIssuer
+ subject:
+ organizationalUnits:
+ - "Observatorium"
+ - "Development"
+---
+# CA Issuer using the CA certificate
+apiVersion: cert-manager.io/v1
+kind: Issuer
+metadata:
+ name: ca-issuer
+ namespace: proxy
+spec:
+ ca:
+ secretName: ca-cert
+---
+# Client certificate for mTLS authentication (test client)
+apiVersion: cert-manager.io/v1
+kind: Certificate
+metadata:
+ name: client-cert
+ namespace: proxy
+spec:
+ secretName: client-cert
+ commonName: "test-client"
+ dnsNames:
+ - "test-client"
+ duration: 2160h # 90 days
+ renewBefore: 360h # 15 days
+ issuerRef:
+ name: ca-issuer
+ kind: Issuer
+ subject:
+ organizationalUnits:
+ - "Observatorium"
+ - "Test Client"
+ usages:
+ - client auth
+---
+# Admin client certificate for mTLS authentication
+apiVersion: cert-manager.io/v1
+kind: Certificate
+metadata:
+ name: admin-cert
+ namespace: proxy
+spec:
+ secretName: admin-cert
+ commonName: "admin-client"
+ dnsNames:
+ - "admin-client"
+ duration: 2160h # 90 days
+ renewBefore: 360h # 15 days
+ issuerRef:
+ name: ca-issuer
+ kind: Issuer
+ subject:
+ organizationalUnits:
+ - "Observatorium"
+ - "Admin Client"
+ usages:
+ - client auth
+---
+# Server certificate for HTTPS
+apiVersion: cert-manager.io/v1
+kind: Certificate
+metadata:
+ name: server-cert
+ namespace: proxy
+spec:
+ secretName: server-cert
+ commonName: "observatorium-api"
+ dnsNames:
+ - "observatorium-api"
+ - "observatorium-api.proxy.svc.cluster.local"
+ - "localhost"
+ duration: 2160h # 90 days
+ renewBefore: 360h # 15 days
+ issuerRef:
+ name: ca-issuer
+ kind: Issuer
+ subject:
+ organizationalUnits:
+ - "Observatorium"
+ - "API Server"
+ usages:
+ - server auth
\ No newline at end of file
diff --git a/test/kind/resources/dex.yaml b/test/kind/resources/dex.yaml
new file mode 100644
index 00000000..e98bc4f6
--- /dev/null
+++ b/test/kind/resources/dex.yaml
@@ -0,0 +1,88 @@
+# Dex OIDC Provider
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: proxy
+---
+# Dex Configuration
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: dex-config
+ namespace: proxy
+data:
+ config.yaml: |
+ issuer: http://dex.proxy.svc.cluster.local:5556/dex
+ storage:
+ type: sqlite3
+ config:
+ file: /tmp/dex.db
+ web:
+ http: 0.0.0.0:5556
+ oauth2:
+ passwordConnector: local
+ staticClients:
+ - id: test
+ redirectURIs:
+ - 'http://localhost:8080/oidc/auth-tenant/callback'
+ name: 'test'
+ secret: ZXhhbXBsZS1hcHAtc2VjcmV0
+ - id: observatorium-api
+ redirectURIs:
+ - 'http://localhost:8080/oidc/auth-tenant/callback'
+ name: 'observatorium-api'
+ secret: ZXhhbXBsZS1hcHAtc2VjcmV0
+ enablePasswordDB: true
+ staticPasswords:
+ - email: "admin@example.com"
+ hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
+ username: "admin"
+ userID: "08a8684b-db88-4b73-90a9-3cd1661f5466"
+ - email: "test@example.com"
+ hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
+ username: "test"
+ userID: "41331323-6f44-45e6-b3b9-0c5c68e5fc78"
+---
+# Dex Deployment
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: dex
+ namespace: proxy
+ labels:
+ app: dex
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: dex
+ template:
+ metadata:
+ labels:
+ app: dex
+ spec:
+ containers:
+ - name: dex
+ image: dexidp/dex:v2.37.0
+ command:
+ - dex
+ - serve
+ - /etc/config/config.yaml
+ ports:
+ - name: http
+ containerPort: 5556
+ - name: grpc
+ containerPort: 5557
+ volumeMounts:
+ - name: config
+ mountPath: /etc/config
+ readOnly: true
+ env:
+ - name: KUBERNETES_POD_NAMESPACE
+ valueFrom:
+ fieldRef:
+ fieldPath: metadata.namespace
+ volumes:
+ - name: config
+ configMap:
+ name: dex-config
\ No newline at end of file
diff --git a/test/kind/resources/services.yaml b/test/kind/resources/services.yaml
new file mode 100644
index 00000000..c5a2cb0e
--- /dev/null
+++ b/test/kind/resources/services.yaml
@@ -0,0 +1,49 @@
+# Services for port forwarding
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: proxy
+---
+# Observatorium API Service
+apiVersion: v1
+kind: Service
+metadata:
+ name: observatorium-api
+ namespace: proxy
+ labels:
+ app: observatorium-api
+spec:
+ type: ClusterIP
+ ports:
+ - name: http
+ port: 8080
+ targetPort: 8080
+ protocol: TCP
+ - name: internal
+ port: 8081
+ targetPort: 8081
+ protocol: TCP
+ selector:
+ app: observatorium-api
+---
+# Dex OIDC Provider Service
+apiVersion: v1
+kind: Service
+metadata:
+ name: dex
+ namespace: proxy
+ labels:
+ app: dex
+spec:
+ type: ClusterIP
+ ports:
+ - name: http
+ port: 5556
+ targetPort: 5556
+ protocol: TCP
+ - name: grpc
+ port: 5557
+ targetPort: 5557
+ protocol: TCP
+ selector:
+ app: dex
diff --git a/test/kind/testdata/admin-client.crt b/test/kind/testdata/admin-client.crt
new file mode 100644
index 00000000..c245a829
--- /dev/null
+++ b/test/kind/testdata/admin-client.crt
@@ -0,0 +1,21 @@
+-----BEGIN CERTIFICATE-----
+MIIDdDCCAlygAwIBAgIQZwWAT+xXMnHkHRiyDqr1TTANBgkqhkiG9w0BAQsFADBH
+MSowEgYDVQQLEwtEZXZlbG9wbWVudDAUBgNVBAsTDU9ic2VydmF0b3JpdW0xGTAX
+BgNVBAMTEE9ic2VydmF0b3JpdW0gQ0EwHhcNMjYwNDA5MTYxNzIwWhcNMjYwNzA4
+MTYxNzIwWjBEMSswEwYDVQQLEwxBZG1pbiBDbGllbnQwFAYDVQQLEw1PYnNlcnZh
+dG9yaXVtMRUwEwYDVQQDEwxhZG1pbi1jbGllbnQwggEiMA0GCSqGSIb3DQEBAQUA
+A4IBDwAwggEKAoIBAQCxu5gX+vfEsnVKodrZNYBrIYjCJ+rK9+A4DOO8t4P4ZLjp
+oTubyRZsTNSk/UUNvlGmAhkZRdN8TqZWKoYb/F3/lljD4fGk6IhajFs9qZvUBEFZ
+F/+u8jk73pRSl2/nAgsiiRXh4kwzGniFwpO1mfQkBJZXtE0NLTMCWFG0ImHGtNQp
+/K/fPUdo69irOsqtmfYFCK0DZQ/zdtFeRAhbw/nv82OvVr16FdB4KUU7KMizjHdk
+jgoLH6EWGB7VYX3cl2p+Y6c94yfWWKhIQQ59HDwobM0t9vyFTlljI6Qm9hf8hlDZ
+PuKwK/2AIc8OLHSmUvI9t8KItguYqGmZ5o3EGbedAgMBAAGjXzBdMBMGA1UdJQQM
+MAoGCCsGAQUFBwMCMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUoz7Q2xINENmG
+IyzlZmtAuTYAuuYwFwYDVR0RBBAwDoIMYWRtaW4tY2xpZW50MA0GCSqGSIb3DQEB
+CwUAA4IBAQCBbvc/v9jAfVW10h5NRKJe+Ppx9wwpE+eLQiK/BQs3X0KzLdwMH1nn
+tk5E2VzKmg2YlgCAx0Jf0bCEnI1PAt2HNKNmgreEYpicg/+EFr5xpaPfCvK4YCoT
+4i3lqXEa9FsosXEcXe7XsbPta6yVE2bISqi2zMM/PPdML2ws9bwDBrXSiMZ9bfCD
+zEVH4KuspLNzj2Gk0A7sTCx5kHGnHgFpikw97t9pvHL9VeaQPPxWddzLAylvUobq
+3cYLy/D5N3GmPxUm5HA9JDqgkjJRjVXygH1ZW3YIa1FTyIhEJ+Pje8yqdNPVP3hc
+V0XQe0C47IPgHPGJXUB1p+VZnJ/M+BbI
+-----END CERTIFICATE-----
diff --git a/test/kind/testdata/admin-client.key b/test/kind/testdata/admin-client.key
new file mode 100644
index 00000000..970b13ba
--- /dev/null
+++ b/test/kind/testdata/admin-client.key
@@ -0,0 +1,27 @@
+-----BEGIN RSA PRIVATE KEY-----
+MIIEogIBAAKCAQEAsbuYF/r3xLJ1SqHa2TWAayGIwifqyvfgOAzjvLeD+GS46aE7
+m8kWbEzUpP1FDb5RpgIZGUXTfE6mViqGG/xd/5ZYw+HxpOiIWoxbPamb1ARBWRf/
+rvI5O96UUpdv5wILIokV4eJMMxp4hcKTtZn0JASWV7RNDS0zAlhRtCJhxrTUKfyv
+3z1HaOvYqzrKrZn2BQitA2UP83bRXkQIW8P57/Njr1a9ehXQeClFOyjIs4x3ZI4K
+Cx+hFhge1WF93JdqfmOnPeMn1lioSEEOfRw8KGzNLfb8hU5ZYyOkJvYX/IZQ2T7i
+sCv9gCHPDix0plLyPbfCiLYLmKhpmeaNxBm3nQIDAQABAoIBACWHJNA7b8GapOWD
+U4B1qY31YLkOUKdWu4NaRWP9o+H48opyPvHf/doURvoneEM0omzZGI+bjNI8kSa0
+h+i02uwyxL9nn+xgJRppdIKKo5qa42l6hcRc5PTdRJhD3Z77cXpzU6mEbO6Fcllc
+AnBf94r7ZPtT2MkleBXQrD/K2rZn8hhHhUeNnayJjmODCnXZdoINqj589ScTyYzX
+ufEfNZwFURY2OCwSlnytoECc9eHB6veMOwjzw87J6SQS1lsC6omMy5VpTN7HLcJu
+7ZepjGxxt3f4cx/DwXRiiwQs+WwctYvfKh1bfdTxbZ0ETOM0/33jbTGATbk14pHe
+/GRbIQkCgYEA3VrfkJmfZPj4HX5PP7vtjH298HmXmWd5EPAsIrSY8dUCsC7FVrEb
+MnIUA/cq5edjccJFLP0BAAOg/ICk+gstlQdP3rwpvxsDF/L6h6DHC685MT5XaHuP
+6TdIk696bS2+0xePhZodP/VG1UqS/pDT9yhKeKrkLvSOA/rpa4gpensCgYEAzYzk
+QxFR3V2T2SAXf64AsMxw4KWkGq0gHNfjdEVIbssT+nBpeMxn51Ve+lU/g5WW7y9g
++feI/SdgFa1svGSl7fNCmoyo5u+To533iEF37iucZL0L5IuPIVKYJKe7Dg62SvIH
+wX5K6qyAbWqd+huHcNZAbNBcegJZJvw8Zesj5scCgYAy8nN6aKFTMCqLP0MmPC7U
+oyxQaOwHltU6nMzLwB1jq89OlbU92s2TssYAk6b/+13cFQau8ByG0E8BTuqp0mDP
+aDtt3IkPPzxbCsW26b5mZhIXz2120tmwp9TAiSb4cgr1svqJmYsZ6W5AMUXb6aGf
+xVo+o7aZSBhXuix3X4OMeQKBgHeBncjcjgMs/+OyA9eI+//OrSX/R/z2gQAkCKs2
+CNnZmkD2EGxaM2LNQM48uBOx6jIgErriTzQYK4YO8XRK9Cn3T9b5Rs4VpnnvQtZm
+cer4UhJD02FKPqo6EhjlqByRMy05sIav/bCZIIX9AeJDFSjmeEiLj+ij6t9+sUL0
+RkhLAoGABswJ7kNEBK46suK+gdrL8+YqDtaqMXAcaZcWWI/1yBrCAy8Pqm5Oo8k9
+PQO6Iqs/HSmgmnLpSmtZIrS9JUh1NbYTAUeM/Kwi/BCxjGyFiU1nOdaiR+lJkE3f
+pTWoJA3S5NGl5TTpJeTytRUWdiWDinLXy6GO/YgvPRxKZP9EWLs=
+-----END RSA PRIVATE KEY-----
diff --git a/test/kind/testdata/ca.crt b/test/kind/testdata/ca.crt
new file mode 100644
index 00000000..9b6103fb
--- /dev/null
+++ b/test/kind/testdata/ca.crt
@@ -0,0 +1,21 @@
+-----BEGIN CERTIFICATE-----
+MIIDdzCCAl+gAwIBAgIQBLSr/dkALdFTTl/S72cxRDANBgkqhkiG9w0BAQsFADBH
+MSowEgYDVQQLEwtEZXZlbG9wbWVudDAUBgNVBAsTDU9ic2VydmF0b3JpdW0xGTAX
+BgNVBAMTEE9ic2VydmF0b3JpdW0gQ0EwHhcNMjYwNDA5MTYxNzE2WhcNMjcwNDA5
+MTYxNzE2WjBHMSowEgYDVQQLEwtEZXZlbG9wbWVudDAUBgNVBAsTDU9ic2VydmF0
+b3JpdW0xGTAXBgNVBAMTEE9ic2VydmF0b3JpdW0gQ0EwggEiMA0GCSqGSIb3DQEB
+AQUAA4IBDwAwggEKAoIBAQCeSLo0uKPeaUZLkqn5uPxLpTYqK/ZEWHnXFEpO7v5i
+jH3B98sn/N8tZA1uF0KZAHRaP1XHPKtD7ywOxgq2dKXc+vT0Pq/HTGGAlzjnf/hi
+0ZmyxL7pnOoZWkGuiwBQ8QNLxHc8hkvVghGpmX9+LbaYGd11QBfAAATia8PVWBQD
+/Y5qW9f8cQJ69kIpYpem1HKm+QHkR65nc+szjGrtkS3FNIMohc7ti0dqMu/PDlky
+wR1JbsWeob9E/txlwAtpmG1LvNzrPNKYCtyamO8kL286kM9WsOQr/pu3YMGWoWv4
+sQlDZrYjX/jn7l1Bn1w+e0EQs5+7mV0L+BuNarlQdB15AgMBAAGjXzBdMA4GA1Ud
+DwEB/wQEAwICpDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBSjPtDbEg0Q2YYj
+LOVma0C5NgC65jAbBgNVHREEFDASghBvYnNlcnZhdG9yaXVtLWNhMA0GCSqGSIb3
+DQEBCwUAA4IBAQBfZTvAnkq5BDoKIXuQlVLjPHHf/ie+WlNXUm22bUWn4SBn9Kvv
+3zHHEJOOj+KjVrJYde2bQfK+jU8g4nLQOundCETIGuomv4XzgaSrAW99CqdzDS4v
+XRRK9D8cxpAf45AW4riRg4tjRJoYCnJRz9LYZcmAUC2Uh1rwmjRluWiki+/5XaMf
+tuZcbN8ccTr1fwjq3ClWwPeRUtB4WEc7hvwnqifVGFmPEZ3/qkWC+OijZtmWNDmb
+Lh3Rz5cdImS6oIqhXDhiTE6/o6XvdtbBtCsqlxCqfFX6qWhXywl2KGL//qa2IB1d
+4Fst62fvGCtIOSf2DWuRfXSoKpvZpzJsy3yw
+-----END CERTIFICATE-----
diff --git a/test/kind/testdata/observatorium-api.yaml b/test/kind/testdata/observatorium-api.yaml
new file mode 100644
index 00000000..8ea5921b
--- /dev/null
+++ b/test/kind/testdata/observatorium-api.yaml
@@ -0,0 +1,184 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: tenant-config
+ namespace: proxy
+data:
+ tenants.yaml: |
+ tenants:
+ - name: auth-tenant
+ id: "1610702597"
+ oidc:
+ clientID: observatorium-api
+ clientSecret: ZXhhbXBsZS1hcHAtc2VjcmV0
+ issuerURL: http://dex.proxy.svc.cluster.local:5556/dex
+ redirectURL: http://localhost:8080/oidc/auth-tenant/callback
+ usernameClaim: email
+ paths:
+ - operator: "!~"
+ pattern: ".*(loki/api/v1/push|api/v1/receive).*"
+ mTLS:
+ caPath: /etc/certs/ca.crt
+ paths:
+ - operator: "=~"
+ pattern: ".*(api/v1/receive).*"
+ - operator: "=~"
+ pattern: ".*(loki/api/v1/push).*"
+---
+# CA Certificate ConfigMap
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: ca-cert-config
+ namespace: proxy
+data:
+ ca.crt: |
+ -----BEGIN CERTIFICATE-----
+ MIIDdzCCAl+gAwIBAgIQBLSr/dkALdFTTl/S72cxRDANBgkqhkiG9w0BAQsFADBH
+ MSowEgYDVQQLEwtEZXZlbG9wbWVudDAUBgNVBAsTDU9ic2VydmF0b3JpdW0xGTAX
+ BgNVBAMTEE9ic2VydmF0b3JpdW0gQ0EwHhcNMjYwNDA5MTYxNzE2WhcNMjcwNDA5
+ MTYxNzE2WjBHMSowEgYDVQQLEwtEZXZlbG9wbWVudDAUBgNVBAsTDU9ic2VydmF0
+ b3JpdW0xGTAXBgNVBAMTEE9ic2VydmF0b3JpdW0gQ0EwggEiMA0GCSqGSIb3DQEB
+ AQUAA4IBDwAwggEKAoIBAQCeSLo0uKPeaUZLkqn5uPxLpTYqK/ZEWHnXFEpO7v5i
+ jH3B98sn/N8tZA1uF0KZAHRaP1XHPKtD7ywOxgq2dKXc+vT0Pq/HTGGAlzjnf/hi
+ 0ZmyxL7pnOoZWkGuiwBQ8QNLxHc8hkvVghGpmX9+LbaYGd11QBfAAATia8PVWBQD
+ /Y5qW9f8cQJ69kIpYpem1HKm+QHkR65nc+szjGrtkS3FNIMohc7ti0dqMu/PDlky
+ wR1JbsWeob9E/txlwAtpmG1LvNzrPNKYCtyamO8kL286kM9WsOQr/pu3YMGWoWv4
+ sQlDZrYjX/jn7l1Bn1w+e0EQs5+7mV0L+BuNarlQdB15AgMBAAGjXzBdMA4GA1Ud
+ DwEB/wQEAwICpDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBSjPtDbEg0Q2YYj
+ LOVma0C5NgC65jAbBgNVHREEFDASghBvYnNlcnZhdG9yaXVtLWNhMA0GCSqGSIb3
+ DQEBCwUAA4IBAQBfZTvAnkq5BDoKIXuQlVLjPHHf/ie+WlNXUm22bUWn4SBn9Kvv
+ 3zHHEJOOj+KjVrJYde2bQfK+jU8g4nLQOundCETIGuomv4XzgaSrAW99CqdzDS4v
+ XRRK9D8cxpAf45AW4riRg4tjRJoYCnJRz9LYZcmAUC2Uh1rwmjRluWiki+/5XaMf
+ tuZcbN8ccTr1fwjq3ClWwPeRUtB4WEc7hvwnqifVGFmPEZ3/qkWC+OijZtmWNDmb
+ Lh3Rz5cdImS6oIqhXDhiTE6/o6XvdtbBtCsqlxCqfFX6qWhXywl2KGL//qa2IB1d
+ 4Fst62fvGCtIOSf2DWuRfXSoKpvZpzJsy3yw
+ -----END CERTIFICATE-----
+---
+# RBAC Configuration
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: rbac-config
+ namespace: proxy
+data:
+ rbac.yaml: |
+ roles:
+ - name: read-write
+ resources:
+ - metrics
+ - logs
+ tenants:
+ - auth-tenant
+ permissions:
+ - read
+ - write
+ - name: read
+ resources:
+ - metrics
+ - logs
+ tenants:
+ - auth-tenant
+ permissions:
+ - read
+ - name: write
+ resources:
+ - metrics
+ - logs
+ tenants:
+ - auth-tenant
+ permissions:
+ - write
+ roleBindings:
+ - name: admin-user
+ roles:
+ - read-write
+ subjects:
+ - kind: user
+ name: admin@example.com
+ - name: test-user
+ roles:
+ - read
+ subjects:
+ - kind: user
+ name: test@example.com
+ - name: admin-client
+ roles:
+ - write
+ subjects:
+ - kind: user
+ name: admin-client
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: observatorium-api
+ namespace: proxy
+ labels:
+ app: observatorium-api
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: observatorium-api
+ template:
+ metadata:
+ labels:
+ app: observatorium-api
+ spec:
+ containers:
+ - name: observatorium-api
+ image: observatorium-api-auth-test:latest
+ imagePullPolicy: Never
+ ports:
+ - name: http
+ containerPort: 8080
+ - name: internal
+ containerPort: 8081
+ args:
+ - --web.listen=0.0.0.0:8080
+ - --web.internal.listen=0.0.0.0:8081
+ - --tls.server.cert-file=/etc/server-certs/tls.crt
+ - --tls.server.key-file=/etc/server-certs/tls.key
+ - --tls.client-auth-type=RequestClientCert
+ - --web.healthchecks.url=http://localhost:8081
+ - --tenants.config=/etc/config/tenants.yaml
+ - --rbac.config=/etc/config/rbac.yaml
+ - --metrics.read.endpoint=http://api-proxy.proxy.svc.cluster.local
+ - --metrics.write.endpoint=http://api-proxy.proxy.svc.cluster.local
+ - --logs.read.endpoint=http://api-proxy.proxy.svc.cluster.local
+ - --logs.write.endpoint=http://api-proxy.proxy.svc.cluster.local
+ - --log.level=debug
+ volumeMounts:
+ - name: server-certs
+ mountPath: /etc/server-certs
+ readOnly: true
+ - name: ca-cert
+ mountPath: /etc/certs
+ readOnly: true
+ - name: tenant-config
+ mountPath: /etc/config/tenants.yaml
+ readOnly: true
+ subPath: tenants.yaml
+ - name: rbac-config
+ mountPath: /etc/config/rbac.yaml
+ readOnly: true
+ subPath: rbac.yaml
+ env:
+ - name: KUBERNETES_NAMESPACE
+ valueFrom:
+ fieldRef:
+ fieldPath: metadata.namespace
+ volumes:
+ - name: server-certs
+ secret:
+ secretName: server-cert
+ - name: ca-cert
+ configMap:
+ name: ca-cert-config
+ - name: tenant-config
+ configMap:
+ name: tenant-config
+ - name: rbac-config
+ configMap:
+ name: rbac-config
diff --git a/test/kind/testdata/rbac.yaml b/test/kind/testdata/rbac.yaml
new file mode 100644
index 00000000..5540307a
--- /dev/null
+++ b/test/kind/testdata/rbac.yaml
@@ -0,0 +1,45 @@
+roles:
+- name: read-write
+ resources:
+ - metrics
+ - logs
+ tenants:
+ - auth-tenant
+ permissions:
+ - read
+ - write
+- name: read
+ resources:
+ - metrics
+ - logs
+ tenants:
+ - auth-tenant
+ permissions:
+ - read
+- name: write
+ resources:
+ - metrics
+ - logs
+ tenants:
+ - auth-tenant
+ permissions:
+ - write
+roleBindings:
+- name: admin-user
+ roles:
+ - read-write
+ subjects:
+ - kind: user
+ name: admin@example.com
+- name: test-user
+ roles:
+ - read
+ subjects:
+ - kind: user
+ name: test@example.com
+- name: admin-client
+ roles:
+ - write
+ subjects:
+ - kind: user
+ name: admin-client
diff --git a/test/kind/testdata/tenants.yaml b/test/kind/testdata/tenants.yaml
new file mode 100644
index 00000000..5d79f06e
--- /dev/null
+++ b/test/kind/testdata/tenants.yaml
@@ -0,0 +1,19 @@
+tenants:
+- name: auth-tenant
+ id: "1610702597"
+ oidc:
+ clientID: observatorium-api
+ clientSecret: ZXhhbXBsZS1hcHAtc2VjcmV0
+ issuerURL: http://dex.proxy.svc.cluster.local:5556/dex
+ redirectURL: http://localhost:8080/oidc/auth-tenant/callback
+ usernameClaim: email
+ paths:
+ - operator: "!~"
+ pattern: ".*(loki/api/v1/push|api/v1/receive).*"
+ mTLS:
+ caPath: /etc/certs/ca.crt
+ paths:
+ - operator: "=~"
+ pattern: ".*(api/v1/receive).*"
+ - operator: "=~"
+ pattern: ".*(loki/api/v1/push).*"
diff --git a/test/kind/testdata/test-client.crt b/test/kind/testdata/test-client.crt
new file mode 100644
index 00000000..ed6481c9
--- /dev/null
+++ b/test/kind/testdata/test-client.crt
@@ -0,0 +1,21 @@
+-----BEGIN CERTIFICATE-----
+MIIDcTCCAlmgAwIBAgIQJFBcceRaZW45e9UrLXkE5DANBgkqhkiG9w0BAQsFADBH
+MSowEgYDVQQLEwtEZXZlbG9wbWVudDAUBgNVBAsTDU9ic2VydmF0b3JpdW0xGTAX
+BgNVBAMTEE9ic2VydmF0b3JpdW0gQ0EwHhcNMjYwNDA5MTYxNzIwWhcNMjYwNzA4
+MTYxNzIwWjBCMSowEgYDVQQLEwtUZXN0IENsaWVudDAUBgNVBAsTDU9ic2VydmF0
+b3JpdW0xFDASBgNVBAMTC3Rlc3QtY2xpZW50MIIBIjANBgkqhkiG9w0BAQEFAAOC
+AQ8AMIIBCgKCAQEAx8HSzr+sIGGz+/17uq8sAORubGG8R80Jng/Jcx1UvrEnPyb4
+fN+DNprdtfMDjZ6LljGhRDIpF6o7jgeBI40bgG54fytZWDPq6KTox6rEiRsrH7/D
+A61M6OeULq1VAo4nT+cmonVXHs60gRL45DFdQVZX8tenFkqlOtP2BYTQYzeG9NVh
+Bliz7V7BfyktkyF5iE+guXJCri6NgG73vYM1cnKeWOS0/qP0uruG8KceKzpB96SP
+XNFbUZjlR6auQTjr4XXntOLusFd8opv+VN7c4AlXxLvYgj4/VG+1aMiS34PKpcg+
+w4qmJLH5Q5PJX/bstNdcLzHzcYVcUWc7DfY3sQIDAQABo14wXDATBgNVHSUEDDAK
+BggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFKM+0NsSDRDZhiMs
+5WZrQLk2ALrmMBYGA1UdEQQPMA2CC3Rlc3QtY2xpZW50MA0GCSqGSIb3DQEBCwUA
+A4IBAQArIqGmXMuD5mS7agc0/684/S6z1yvfpaAOVBMT1JobqEMX6t/HWM2fQOiu
+fc0uyDD4GJtTTk20HBqyLkhEynRuHz9fJVHen482/VP9Z1RXP44vsZcqHIHPzhZj
+M7rTNA2nmTQk9QqsKL1/rRVHDdiHRV13ab/vuX34DUwXN9LOhCNhrFr1KgjCSD87
+cgHrRBtDdYiQZ89KwD04ozGPUAMsNeErxPuqgEH3fDZukCpZKVsOxcgX2e1M9lbI
+Od1MjN/vnxu5VrBvw1026gkicLSrqGQOv4reZ+/DH6dFNYdL35LdiqqGlYHYyBxs
+aHLbrSP8Atuq/V0ZHUnWiQLXrajs
+-----END CERTIFICATE-----
diff --git a/test/kind/testdata/test-client.key b/test/kind/testdata/test-client.key
new file mode 100644
index 00000000..575bf7c0
--- /dev/null
+++ b/test/kind/testdata/test-client.key
@@ -0,0 +1,27 @@
+-----BEGIN RSA PRIVATE KEY-----
+MIIEpAIBAAKCAQEAx8HSzr+sIGGz+/17uq8sAORubGG8R80Jng/Jcx1UvrEnPyb4
+fN+DNprdtfMDjZ6LljGhRDIpF6o7jgeBI40bgG54fytZWDPq6KTox6rEiRsrH7/D
+A61M6OeULq1VAo4nT+cmonVXHs60gRL45DFdQVZX8tenFkqlOtP2BYTQYzeG9NVh
+Bliz7V7BfyktkyF5iE+guXJCri6NgG73vYM1cnKeWOS0/qP0uruG8KceKzpB96SP
+XNFbUZjlR6auQTjr4XXntOLusFd8opv+VN7c4AlXxLvYgj4/VG+1aMiS34PKpcg+
+w4qmJLH5Q5PJX/bstNdcLzHzcYVcUWc7DfY3sQIDAQABAoIBAQCoD5guvfAxJkJp
+RfCtNefNsGJ+6ROhTQ5EX3/bS9gav60HYuA5H0ujy1OxWw2oPrWt3X+Sgqkz8dM9
+QW8S3AtBWLAkPfJjVPrUVLpMEo5b0/HKOmn2U/2bDgsL4mTdS4Ajp5RHpnVvw/T1
+i2K/ihCtgdloxKsdBBxTjDU2m3E/pt7jLqMKvc+4wCr3FUYAV/dZzs5Rlg/pQIB+
+R1EXMndrx+0XfTuA7nnxEPpBNB8+I2c0Q9PJa3L1k7PHyFBVSTOj5V6oXmhK+i9A
+LVTJCfMSuAP9Eek1WDZEili3m0y7DtWCQRJKCxOexrHJmKKqkEXpkTy0bxmGA++3
+00Or20ThAoGBAP3XoGWDtTepb1VgKgKxVEBPyzFWwY4uDdgI073zi2n05AnMTlku
+L5O3wlRiAgt8MaFdtRrqvTvqTOIk0BnT62WGgThWmPXCZr21G7imGPyKCjrRPkMZ
+C9xdko2SJFo+E08cRr5sRgnMOgRq1Nd5MqJozToEyEeIcQ6dGtCs7RJtAoGBAMl0
+gUFCPzl9Fv2MogtnrNFJNAKg5N2fBQnTM8jyE0mI3r2O1T8oVd7+e+9wTA6ryjrZ
+YWHLQOzDaK4zivvOu0Tw6qnAao/7+nLKtrqZMHoIAAdzEYrKm4NI34bltlYS/1LC
+Fz3SU4Z4DFAbJ+kJh0VlFlowOgH8DX0vA9SyN4/VAoGBAJW6l7jpXH4tqicg5wbZ
+lImux3sd4mO8VJwmcMdtncFtGwmQgnJmJNgsHyto+C3QHvJA9O+goOhzhtApyLpD
+X2luUlBp/CWSesnRxz0+dCSaQ/h3rhMj9fQRGb36Awlb7kXOtwfhk9p5pYsvfMZw
+jeZwjQV5Bq3zFET7dHK8XcZtAoGALVGXLi4P+QfJ3zn+ziABgYc9OwYk1jJKuN4Z
+PTAv5I/0w6HZGP8i6ipHiSKzCW3d7YUvYgeOUHTZHK8dqe3ktOqZb5yInGFsAtzV
+ZH7Hp/wavZJGNPnFKDCBkGAmt5BIfb6J6e9huNNSucaSINty4cqOz7Ufp4ijJDEq
+ZHO/Cg0CgYAfvsW4KixtYLhba9xForLr95aB03at4hCYLkXsDh7iMHYKipvJh6He
+wt/pjJrJP581aHRoVqiX7gC5hSj+KYD8RhxX+SP5psqjhVzb0dBFiWfoLCr9SLEb
+OjK+JWVI75cZoJQp2qdEel/bibCbWkmxir0DzYHBcIFfP8fo320jug==
+-----END RSA PRIVATE KEY-----