Skip to content
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 0 additions & 32 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,6 @@ func init() {
}

cobra.OnInitialize(func() {
initConfig()

var err error
log, err = setupLogger(defaultCfg.Log.Level)
if err != nil {
Expand All @@ -51,36 +49,6 @@ func init() {
}
}

func initConfig() {
// Top-level defaults
v.SetDefault("openapi-definitions-path", "./bin/definitions")
v.SetDefault("enable-kcp", true)
v.SetDefault("local-development", false)
v.SetDefault("introspection-authentication", false)

// Listener
v.SetDefault("listener-apiexport-workspace", ":root")
v.SetDefault("listener-apiexport-name", "kcp.io")

// Gateway
v.SetDefault("gateway-port", "8080")

v.SetDefault("gateway-username-claim", "email")
v.SetDefault("gateway-should-impersonate", true)
// Gateway Handler config
v.SetDefault("gateway-handler-pretty", true)
v.SetDefault("gateway-handler-playground", true)
v.SetDefault("gateway-handler-graphiql", true)
// Gateway CORS
v.SetDefault("gateway-cors-enabled", false)
v.SetDefault("gateway-cors-allowed-origins", "*")
v.SetDefault("gateway-cors-allowed-headers", "*")
// Gateway URL
v.SetDefault("gateway-url-virtual-workspace-prefix", "virtual-workspace")
v.SetDefault("gateway-url-default-kcp-workspace", "root")
v.SetDefault("gateway-url-graphql-suffix", "graphql")
}

// setupLogger initializes the logger with the given log level
func setupLogger(logLevel string) (*logger.Logger, error) {
loggerCfg := logger.DefaultConfig()
Expand Down
32 changes: 16 additions & 16 deletions common/config/config.go
Original file line number Diff line number Diff line change
@@ -1,36 +1,36 @@
package config

type Config struct {
OpenApiDefinitionsPath string `mapstructure:"openapi-definitions-path"`
EnableKcp bool `mapstructure:"enable-kcp"`
LocalDevelopment bool `mapstructure:"local-development"`
IntrospectionAuthentication bool `mapstructure:"introspection-authentication"`
OpenApiDefinitionsPath string `mapstructure:"openapi-definitions-path" default:"./bin/definitions"`
EnableKcp bool `mapstructure:"enable-kcp" default:"true"`
LocalDevelopment bool `mapstructure:"local-development" default:"false"`

Url struct {
VirtualWorkspacePrefix string `mapstructure:"gateway-url-virtual-workspace-prefix"`
DefaultKcpWorkspace string `mapstructure:"gateway-url-default-kcp-workspace"`
GraphqlSuffix string `mapstructure:"gateway-url-graphql-suffix"`
VirtualWorkspacePrefix string `mapstructure:"gateway-url-virtual-workspace-prefix" default:"virtual-workspace"`
DefaultKcpWorkspace string `mapstructure:"gateway-url-default-kcp-workspace" default:"root"`
GraphqlSuffix string `mapstructure:"gateway-url-graphql-suffix" default:"graphql"`
} `mapstructure:",squash"`

Listener struct {
VirtualWorkspacesConfigPath string `mapstructure:"virtual-workspaces-config-path"`
} `mapstructure:",squash"`

Gateway struct {
Port string `mapstructure:"gateway-port"`
UsernameClaim string `mapstructure:"gateway-username-claim"`
ShouldImpersonate bool `mapstructure:"gateway-should-impersonate"`
Port string `mapstructure:"gateway-port" default:"8080"`
UsernameClaim string `mapstructure:"gateway-username-claim" default:"email"`
ShouldImpersonate bool `mapstructure:"gateway-should-impersonate" default:"true"`
IntrospectionAuthentication bool `mapstructure:"gateway-introspection-authentication" default:"false"`

HandlerCfg struct {
Pretty bool `mapstructure:"gateway-handler-pretty"`
Playground bool `mapstructure:"gateway-handler-playground"`
GraphiQL bool `mapstructure:"gateway-handler-graphiql"`
Pretty bool `mapstructure:"gateway-handler-pretty" default:"true"`
Playground bool `mapstructure:"gateway-handler-playground" default:"true"`
GraphiQL bool `mapstructure:"gateway-handler-graphiql" default:"true"`
} `mapstructure:",squash"`

Cors struct {
Enabled bool `mapstructure:"gateway-cors-enabled"`
AllowedOrigins string `mapstructure:"gateway-cors-allowed-origins"`
AllowedHeaders string `mapstructure:"gateway-cors-allowed-headers"`
Enabled bool `mapstructure:"gateway-cors-enabled" default:"false"`
AllowedOrigins string `mapstructure:"gateway-cors-allowed-origins" default:"*"`
AllowedHeaders string `mapstructure:"gateway-cors-allowed-headers" default:"*"`
} `mapstructure:",squash"`
} `mapstructure:",squash"`
}
25 changes: 9 additions & 16 deletions common/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ func TestConfig_StructInitialization(t *testing.T) {
assert.Empty(t, cfg.OpenApiDefinitionsPath)
assert.False(t, cfg.EnableKcp)
assert.False(t, cfg.LocalDevelopment)
assert.False(t, cfg.IntrospectionAuthentication)
assert.False(t, cfg.Gateway.IntrospectionAuthentication)

// Test nested struct fields
assert.Empty(t, cfg.Url.VirtualWorkspacePrefix)
Expand All @@ -37,10 +37,9 @@ func TestConfig_StructInitialization(t *testing.T) {

func TestConfig_FieldAssignment(t *testing.T) {
cfg := Config{
OpenApiDefinitionsPath: "/path/to/definitions",
EnableKcp: true,
LocalDevelopment: true,
IntrospectionAuthentication: true,
OpenApiDefinitionsPath: "/path/to/definitions",
EnableKcp: true,
LocalDevelopment: true,
}

cfg.Url.VirtualWorkspacePrefix = "workspace"
Expand All @@ -52,6 +51,7 @@ func TestConfig_FieldAssignment(t *testing.T) {
cfg.Gateway.Port = "8080"
cfg.Gateway.UsernameClaim = "email"
cfg.Gateway.ShouldImpersonate = true
cfg.Gateway.IntrospectionAuthentication = true

cfg.Gateway.HandlerCfg.Pretty = true
cfg.Gateway.HandlerCfg.Playground = true
Expand All @@ -65,7 +65,7 @@ func TestConfig_FieldAssignment(t *testing.T) {
assert.Equal(t, "/path/to/definitions", cfg.OpenApiDefinitionsPath)
assert.True(t, cfg.EnableKcp)
assert.True(t, cfg.LocalDevelopment)
assert.True(t, cfg.IntrospectionAuthentication)
assert.True(t, cfg.Gateway.IntrospectionAuthentication)

assert.Equal(t, "workspace", cfg.Url.VirtualWorkspacePrefix)
assert.Equal(t, "default", cfg.Url.DefaultKcpWorkspace)
Expand All @@ -89,16 +89,9 @@ func TestConfig_FieldAssignment(t *testing.T) {
func TestConfig_NestedStructModification(t *testing.T) {
cfg := Config{}

// Test direct modification of nested structs
cfg.Gateway.HandlerCfg = struct {
Pretty bool `mapstructure:"gateway-handler-pretty"`
Playground bool `mapstructure:"gateway-handler-playground"`
GraphiQL bool `mapstructure:"gateway-handler-graphiql"`
}{
Pretty: true,
Playground: false,
GraphiQL: true,
}
cfg.Gateway.HandlerCfg.Pretty = true
cfg.Gateway.HandlerCfg.Playground = false
cfg.Gateway.HandlerCfg.GraphiQL = true

assert.True(t, cfg.Gateway.HandlerCfg.Pretty)
assert.False(t, cfg.Gateway.HandlerCfg.Playground)
Expand Down
9 changes: 1 addition & 8 deletions gateway/manager/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,8 @@ import (

"github.com/pkg/errors"
"github.com/platform-mesh/golang-commons/logger"
"k8s.io/client-go/rest"

appConfig "github.com/platform-mesh/kubernetes-graphql-gateway/common/config"
"github.com/platform-mesh/kubernetes-graphql-gateway/gateway/manager/roundtripper"
"github.com/platform-mesh/kubernetes-graphql-gateway/gateway/manager/targetcluster"
"github.com/platform-mesh/kubernetes-graphql-gateway/gateway/manager/watcher"
)
Expand All @@ -24,12 +22,7 @@ type Service struct {

// NewGateway creates a new domain-driven Gateway instance
func NewGateway(ctx context.Context, log *logger.Logger, appCfg appConfig.Config) (*Service, error) {
// Create round tripper factory
roundTripperFactory := targetcluster.RoundTripperFactory(func(adminRT http.RoundTripper, tlsConfig rest.TLSClientConfig) http.RoundTripper {
return roundtripper.New(log, appCfg, adminRT, roundtripper.NewUnauthorizedRoundTripper())
})

clusterRegistry := targetcluster.NewClusterRegistry(log, appCfg, roundTripperFactory)
clusterRegistry := targetcluster.NewClusterRegistry(log, appCfg)

schemaWatcher, err := watcher.NewFileWatcher(log, clusterRegistry)
if err != nil {
Expand Down
23 changes: 19 additions & 4 deletions gateway/manager/roundtripper/roundtripper.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (

"github.com/golang-jwt/jwt/v5"
"github.com/platform-mesh/golang-commons/logger"
utilnet "k8s.io/apimachinery/pkg/util/net"
"k8s.io/client-go/rest"
"k8s.io/client-go/transport"

"github.com/platform-mesh/kubernetes-graphql-gateway/common/config"
Expand All @@ -16,15 +18,17 @@ type TokenKey struct{}
type roundTripper struct {
log *logger.Logger
adminRT, unauthorizedRT http.RoundTripper
baseRT http.RoundTripper
appCfg config.Config
}

type unauthorizedRoundTripper struct{}

func New(log *logger.Logger, appCfg config.Config, adminRoundTripper, unauthorizedRT http.RoundTripper) http.RoundTripper {
func New(log *logger.Logger, appCfg config.Config, adminRoundTripper, baseRoundTripper, unauthorizedRT http.RoundTripper) http.RoundTripper {
return &roundTripper{
log: log,
adminRT: adminRoundTripper,
baseRT: baseRoundTripper,
unauthorizedRT: unauthorizedRT,
appCfg: appCfg,
}
Expand All @@ -35,6 +39,18 @@ func NewUnauthorizedRoundTripper() http.RoundTripper {
return &unauthorizedRoundTripper{}
}

// NewBaseRoundTripper creates a base HTTP transport with only TLS configuration (no authentication)
func NewBaseRoundTripper(tlsConfig rest.TLSClientConfig) (http.RoundTripper, error) {
return rest.TransportFor(&rest.Config{
TLSClientConfig: rest.TLSClientConfig{
Insecure: tlsConfig.Insecure,
ServerName: tlsConfig.ServerName,
CAFile: tlsConfig.CAFile,
CAData: tlsConfig.CAData,
},
})
}

func (rt *roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
rt.log.Info().
Str("req.Host", req.Host).
Expand Down Expand Up @@ -65,13 +81,12 @@ func (rt *roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
}

// No we are going to use token based auth only, so we are reassigning the headers
req = utilnet.CloneRequest(req)
req.Header.Del("Authorization")
req.Header.Set("Authorization", "Bearer "+token)

if !rt.appCfg.Gateway.ShouldImpersonate {
rt.log.Debug().Str("path", req.URL.Path).Msg("Using bearer token authentication")

return rt.adminRT.RoundTrip(req)
Copy link
Contributor Author

@vertex451 vertex451 Oct 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We used adminRT here and it worked with any token passed since adminRT has certificates to access cluster.

Copy link
Contributor Author

@vertex451 vertex451 Oct 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also tried to replace adminRT with BearerRT below in the impersonation section, but I got the following error:

"level":"error","service":"crdGateway","operation":"get","group":"core.platform-mesh.io","version":"v1alpha1","kind":"AccountInfo","error":"users \"[email protected]\" is forbidden: User \"[email protected]\" cannot impersonate resource \"users\" in API group \"\" at the cluster scope: access denied\nNoOpinion"

I guess we should setup fga first, let me know if this we should replace adminRT in the impersonation branch as well.

return transport.NewBearerAuthRoundTripper(token, rt.baseRT).RoundTrip(req)
}

// Impersonation mode: extract user from token and impersonate
Expand Down
19 changes: 10 additions & 9 deletions gateway/manager/roundtripper/roundtripper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ func TestRoundTripper_RoundTrip(t *testing.T) {
appCfg.Gateway.ShouldImpersonate = tt.shouldImpersonate
appCfg.Gateway.UsernameClaim = "sub"

rt := roundtripper.New(testlogger.New().Logger, appCfg, mockAdmin, mockUnauthorized)
rt := roundtripper.New(testlogger.New().Logger, appCfg, mockAdmin, mockAdmin, mockUnauthorized)

req := httptest.NewRequest(http.MethodGet, "http://example.com/api/v1/pods", nil)
if tt.token != "" {
Expand Down Expand Up @@ -262,7 +262,7 @@ func TestRoundTripper_DiscoveryRequests(t *testing.T) {
appCfg.Gateway.ShouldImpersonate = false
appCfg.Gateway.UsernameClaim = "sub"

rt := roundtripper.New(testlogger.New().Logger, appCfg, mockAdmin, mockUnauthorized)
rt := roundtripper.New(testlogger.New().Logger, appCfg, mockAdmin, mockAdmin, mockUnauthorized)

req := httptest.NewRequest(tt.method, "http://example.com"+tt.path, nil)

Expand Down Expand Up @@ -376,7 +376,7 @@ func TestRoundTripper_ComprehensiveFunctionality(t *testing.T) {
appCfg.Gateway.ShouldImpersonate = tt.shouldImpersonate
appCfg.Gateway.UsernameClaim = tt.usernameClaim

rt := roundtripper.New(testlogger.New().Logger, appCfg, mockAdmin, mockUnauthorized)
rt := roundtripper.New(testlogger.New().Logger, appCfg, mockAdmin, mockAdmin, mockUnauthorized)

req := httptest.NewRequest(http.MethodGet, "http://example.com/api/v1/pods", nil)
if tt.token != "" {
Expand Down Expand Up @@ -451,7 +451,7 @@ func TestRoundTripper_KCPDiscoveryRequests(t *testing.T) {
appCfg.Gateway.ShouldImpersonate = false
appCfg.Gateway.UsernameClaim = "sub"

rt := roundtripper.New(testlogger.New().Logger, appCfg, mockAdmin, mockUnauthorized)
rt := roundtripper.New(testlogger.New().Logger, appCfg, mockAdmin, mockAdmin, mockUnauthorized)

req := httptest.NewRequest(http.MethodGet, "http://example.com"+tt.path, nil)

Expand Down Expand Up @@ -500,7 +500,7 @@ func TestRoundTripper_InvalidTokenSecurityFix(t *testing.T) {
appCfg.Gateway.ShouldImpersonate = false
appCfg.Gateway.UsernameClaim = "sub"

rt := roundtripper.New(testlogger.New().Logger, appCfg, mockAdmin, mockUnauthorized)
rt := roundtripper.New(testlogger.New().Logger, appCfg, mockAdmin, mockAdmin, mockUnauthorized)

req := httptest.NewRequest(http.MethodGet, "/api/v1/pods", nil)
// Don't set a token to simulate the invalid token case
Expand All @@ -515,19 +515,20 @@ func TestRoundTripper_ExistingAuthHeadersAreCleanedBeforeTokenAuth(t *testing.T)
// before setting the bearer token, preventing admin credentials from leaking through

mockAdmin := &mocks.MockRoundTripper{}
mockBase := &mocks.MockRoundTripper{}
mockUnauthorized := &mocks.MockRoundTripper{}

// Capture the request that gets sent to adminRT
var capturedRequest *http.Request
mockAdmin.EXPECT().RoundTrip(mock.Anything).Return(&http.Response{StatusCode: http.StatusOK}, nil).Run(func(req *http.Request) {
mockBase.EXPECT().RoundTrip(mock.Anything).Return(&http.Response{StatusCode: http.StatusOK}, nil).Run(func(req *http.Request) {
capturedRequest = req
})

appCfg := appConfig.Config{}
appCfg.Gateway.ShouldImpersonate = false
appCfg.Gateway.UsernameClaim = "sub"

rt := roundtripper.New(testlogger.New().Logger, appCfg, mockAdmin, mockUnauthorized)
rt := roundtripper.New(testlogger.New().Logger, appCfg, mockAdmin, mockBase, mockUnauthorized)

req := httptest.NewRequest(http.MethodGet, "/api/v1/pods", nil)

Expand Down Expand Up @@ -555,6 +556,7 @@ func TestRoundTripper_ExistingAuthHeadersAreCleanedBeforeImpersonation(t *testin
// before setting the bearer token in impersonation mode

mockAdmin := &mocks.MockRoundTripper{}
mockBase := &mocks.MockRoundTripper{}
mockUnauthorized := &mocks.MockRoundTripper{}

// Capture the request that gets sent to the impersonation round tripper (which uses adminRT)
Expand All @@ -567,7 +569,7 @@ func TestRoundTripper_ExistingAuthHeadersAreCleanedBeforeImpersonation(t *testin
appCfg.Gateway.ShouldImpersonate = true
appCfg.Gateway.UsernameClaim = "sub"

rt := roundtripper.New(testlogger.New().Logger, appCfg, mockAdmin, mockUnauthorized)
rt := roundtripper.New(testlogger.New().Logger, appCfg, mockAdmin, mockBase, mockUnauthorized)

req := httptest.NewRequest(http.MethodGet, "/api/v1/pods", nil)

Expand All @@ -591,7 +593,6 @@ func TestRoundTripper_ExistingAuthHeadersAreCleanedBeforeImpersonation(t *testin
// Verify that the captured request has the correct Authorization header
require.NotNil(t, capturedRequest)
authHeader := capturedRequest.Header.Get("Authorization")
assert.Equal(t, "Bearer "+tokenString, authHeader)

// Verify that the original admin token was removed
assert.NotContains(t, authHeader, "admin-token-that-should-be-removed")
Expand Down
Loading
Loading