Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions cmd/thv-operator/api/v1beta1/mcpremoteproxy_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,10 @@ type MCPRemoteProxyStatus struct {
// +optional
AuthServerConfigHash string `json:"authServerConfigHash,omitempty"`

// AuthzConfigHash is the hash of the referenced MCPAuthzConfig spec for change detection
// +optional
AuthzConfigHash string `json:"authzConfigHash,omitempty"`

// OIDCConfigHash is the hash of the referenced MCPOIDCConfig spec for change detection
// +optional
OIDCConfigHash string `json:"oidcConfigHash,omitempty"`
Expand Down
21 changes: 21 additions & 0 deletions cmd/thv-operator/api/v1beta1/mcpserver_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ const (
const (
// ConditionOIDCConfigRefValidated indicates whether the OIDCConfigRef is valid
ConditionOIDCConfigRefValidated = "OIDCConfigRefValidated"

// ConditionAuthzConfigRefValidated indicates whether the AuthzConfigRef is valid
ConditionAuthzConfigRefValidated = "AuthzConfigRefValidated"
)

const (
Expand All @@ -74,6 +77,20 @@ const (
ConditionReasonOIDCConfigRefError = "OIDCConfigRefError"
)

const (
// ConditionReasonAuthzConfigRefValid indicates the referenced MCPAuthzConfig is valid and ready
ConditionReasonAuthzConfigRefValid = "AuthzConfigRefValid"

// ConditionReasonAuthzConfigRefNotFound indicates the referenced MCPAuthzConfig was not found
ConditionReasonAuthzConfigRefNotFound = "AuthzConfigRefNotFound"

// ConditionReasonAuthzConfigRefNotValid indicates the referenced MCPAuthzConfig is not valid
ConditionReasonAuthzConfigRefNotValid = "AuthzConfigRefNotValid"

// ConditionReasonAuthzConfigRefError indicates an error occurred validating the AuthzConfigRef
ConditionReasonAuthzConfigRefError = "AuthzConfigRefError"
)

const (
// ConditionReasonCABundleRefValid indicates the CABundleRef is valid and the ConfigMap exists
ConditionReasonCABundleRefValid = "CABundleRefValid"
Expand Down Expand Up @@ -898,6 +915,10 @@ type MCPServerStatus struct {
// +optional
AuthServerConfigHash string `json:"authServerConfigHash,omitempty"`

// AuthzConfigHash is the hash of the referenced MCPAuthzConfig spec for change detection
// +optional
AuthzConfigHash string `json:"authzConfigHash,omitempty"`

// OIDCConfigHash is the hash of the referenced MCPOIDCConfig spec for change detection
// +optional
OIDCConfigHash string `json:"oidcConfigHash,omitempty"`
Expand Down
5 changes: 5 additions & 0 deletions cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,11 @@ type VirtualMCPServerStatus struct {
// +optional
BackendCount int32 `json:"backendCount,omitempty"`

// AuthzConfigHash is the hash of the referenced MCPAuthzConfig spec for change detection.
// Only populated when IncomingAuth.AuthzConfigRef is set.
// +optional
AuthzConfigHash string `json:"authzConfigHash,omitempty"`

// OIDCConfigHash is the hash of the referenced MCPOIDCConfig spec for change detection.
// Only populated when IncomingAuth.OIDCConfigRef is set.
// +optional
Expand Down
43 changes: 4 additions & 39 deletions cmd/thv-operator/controllers/mcpauthzconfig_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import (

mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1"
ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil"
"github.com/stacklok/toolhive/pkg/authz/authorizers"
)

const (
Expand All @@ -34,9 +33,6 @@ const (

// authzConfigRequeueDelay is the delay before requeuing after adding a finalizer
authzConfigRequeueDelay = 500 * time.Millisecond

// authzConfigVersion is the default version for reconstructed authz configs
authzConfigVersion = "1.0"
)

// MCPAuthzConfigReconciler reconciles a MCPAuthzConfig object.
Expand Down Expand Up @@ -208,48 +204,17 @@ func (*MCPAuthzConfigReconciler) validateAuthzConfig(authzConfig *mcpv1beta1.MCP
return err
}

// buildFullAuthzConfigJSON returns the registered factory alongside the
// BuildFullAuthzConfigJSON returns the registered factory alongside the
// envelope, so we don't have to re-Unmarshal the JSON or re-look-up the
// factory just to dispatch ValidateConfig.
fullConfigJSON, factory, err := buildFullAuthzConfigJSON(authzConfig.Spec)
// factory just to dispatch ValidateConfig. It lives in controllerutil so the
// workload controllers can reuse it without an import cycle.
fullConfigJSON, factory, err := ctrlutil.BuildFullAuthzConfigJSON(authzConfig.Spec)
if err != nil {
return err
}
return factory.ValidateConfig(fullConfigJSON)
}

// buildFullAuthzConfigJSON reconstructs the full authorizer config JSON from a
// MCPAuthzConfig spec and returns it alongside the resolved factory. The JSON
// shape is the same one accepted by authorizers.Config and stored in
// ConfigMaps: {"version": "1.0", "type": "<type>", "<configKey>": {<config>}}.
// Returning the factory together with the JSON lets callers (notably
// validateAuthzConfig) skip a second registry lookup.
func buildFullAuthzConfigJSON(spec mcpv1beta1.MCPAuthzConfigSpec) ([]byte, authorizers.AuthorizerFactory, error) {
factory := authorizers.GetFactory(spec.Type)
if factory == nil {
return nil, nil, fmt.Errorf("unsupported authorizer type: %s (registered types: %v)",
spec.Type, authorizers.RegisteredTypes())
}

if len(spec.Config.Raw) == 0 {
return nil, nil, fmt.Errorf("config field is empty")
}

versionJSON, _ := json.Marshal(authzConfigVersion)
typeJSON, _ := json.Marshal(spec.Type)
fullConfig := map[string]json.RawMessage{
"version": versionJSON,
"type": typeJSON,
factory.ConfigKey(): spec.Config.Raw,
}

result, err := json.Marshal(fullConfig)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal full authz config: %w", err)
}
return result, factory, nil
}

// canonicalizeSpecForHash returns a copy of spec whose Config.Raw has been
// re-marshalled into canonical JSON form (sorted keys, no extra whitespace).
// The returned value is suitable for ctrlutil.CalculateConfigHash and produces
Expand Down
91 changes: 0 additions & 91 deletions cmd/thv-operator/controllers/mcpauthzconfig_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
package controllers

import (
"encoding/json"
"testing"
"time"

Expand Down Expand Up @@ -56,96 +55,6 @@ func newAuthzTestReconciler(t *testing.T, objs ...client.Object) (*MCPAuthzConfi
return &MCPAuthzConfigReconciler{Client: fakeClient, Scheme: scheme}, fakeClient
}

func Test_buildFullAuthzConfigJSON(t *testing.T) {
t.Parallel()

tests := []struct {
name string
spec mcpv1beta1.MCPAuthzConfigSpec
expectError bool
expectType string
expectKey string
expectVersion string
}{
{
name: "valid Cedar config produces correct JSON",
spec: mcpv1beta1.MCPAuthzConfigSpec{
Type: "cedarv1",
Config: validCedarConfig(),
},
expectType: "cedarv1",
expectKey: "cedar",
expectVersion: "1.0",
},
{
name: "valid HTTP PDP config produces correct JSON",
spec: mcpv1beta1.MCPAuthzConfigSpec{
Type: "httpv1",
Config: validHTTPPDPConfig(),
},
expectType: "httpv1",
expectKey: "pdp",
expectVersion: "1.0",
},
{
name: "unknown type returns error",
spec: mcpv1beta1.MCPAuthzConfigSpec{
Type: "unknown-type",
Config: runtime.RawExtension{Raw: []byte(`{}`)},
},
expectError: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

result, factory, err := buildFullAuthzConfigJSON(tt.spec)

if tt.expectError {
require.Error(t, err)
assert.Nil(t, factory, "factory must be nil when buildFullAuthzConfigJSON errors")
return
}

require.NoError(t, err)
require.NotNil(t, factory, "factory must accompany a successful build")
assert.Equal(t, tt.expectKey, factory.ConfigKey(),
"returned factory's ConfigKey must match the nested envelope key")

var parsed map[string]json.RawMessage
require.NoError(t, json.Unmarshal(result, &parsed), "Output must be valid JSON")

var version string
require.NoError(t, json.Unmarshal(parsed["version"], &version))
assert.Equal(t, tt.expectVersion, version)

var typ string
require.NoError(t, json.Unmarshal(parsed["type"], &typ))
assert.Equal(t, tt.expectType, typ)

_, hasKey := parsed[tt.expectKey]
assert.True(t, hasKey, "Output JSON should contain key %q", tt.expectKey)
})
}
}

func Test_buildFullAuthzConfigJSON_EmptyConfigRaw(t *testing.T) {
t.Parallel()

spec := mcpv1beta1.MCPAuthzConfigSpec{
Type: "cedarv1",
Config: runtime.RawExtension{Raw: []byte{}},
}

result, factory, err := buildFullAuthzConfigJSON(spec)
require.Error(t, err)
assert.Nil(t, result)
assert.Nil(t, factory, "factory must be nil when buildFullAuthzConfigJSON errors")
assert.Contains(t, err.Error(), "config field is empty")
}

func TestCanonicalizeSpecForHash(t *testing.T) {
t.Parallel()

Expand Down
Loading
Loading