From a02fbfce2d1603c2be9ef4d210726da066b39eac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20Ma=C5=82ek?= Date: Tue, 1 Oct 2024 15:02:52 +0200 Subject: [PATCH] feat(konnect): add support for KongCredentialJWT --- .mockery.yaml | 1 + CHANGELOG.md | 1 + config/samples/konnect_kongconsumer_jwt.yaml | 45 +++ controller/konnect/constraints/constraints.go | 1 + .../konnect/constraints/entitytypename.go | 10 +- controller/konnect/index.go | 28 -- controller/konnect/index_credentials_jwt.go | 32 +++ controller/konnect/ops/credentialjwt.go | 14 + controller/konnect/ops/credentialjwt_mock.go | 259 ++++++++++++++++++ controller/konnect/ops/ops.go | 6 + controller/konnect/ops/ops_credentialjwt.go | 157 +++++++++++ controller/konnect/ops/sdkfactory.go | 6 + controller/konnect/ops/sdkfactory_mock.go | 6 + controller/konnect/reconciler_generic.go | 8 + controller/konnect/watch.go | 2 + controller/konnect/watch_credential.go | 67 +++++ controller/konnect/watch_credentialacl.go | 36 +-- controller/konnect/watch_credentialapikey.go | 35 +-- .../konnect/watch_credentialbasicauth.go | 35 +-- controller/konnect/watch_credentialjwt.go | 220 +++++++++++++++ go.mod | 2 +- go.sum | 8 +- modules/manager/controller_setup.go | 99 ++++--- .../kongconsumercredential_jwt_test.go | 164 +++++++++++ test/helpers/deploy/deploy_resources.go | 29 ++ 25 files changed, 1103 insertions(+), 168 deletions(-) create mode 100644 config/samples/konnect_kongconsumer_jwt.yaml create mode 100644 controller/konnect/index_credentials_jwt.go create mode 100644 controller/konnect/ops/credentialjwt.go create mode 100644 controller/konnect/ops/credentialjwt_mock.go create mode 100644 controller/konnect/ops/ops_credentialjwt.go create mode 100644 controller/konnect/watch_credential.go create mode 100644 controller/konnect/watch_credentialjwt.go create mode 100644 test/envtest/kongconsumercredential_jwt_test.go diff --git a/.mockery.yaml b/.mockery.yaml index 43cab5626..4215a1f75 100644 --- a/.mockery.yaml +++ b/.mockery.yaml @@ -24,6 +24,7 @@ packages: KongCredentialAPIKeySDK: KongCredentialACLSDK: KongCredentialBasicAuthSDK: + KongCredentialJWTSDK: CACertificatesSDK: CertificatesSDK: KeysSDK: diff --git a/CHANGELOG.md b/CHANGELOG.md index 40731ab7e..eb8c12ac6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,7 @@ - basic-auth [#625](https://github.com/Kong/gateway-operator/pull/625) - API key [#635](https://github.com/Kong/gateway-operator/pull/635) - ACL [#661](https://github.com/Kong/gateway-operator/pull/661) + - JWT [#678](https://github.com/Kong/gateway-operator/pull/678) - Add support for `KongRoute`s bound directly to `KonnectGatewayControlPlane`s (serviceless rotues). [#669](https://github.com/Kong/gateway-operator/pull/669) diff --git a/config/samples/konnect_kongconsumer_jwt.yaml b/config/samples/konnect_kongconsumer_jwt.yaml new file mode 100644 index 000000000..59cf70f49 --- /dev/null +++ b/config/samples/konnect_kongconsumer_jwt.yaml @@ -0,0 +1,45 @@ +kind: KonnectAPIAuthConfiguration +apiVersion: konnect.konghq.com/v1alpha1 +metadata: + name: konnect-api-auth-dev-1 + namespace: default +spec: + type: token + token: kpat_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX + serverURL: us.api.konghq.com +--- +kind: KonnectGatewayControlPlane +apiVersion: konnect.konghq.com/v1alpha1 +metadata: + name: test-cp-basic-auth + namespace: default +spec: + name: test-cp-basic-auth + labels: + app: test-cp-basic-auth + key1: test-cp-basic-auth + konnect: + authRef: + name: konnect-api-auth-dev-1 +--- +kind: KongConsumer +apiVersion: configuration.konghq.com/v1 +metadata: + name: consumer-jwt-1 + namespace: default +username: consumer1 +spec: + controlPlaneRef: + type: konnectNamespacedRef + konnectNamespacedRef: + name: test-cp-basic-auth +--- +apiVersion: configuration.konghq.com/v1alpha1 +kind: KongCredentialJWT +metadata: + name: jwt-1 + namespace: default +spec: + consumerRef: + name: consumer-jwt-1 + key: secretkey diff --git a/controller/konnect/constraints/constraints.go b/controller/konnect/constraints/constraints.go index d3b74abab..9969403eb 100644 --- a/controller/konnect/constraints/constraints.go +++ b/controller/konnect/constraints/constraints.go @@ -22,6 +22,7 @@ type SupportedKonnectEntityType interface { configurationv1alpha1.KongCredentialBasicAuth | configurationv1alpha1.KongCredentialAPIKey | configurationv1alpha1.KongCredentialACL | + configurationv1alpha1.KongCredentialJWT | configurationv1alpha1.KongUpstream | configurationv1alpha1.KongCACertificate | configurationv1alpha1.KongCertificate | diff --git a/controller/konnect/constraints/entitytypename.go b/controller/konnect/constraints/entitytypename.go index bc96d42f1..606f3bf57 100644 --- a/controller/konnect/constraints/entitytypename.go +++ b/controller/konnect/constraints/entitytypename.go @@ -1,14 +1,16 @@ package constraints +type typeWithName interface { + GetTypeName() string +} + // EntityTypeName returns the name of the entity type. -func EntityTypeName[T SupportedKonnectEntityType]() string { +func EntityTypeName[T typeWithName]() string { var e T return e.GetTypeName() } // EntityTypeNameForObj returns the name of the provided entity. -func EntityTypeNameForObj[T interface { - GetTypeName() string -}](obj T) string { +func EntityTypeNameForObj[T typeWithName](obj T) string { return obj.GetTypeName() } diff --git a/controller/konnect/index.go b/controller/konnect/index.go index 1a39c14eb..614bdeab4 100644 --- a/controller/konnect/index.go +++ b/controller/konnect/index.go @@ -2,11 +2,6 @@ package konnect import ( "sigs.k8s.io/controller-runtime/pkg/client" - - "github.com/kong/gateway-operator/controller/konnect/constraints" - - configurationv1 "github.com/kong/kubernetes-configuration/api/configuration/v1" - configurationv1alpha1 "github.com/kong/kubernetes-configuration/api/configuration/v1alpha1" ) // ReconciliationIndexOption contains required options of index for a kind of object required for reconciliation. @@ -15,26 +10,3 @@ type ReconciliationIndexOption struct { IndexField string ExtractValue client.IndexerFunc } - -// ReconciliationIndexOptionsForEntity returns required index options for controller reconciliing the entity. -func ReconciliationIndexOptionsForEntity[ - TEnt constraints.EntityType[T], - T constraints.SupportedKonnectEntityType, -]() []ReconciliationIndexOption { - var e TEnt - switch any(e).(type) { - case *configurationv1alpha1.KongPluginBinding: - return IndexOptionsForKongPluginBinding() - case *configurationv1alpha1.KongService: - return IndexOptionsForKongService() - case *configurationv1alpha1.KongRoute: - return IndexOptionsForKongRoute() - case *configurationv1alpha1.KongCredentialBasicAuth: - return IndexOptionsForCredentialsBasicAuth() - case *configurationv1.KongConsumer: - return IndexOptionsForKongConsumer() - case *configurationv1alpha1.KongSNI: - return IndexOptionsForKongSNI() - } - return nil -} diff --git a/controller/konnect/index_credentials_jwt.go b/controller/konnect/index_credentials_jwt.go new file mode 100644 index 000000000..d681236e3 --- /dev/null +++ b/controller/konnect/index_credentials_jwt.go @@ -0,0 +1,32 @@ +package konnect + +import ( + "sigs.k8s.io/controller-runtime/pkg/client" + + configurationv1alpha1 "github.com/kong/kubernetes-configuration/api/configuration/v1alpha1" +) + +const ( + // IndexFieldKongCredentialJWTReferencesKongConsumer is the index name for KongCredentialJWT -> Consumer. + IndexFieldKongCredentialJWTReferencesKongConsumer = "kongCredentialsJWTConsumerRef" +) + +// IndexOptionsForCredentialsJWT returns required Index options for KongCredentialJWT. +func IndexOptionsForCredentialsJWT() []ReconciliationIndexOption { + return []ReconciliationIndexOption{ + { + IndexObject: &configurationv1alpha1.KongCredentialJWT{}, + IndexField: IndexFieldKongCredentialJWTReferencesKongConsumer, + ExtractValue: kongCredentialJWTReferencesConsumer, + }, + } +} + +// kongCredentialJWTReferencesConsumer returns the name of referenced Consumer. +func kongCredentialJWTReferencesConsumer(obj client.Object) []string { + cred, ok := obj.(*configurationv1alpha1.KongCredentialJWT) + if !ok { + return nil + } + return []string{cred.Spec.ConsumerRef.Name} +} diff --git a/controller/konnect/ops/credentialjwt.go b/controller/konnect/ops/credentialjwt.go new file mode 100644 index 000000000..d7a0599d7 --- /dev/null +++ b/controller/konnect/ops/credentialjwt.go @@ -0,0 +1,14 @@ +package ops + +import ( + "context" + + sdkkonnectops "github.com/Kong/sdk-konnect-go/models/operations" +) + +// KongCredentialJWTSDK is the interface for the Konnect KongCredentialJWTSDK. +type KongCredentialJWTSDK interface { + CreateJwtWithConsumer(ctx context.Context, req sdkkonnectops.CreateJwtWithConsumerRequest, opts ...sdkkonnectops.Option) (*sdkkonnectops.CreateJwtWithConsumerResponse, error) + DeleteJwtWithConsumer(ctx context.Context, request sdkkonnectops.DeleteJwtWithConsumerRequest, opts ...sdkkonnectops.Option) (*sdkkonnectops.DeleteJwtWithConsumerResponse, error) + UpsertJwtWithConsumer(ctx context.Context, request sdkkonnectops.UpsertJwtWithConsumerRequest, opts ...sdkkonnectops.Option) (*sdkkonnectops.UpsertJwtWithConsumerResponse, error) +} diff --git a/controller/konnect/ops/credentialjwt_mock.go b/controller/konnect/ops/credentialjwt_mock.go new file mode 100644 index 000000000..acdb8776e --- /dev/null +++ b/controller/konnect/ops/credentialjwt_mock.go @@ -0,0 +1,259 @@ +// Code generated by mockery. DO NOT EDIT. + +package ops + +import ( + context "context" + + operations "github.com/Kong/sdk-konnect-go/models/operations" + mock "github.com/stretchr/testify/mock" +) + +// MockKongCredentialJWTSDK is an autogenerated mock type for the KongCredentialJWTSDK type +type MockKongCredentialJWTSDK struct { + mock.Mock +} + +type MockKongCredentialJWTSDK_Expecter struct { + mock *mock.Mock +} + +func (_m *MockKongCredentialJWTSDK) EXPECT() *MockKongCredentialJWTSDK_Expecter { + return &MockKongCredentialJWTSDK_Expecter{mock: &_m.Mock} +} + +// CreateJwtWithConsumer provides a mock function with given fields: ctx, req, opts +func (_m *MockKongCredentialJWTSDK) CreateJwtWithConsumer(ctx context.Context, req operations.CreateJwtWithConsumerRequest, opts ...operations.Option) (*operations.CreateJwtWithConsumerResponse, error) { + _va := make([]interface{}, len(opts)) + for _i := range opts { + _va[_i] = opts[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, req) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for CreateJwtWithConsumer") + } + + var r0 *operations.CreateJwtWithConsumerResponse + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, operations.CreateJwtWithConsumerRequest, ...operations.Option) (*operations.CreateJwtWithConsumerResponse, error)); ok { + return rf(ctx, req, opts...) + } + if rf, ok := ret.Get(0).(func(context.Context, operations.CreateJwtWithConsumerRequest, ...operations.Option) *operations.CreateJwtWithConsumerResponse); ok { + r0 = rf(ctx, req, opts...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*operations.CreateJwtWithConsumerResponse) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, operations.CreateJwtWithConsumerRequest, ...operations.Option) error); ok { + r1 = rf(ctx, req, opts...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockKongCredentialJWTSDK_CreateJwtWithConsumer_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateJwtWithConsumer' +type MockKongCredentialJWTSDK_CreateJwtWithConsumer_Call struct { + *mock.Call +} + +// CreateJwtWithConsumer is a helper method to define mock.On call +// - ctx context.Context +// - req operations.CreateJwtWithConsumerRequest +// - opts ...operations.Option +func (_e *MockKongCredentialJWTSDK_Expecter) CreateJwtWithConsumer(ctx interface{}, req interface{}, opts ...interface{}) *MockKongCredentialJWTSDK_CreateJwtWithConsumer_Call { + return &MockKongCredentialJWTSDK_CreateJwtWithConsumer_Call{Call: _e.mock.On("CreateJwtWithConsumer", + append([]interface{}{ctx, req}, opts...)...)} +} + +func (_c *MockKongCredentialJWTSDK_CreateJwtWithConsumer_Call) Run(run func(ctx context.Context, req operations.CreateJwtWithConsumerRequest, opts ...operations.Option)) *MockKongCredentialJWTSDK_CreateJwtWithConsumer_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]operations.Option, len(args)-2) + for i, a := range args[2:] { + if a != nil { + variadicArgs[i] = a.(operations.Option) + } + } + run(args[0].(context.Context), args[1].(operations.CreateJwtWithConsumerRequest), variadicArgs...) + }) + return _c +} + +func (_c *MockKongCredentialJWTSDK_CreateJwtWithConsumer_Call) Return(_a0 *operations.CreateJwtWithConsumerResponse, _a1 error) *MockKongCredentialJWTSDK_CreateJwtWithConsumer_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockKongCredentialJWTSDK_CreateJwtWithConsumer_Call) RunAndReturn(run func(context.Context, operations.CreateJwtWithConsumerRequest, ...operations.Option) (*operations.CreateJwtWithConsumerResponse, error)) *MockKongCredentialJWTSDK_CreateJwtWithConsumer_Call { + _c.Call.Return(run) + return _c +} + +// DeleteJwtWithConsumer provides a mock function with given fields: ctx, request, opts +func (_m *MockKongCredentialJWTSDK) DeleteJwtWithConsumer(ctx context.Context, request operations.DeleteJwtWithConsumerRequest, opts ...operations.Option) (*operations.DeleteJwtWithConsumerResponse, error) { + _va := make([]interface{}, len(opts)) + for _i := range opts { + _va[_i] = opts[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, request) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for DeleteJwtWithConsumer") + } + + var r0 *operations.DeleteJwtWithConsumerResponse + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, operations.DeleteJwtWithConsumerRequest, ...operations.Option) (*operations.DeleteJwtWithConsumerResponse, error)); ok { + return rf(ctx, request, opts...) + } + if rf, ok := ret.Get(0).(func(context.Context, operations.DeleteJwtWithConsumerRequest, ...operations.Option) *operations.DeleteJwtWithConsumerResponse); ok { + r0 = rf(ctx, request, opts...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*operations.DeleteJwtWithConsumerResponse) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, operations.DeleteJwtWithConsumerRequest, ...operations.Option) error); ok { + r1 = rf(ctx, request, opts...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockKongCredentialJWTSDK_DeleteJwtWithConsumer_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteJwtWithConsumer' +type MockKongCredentialJWTSDK_DeleteJwtWithConsumer_Call struct { + *mock.Call +} + +// DeleteJwtWithConsumer is a helper method to define mock.On call +// - ctx context.Context +// - request operations.DeleteJwtWithConsumerRequest +// - opts ...operations.Option +func (_e *MockKongCredentialJWTSDK_Expecter) DeleteJwtWithConsumer(ctx interface{}, request interface{}, opts ...interface{}) *MockKongCredentialJWTSDK_DeleteJwtWithConsumer_Call { + return &MockKongCredentialJWTSDK_DeleteJwtWithConsumer_Call{Call: _e.mock.On("DeleteJwtWithConsumer", + append([]interface{}{ctx, request}, opts...)...)} +} + +func (_c *MockKongCredentialJWTSDK_DeleteJwtWithConsumer_Call) Run(run func(ctx context.Context, request operations.DeleteJwtWithConsumerRequest, opts ...operations.Option)) *MockKongCredentialJWTSDK_DeleteJwtWithConsumer_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]operations.Option, len(args)-2) + for i, a := range args[2:] { + if a != nil { + variadicArgs[i] = a.(operations.Option) + } + } + run(args[0].(context.Context), args[1].(operations.DeleteJwtWithConsumerRequest), variadicArgs...) + }) + return _c +} + +func (_c *MockKongCredentialJWTSDK_DeleteJwtWithConsumer_Call) Return(_a0 *operations.DeleteJwtWithConsumerResponse, _a1 error) *MockKongCredentialJWTSDK_DeleteJwtWithConsumer_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockKongCredentialJWTSDK_DeleteJwtWithConsumer_Call) RunAndReturn(run func(context.Context, operations.DeleteJwtWithConsumerRequest, ...operations.Option) (*operations.DeleteJwtWithConsumerResponse, error)) *MockKongCredentialJWTSDK_DeleteJwtWithConsumer_Call { + _c.Call.Return(run) + return _c +} + +// UpsertJwtWithConsumer provides a mock function with given fields: ctx, request, opts +func (_m *MockKongCredentialJWTSDK) UpsertJwtWithConsumer(ctx context.Context, request operations.UpsertJwtWithConsumerRequest, opts ...operations.Option) (*operations.UpsertJwtWithConsumerResponse, error) { + _va := make([]interface{}, len(opts)) + for _i := range opts { + _va[_i] = opts[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, request) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for UpsertJwtWithConsumer") + } + + var r0 *operations.UpsertJwtWithConsumerResponse + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, operations.UpsertJwtWithConsumerRequest, ...operations.Option) (*operations.UpsertJwtWithConsumerResponse, error)); ok { + return rf(ctx, request, opts...) + } + if rf, ok := ret.Get(0).(func(context.Context, operations.UpsertJwtWithConsumerRequest, ...operations.Option) *operations.UpsertJwtWithConsumerResponse); ok { + r0 = rf(ctx, request, opts...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*operations.UpsertJwtWithConsumerResponse) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, operations.UpsertJwtWithConsumerRequest, ...operations.Option) error); ok { + r1 = rf(ctx, request, opts...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockKongCredentialJWTSDK_UpsertJwtWithConsumer_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpsertJwtWithConsumer' +type MockKongCredentialJWTSDK_UpsertJwtWithConsumer_Call struct { + *mock.Call +} + +// UpsertJwtWithConsumer is a helper method to define mock.On call +// - ctx context.Context +// - request operations.UpsertJwtWithConsumerRequest +// - opts ...operations.Option +func (_e *MockKongCredentialJWTSDK_Expecter) UpsertJwtWithConsumer(ctx interface{}, request interface{}, opts ...interface{}) *MockKongCredentialJWTSDK_UpsertJwtWithConsumer_Call { + return &MockKongCredentialJWTSDK_UpsertJwtWithConsumer_Call{Call: _e.mock.On("UpsertJwtWithConsumer", + append([]interface{}{ctx, request}, opts...)...)} +} + +func (_c *MockKongCredentialJWTSDK_UpsertJwtWithConsumer_Call) Run(run func(ctx context.Context, request operations.UpsertJwtWithConsumerRequest, opts ...operations.Option)) *MockKongCredentialJWTSDK_UpsertJwtWithConsumer_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]operations.Option, len(args)-2) + for i, a := range args[2:] { + if a != nil { + variadicArgs[i] = a.(operations.Option) + } + } + run(args[0].(context.Context), args[1].(operations.UpsertJwtWithConsumerRequest), variadicArgs...) + }) + return _c +} + +func (_c *MockKongCredentialJWTSDK_UpsertJwtWithConsumer_Call) Return(_a0 *operations.UpsertJwtWithConsumerResponse, _a1 error) *MockKongCredentialJWTSDK_UpsertJwtWithConsumer_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockKongCredentialJWTSDK_UpsertJwtWithConsumer_Call) RunAndReturn(run func(context.Context, operations.UpsertJwtWithConsumerRequest, ...operations.Option) (*operations.UpsertJwtWithConsumerResponse, error)) *MockKongCredentialJWTSDK_UpsertJwtWithConsumer_Call { + _c.Call.Return(run) + return _c +} + +// NewMockKongCredentialJWTSDK creates a new instance of MockKongCredentialJWTSDK. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockKongCredentialJWTSDK(t interface { + mock.TestingT + Cleanup(func()) +}) *MockKongCredentialJWTSDK { + mock := &MockKongCredentialJWTSDK{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/controller/konnect/ops/ops.go b/controller/konnect/ops/ops.go index a543dcf39..f6e6e3db3 100644 --- a/controller/konnect/ops/ops.go +++ b/controller/konnect/ops/ops.go @@ -72,6 +72,8 @@ func Create[ return e, createKongCredentialAPIKey(ctx, sdk.GetAPIKeyCredentialsSDK(), ent) case *configurationv1alpha1.KongCredentialACL: return e, createKongCredentialACL(ctx, sdk.GetACLCredentialsSDK(), ent) + case *configurationv1alpha1.KongCredentialJWT: + return e, createKongCredentialJWT(ctx, sdk.GetJWTCredentialsSDK(), ent) case *configurationv1alpha1.KongCACertificate: return e, createCACertificate(ctx, sdk.GetCACertificatesSDK(), ent) case *configurationv1alpha1.KongCertificate: @@ -132,6 +134,8 @@ func Delete[ return deleteKongCredentialAPIKey(ctx, sdk.GetAPIKeyCredentialsSDK(), ent) case *configurationv1alpha1.KongCredentialACL: return deleteKongCredentialACL(ctx, sdk.GetACLCredentialsSDK(), ent) + case *configurationv1alpha1.KongCredentialJWT: + return deleteKongCredentialJWT(ctx, sdk.GetJWTCredentialsSDK(), ent) case *configurationv1alpha1.KongCACertificate: return deleteCACertificate(ctx, sdk.GetCACertificatesSDK(), ent) case *configurationv1alpha1.KongCertificate: @@ -237,6 +241,8 @@ func Update[ return ctrl.Result{}, updateKongCredentialAPIKey(ctx, sdk.GetAPIKeyCredentialsSDK(), ent) case *configurationv1alpha1.KongCredentialACL: return ctrl.Result{}, updateKongCredentialACL(ctx, sdk.GetACLCredentialsSDK(), ent) + case *configurationv1alpha1.KongCredentialJWT: + return ctrl.Result{}, updateKongCredentialJWT(ctx, sdk.GetJWTCredentialsSDK(), ent) case *configurationv1alpha1.KongCACertificate: return ctrl.Result{}, updateCACertificate(ctx, sdk.GetCACertificatesSDK(), ent) case *configurationv1alpha1.KongCertificate: diff --git a/controller/konnect/ops/ops_credentialjwt.go b/controller/konnect/ops/ops_credentialjwt.go new file mode 100644 index 000000000..4685a1712 --- /dev/null +++ b/controller/konnect/ops/ops_credentialjwt.go @@ -0,0 +1,157 @@ +package ops + +import ( + "context" + "errors" + "fmt" + + sdkkonnectcomp "github.com/Kong/sdk-konnect-go/models/components" + sdkkonnectops "github.com/Kong/sdk-konnect-go/models/operations" + sdkkonnecterrs "github.com/Kong/sdk-konnect-go/models/sdkerrors" + "sigs.k8s.io/controller-runtime/pkg/client" + ctrllog "sigs.k8s.io/controller-runtime/pkg/log" + + configurationv1alpha1 "github.com/kong/kubernetes-configuration/api/configuration/v1alpha1" +) + +func createKongCredentialJWT( + ctx context.Context, + sdk KongCredentialJWTSDK, + cred *configurationv1alpha1.KongCredentialJWT, +) error { + cpID := cred.GetControlPlaneID() + if cpID == "" { + return fmt.Errorf("can't create %T %s without a Konnect ControlPlane ID", cred, client.ObjectKeyFromObject(cred)) + } + + resp, err := sdk.CreateJwtWithConsumer(ctx, + sdkkonnectops.CreateJwtWithConsumerRequest{ + ControlPlaneID: cpID, + ConsumerIDForNestedEntities: cred.Status.Konnect.GetConsumerID(), + JWTWithoutParents: kongCredentialJWTToJWTWithoutParents(cred), + }, + ) + + // TODO: handle already exists + // Can't adopt it as it will cause conflicts between the controller + // that created that entity and already manages it, hm + if errWrap := wrapErrIfKonnectOpFailed(err, CreateOp, cred); errWrap != nil { + SetKonnectEntityProgrammedConditionFalse(cred, "FailedToCreate", errWrap.Error()) + return errWrap + } + + cred.Status.Konnect.SetKonnectID(*resp.Jwt.ID) + SetKonnectEntityProgrammedCondition(cred) + + return nil +} + +// updateKongCredentialJWT updates the Konnect JWT entity. +// It is assumed that the provided JWT has Konnect ID set in status. +// It returns an error if the JWT does not have a ControlPlaneRef or +// if the operation fails. +func updateKongCredentialJWT( + ctx context.Context, + sdk KongCredentialJWTSDK, + cred *configurationv1alpha1.KongCredentialJWT, +) error { + cpID := cred.GetControlPlaneID() + if cpID == "" { + return fmt.Errorf("can't update %T %s without a Konnect ControlPlane ID", cred, client.ObjectKeyFromObject(cred)) + } + + _, err := sdk.UpsertJwtWithConsumer(ctx, + sdkkonnectops.UpsertJwtWithConsumerRequest{ + ControlPlaneID: cpID, + ConsumerIDForNestedEntities: cred.Status.Konnect.GetConsumerID(), + JWTID: cred.GetKonnectStatus().GetKonnectID(), + JWTWithoutParents: kongCredentialJWTToJWTWithoutParents(cred), + }) + + // TODO: handle already exists + // Can't adopt it as it will cause conflicts between the controller + // that created that entity and already manages it, hm + if errWrap := wrapErrIfKonnectOpFailed(err, UpdateOp, cred); errWrap != nil { + // JWT update operation returns an SDKError instead of a NotFoundError. + var sdkError *sdkkonnecterrs.SDKError + if errors.As(errWrap, &sdkError) { + switch sdkError.StatusCode { + case 404: + if err := createKongCredentialJWT(ctx, sdk, cred); err != nil { + return FailedKonnectOpError[configurationv1alpha1.KongCredentialJWT]{ + Op: UpdateOp, + Err: err, + } + } + return nil + default: + return FailedKonnectOpError[configurationv1alpha1.KongCredentialJWT]{ + Op: UpdateOp, + Err: sdkError, + } + + } + } + + SetKonnectEntityProgrammedConditionFalse(cred, "FailedToUpdate", errWrap.Error()) + return errWrap + } + + SetKonnectEntityProgrammedCondition(cred) + + return nil +} + +// deleteKongCredentialJWT deletes an JWT credential in Konnect. +// It is assumed that the provided JWT has Konnect ID set in status. +// It returns an error if the operation fails. +func deleteKongCredentialJWT( + ctx context.Context, + sdk KongCredentialJWTSDK, + cred *configurationv1alpha1.KongCredentialJWT, +) error { + cpID := cred.GetControlPlaneID() + id := cred.GetKonnectStatus().GetKonnectID() + _, err := sdk.DeleteJwtWithConsumer(ctx, + sdkkonnectops.DeleteJwtWithConsumerRequest{ + ControlPlaneID: cpID, + ConsumerIDForNestedEntities: cred.Status.Konnect.GetConsumerID(), + JWTID: id, + }) + if errWrap := wrapErrIfKonnectOpFailed(err, DeleteOp, cred); errWrap != nil { + // Service delete operation returns an SDKError instead of a NotFoundError. + var sdkError *sdkkonnecterrs.SDKError + if errors.As(errWrap, &sdkError) { + if sdkError.StatusCode == 404 { + ctrllog.FromContext(ctx). + Info("entity not found in Konnect, skipping delete", + "op", DeleteOp, "type", cred.GetTypeName(), "id", id, + ) + return nil + } + return FailedKonnectOpError[configurationv1alpha1.KongCredentialJWT]{ + Op: DeleteOp, + Err: sdkError, + } + } + return FailedKonnectOpError[configurationv1alpha1.KongService]{ + Op: DeleteOp, + Err: errWrap, + } + } + + return nil +} + +func kongCredentialJWTToJWTWithoutParents( + cred *configurationv1alpha1.KongCredentialJWT, +) sdkkonnectcomp.JWTWithoutParents { + ret := sdkkonnectcomp.JWTWithoutParents{ + Key: cred.Spec.Key, + Algorithm: (*sdkkonnectcomp.JWTWithoutParentsAlgorithm)(&cred.Spec.Algorithm), + RsaPublicKey: cred.Spec.RSAPublicKey, + Secret: cred.Spec.Secret, + Tags: GenerateTagsForObject(cred, cred.Spec.Tags...), + } + return ret +} diff --git a/controller/konnect/ops/sdkfactory.go b/controller/konnect/ops/sdkfactory.go index 5a559228a..33594fae2 100644 --- a/controller/konnect/ops/sdkfactory.go +++ b/controller/konnect/ops/sdkfactory.go @@ -20,6 +20,7 @@ type SDKWrapper interface { GetBasicAuthCredentialsSDK() KongCredentialBasicAuthSDK GetAPIKeyCredentialsSDK() KongCredentialAPIKeySDK GetACLCredentialsSDK() KongCredentialACLSDK + GetJWTCredentialsSDK() KongCredentialJWTSDK GetCACertificatesSDK() CACertificatesSDK GetCertificatesSDK() CertificatesSDK GetKeysSDK() KeysSDK @@ -113,6 +114,11 @@ func (w sdkWrapper) GetACLCredentialsSDK() KongCredentialACLSDK { return w.sdk.ACLs } +// GetJWTCredentialsSDK returns the JWTCredentials SDK to get current organization. +func (w sdkWrapper) GetJWTCredentialsSDK() KongCredentialJWTSDK { + return w.sdk.JWTs +} + // GetKeysSDK returns the SDK to operate keys. func (w sdkWrapper) GetKeysSDK() KeysSDK { return w.sdk.Keys diff --git a/controller/konnect/ops/sdkfactory_mock.go b/controller/konnect/ops/sdkfactory_mock.go index e618b3b40..873478b5c 100644 --- a/controller/konnect/ops/sdkfactory_mock.go +++ b/controller/konnect/ops/sdkfactory_mock.go @@ -19,6 +19,7 @@ type MockSDKWrapper struct { KongCredentialsBasicAuthSDK *MockKongCredentialBasicAuthSDK KongCredentialsAPIKeySDK *MockKongCredentialAPIKeySDK KongCredentialsACLSDK *MockKongCredentialACLSDK + KongCredentialsJWTSDK *MockKongCredentialJWTSDK CACertificatesSDK *MockCACertificatesSDK CertificatesSDK *MockCertificatesSDK VaultSDK *MockVaultSDK @@ -43,6 +44,7 @@ func NewMockSDKWrapperWithT(t *testing.T) *MockSDKWrapper { KongCredentialsBasicAuthSDK: NewMockKongCredentialBasicAuthSDK(t), KongCredentialsAPIKeySDK: NewMockKongCredentialAPIKeySDK(t), KongCredentialsACLSDK: NewMockKongCredentialACLSDK(t), + KongCredentialsJWTSDK: NewMockKongCredentialJWTSDK(t), CACertificatesSDK: NewMockCACertificatesSDK(t), CertificatesSDK: NewMockCertificatesSDK(t), VaultSDK: NewMockVaultSDK(t), @@ -92,6 +94,10 @@ func (m MockSDKWrapper) GetACLCredentialsSDK() KongCredentialACLSDK { return m.KongCredentialsACLSDK } +func (m MockSDKWrapper) GetJWTCredentialsSDK() KongCredentialJWTSDK { + return m.KongCredentialsJWTSDK +} + func (m MockSDKWrapper) GetTargetsSDK() TargetsSDK { return m.TargetsSDK } diff --git a/controller/konnect/reconciler_generic.go b/controller/konnect/reconciler_generic.go index 18f5a50e1..ec1493ba7 100644 --- a/controller/konnect/reconciler_generic.go +++ b/controller/konnect/reconciler_generic.go @@ -718,6 +718,8 @@ func getConsumerRef[T constraints.SupportedKonnectEntityType, TEnt constraints.E return mo.Some(e.Spec.ConsumerRef) case *configurationv1alpha1.KongCredentialACL: return mo.Some(e.Spec.ConsumerRef) + case *configurationv1alpha1.KongCredentialJWT: + return mo.Some(e.Spec.ConsumerRef) default: return mo.None[corev1.LocalObjectReference]() } @@ -814,6 +816,12 @@ func handleKongConsumerRef[T constraints.SupportedKonnectEntityType, TEnt constr } cred.Status.Konnect.ConsumerID = consumer.Status.Konnect.GetKonnectID() } + if cred, ok := any(ent).(*configurationv1alpha1.KongCredentialJWT); ok { + if cred.Status.Konnect == nil { + cred.Status.Konnect = &konnectv1alpha1.KonnectEntityStatusWithControlPlaneAndConsumerRefs{} + } + cred.Status.Konnect.ConsumerID = consumer.Status.Konnect.GetKonnectID() + } if res, errStatus := updateStatusWithCondition( ctx, cl, ent, diff --git a/controller/konnect/watch.go b/controller/konnect/watch.go index 33c20ee59..32f248007 100644 --- a/controller/konnect/watch.go +++ b/controller/konnect/watch.go @@ -48,6 +48,8 @@ func ReconciliationWatchOptionsForEntity[ return kongCredentialAPIKeyReconciliationWatchOptions(cl) case *configurationv1alpha1.KongCredentialACL: return kongCredentialACLReconciliationWatchOptions(cl) + case *configurationv1alpha1.KongCredentialJWT: + return kongCredentialJWTReconciliationWatchOptions(cl) case *configurationv1alpha1.KongCACertificate: return KongCACertificateReconciliationWatchOptions(cl) case *configurationv1alpha1.KongCertificate: diff --git a/controller/konnect/watch_credential.go b/controller/konnect/watch_credential.go new file mode 100644 index 000000000..4132d375f --- /dev/null +++ b/controller/konnect/watch_credential.go @@ -0,0 +1,67 @@ +package konnect + +import ( + "context" + "reflect" + + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + ctrllog "sigs.k8s.io/controller-runtime/pkg/log" + + "github.com/kong/gateway-operator/controller/konnect/constraints" + operatorerrors "github.com/kong/gateway-operator/internal/errors" + + configurationv1 "github.com/kong/kubernetes-configuration/api/configuration/v1" + configurationv1alpha1 "github.com/kong/kubernetes-configuration/api/configuration/v1alpha1" +) + +// kongCredentialRefersToKonnectGatewayControlPlane returns a predicate function that +// returns true if the KongCredential refers to a KongConsumer which uses +// KonnectGatewayControlPlane reference. +func kongCredentialRefersToKonnectGatewayControlPlane[ + T interface { + *configurationv1alpha1.KongCredentialACL | + *configurationv1alpha1.KongCredentialAPIKey | + *configurationv1alpha1.KongCredentialBasicAuth | + *configurationv1alpha1.KongCredentialJWT + // TODO add support for HMAC Auth https://github.com/Kong/gateway-operator/issues/621 + + GetTypeName() string + GetNamespace() string + }, +](cl client.Client) func(obj client.Object) bool { + return func(obj client.Object) bool { + credential, ok := obj.(T) + if !ok { + ctrllog.FromContext(context.Background()).Error( + operatorerrors.ErrUnexpectedObject, + "failed to run predicate function", + "expected", constraints.EntityTypeName[T](), "found", reflect.TypeOf(obj), + ) + return false + } + + var consumerRefName string + switch credential := any(credential).(type) { + case *configurationv1alpha1.KongCredentialACL: + consumerRefName = credential.Spec.ConsumerRef.Name + case *configurationv1alpha1.KongCredentialAPIKey: + consumerRefName = credential.Spec.ConsumerRef.Name + case *configurationv1alpha1.KongCredentialBasicAuth: + consumerRefName = credential.Spec.ConsumerRef.Name + case *configurationv1alpha1.KongCredentialJWT: + consumerRefName = credential.Spec.ConsumerRef.Name + } + + nn := types.NamespacedName{ + Namespace: credential.GetNamespace(), + Name: consumerRefName, + } + var consumer configurationv1.KongConsumer + if err := cl.Get(context.Background(), nn, &consumer); client.IgnoreNotFound(err) != nil { + return true + } + + return objHasControlPlaneRefKonnectNamespacedRef(&consumer) + } +} diff --git a/controller/konnect/watch_credentialacl.go b/controller/konnect/watch_credentialacl.go index a8d8a3d33..c60b27fc6 100644 --- a/controller/konnect/watch_credentialacl.go +++ b/controller/konnect/watch_credentialacl.go @@ -2,7 +2,6 @@ package konnect import ( "context" - "reflect" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" @@ -13,8 +12,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/predicate" "sigs.k8s.io/controller-runtime/pkg/reconcile" - operatorerrors "github.com/kong/gateway-operator/internal/errors" - configurationv1 "github.com/kong/kubernetes-configuration/api/configuration/v1" configurationv1alpha1 "github.com/kong/kubernetes-configuration/api/configuration/v1alpha1" konnectv1alpha1 "github.com/kong/kubernetes-configuration/api/konnect/v1alpha1" @@ -37,7 +34,9 @@ func kongCredentialACLReconciliationWatchOptions( func(b *ctrl.Builder) *ctrl.Builder { return b.For(&configurationv1alpha1.KongCredentialACL{}, builder.WithPredicates( - predicate.NewPredicateFuncs(kongCredentialACLRefersToKonnectGatewayControlPlane(cl)), + predicate.NewPredicateFuncs( + kongCredentialRefersToKonnectGatewayControlPlane[*configurationv1alpha1.KongCredentialACL](cl), + ), ), ) }, @@ -68,35 +67,6 @@ func kongCredentialACLReconciliationWatchOptions( } } -// kongCredentialACLRefersToKonnectGatewayControlPlane returns true if the KongCredentialACL -// refers to a KonnectGatewayControlPlane. -func kongCredentialACLRefersToKonnectGatewayControlPlane(cl client.Client) func(obj client.Object) bool { - return func(obj client.Object) bool { - KongCredentialACL, ok := obj.(*configurationv1alpha1.KongCredentialACL) - if !ok { - ctrllog.FromContext(context.Background()).Error( - operatorerrors.ErrUnexpectedObject, - "failed to run predicate function", - "expected", "KongCredentialACL", "found", reflect.TypeOf(obj), - ) - return false - } - - consumerRef := KongCredentialACL.Spec.ConsumerRef - nn := types.NamespacedName{ - Namespace: KongCredentialACL.Namespace, - Name: consumerRef.Name, - } - consumer := configurationv1.KongConsumer{} - if err := cl.Get(context.Background(), nn, &consumer); client.IgnoreNotFound(err) != nil { - return true - } - - cpRef := consumer.Spec.ControlPlaneRef - return cpRef != nil && cpRef.Type == configurationv1alpha1.ControlPlaneRefKonnectNamespacedRef - } -} - func kongCredentialACLForKonnectAPIAuthConfiguration( cl client.Client, ) func(ctx context.Context, obj client.Object) []reconcile.Request { diff --git a/controller/konnect/watch_credentialapikey.go b/controller/konnect/watch_credentialapikey.go index 32694e446..975d77828 100644 --- a/controller/konnect/watch_credentialapikey.go +++ b/controller/konnect/watch_credentialapikey.go @@ -2,7 +2,6 @@ package konnect import ( "context" - "reflect" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" @@ -13,8 +12,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/predicate" "sigs.k8s.io/controller-runtime/pkg/reconcile" - operatorerrors "github.com/kong/gateway-operator/internal/errors" - configurationv1 "github.com/kong/kubernetes-configuration/api/configuration/v1" configurationv1alpha1 "github.com/kong/kubernetes-configuration/api/configuration/v1alpha1" konnectv1alpha1 "github.com/kong/kubernetes-configuration/api/konnect/v1alpha1" @@ -37,7 +34,9 @@ func kongCredentialAPIKeyReconciliationWatchOptions( func(b *ctrl.Builder) *ctrl.Builder { return b.For(&configurationv1alpha1.KongCredentialAPIKey{}, builder.WithPredicates( - predicate.NewPredicateFuncs(kongCredentialAPIKeyRefersToKonnectGatewayControlPlane(cl)), + predicate.NewPredicateFuncs( + kongCredentialRefersToKonnectGatewayControlPlane[*configurationv1alpha1.KongCredentialAPIKey](cl), + ), ), ) }, @@ -68,34 +67,6 @@ func kongCredentialAPIKeyReconciliationWatchOptions( } } -// kongCredentialAPIKeyRefersToKonnectGatewayControlPlane returns true if the KongCredentialAPIKey -// refers to a KonnectGatewayControlPlane. -func kongCredentialAPIKeyRefersToKonnectGatewayControlPlane(cl client.Client) func(obj client.Object) bool { - return func(obj client.Object) bool { - KongCredentialAPIKey, ok := obj.(*configurationv1alpha1.KongCredentialAPIKey) - if !ok { - ctrllog.FromContext(context.Background()).Error( - operatorerrors.ErrUnexpectedObject, - "failed to run predicate function", - "expected", "KongCredentialAPIKey", "found", reflect.TypeOf(obj), - ) - return false - } - - consumerRef := KongCredentialAPIKey.Spec.ConsumerRef - nn := types.NamespacedName{ - Namespace: KongCredentialAPIKey.Namespace, - Name: consumerRef.Name, - } - consumer := configurationv1.KongConsumer{} - if err := cl.Get(context.Background(), nn, &consumer); client.IgnoreNotFound(err) != nil { - return true - } - - return objHasControlPlaneRefKonnectNamespacedRef(&consumer) - } -} - func kongCredentialAPIKeyForKonnectAPIAuthConfiguration( cl client.Client, ) func(ctx context.Context, obj client.Object) []reconcile.Request { diff --git a/controller/konnect/watch_credentialbasicauth.go b/controller/konnect/watch_credentialbasicauth.go index 3e1736789..4cbb93598 100644 --- a/controller/konnect/watch_credentialbasicauth.go +++ b/controller/konnect/watch_credentialbasicauth.go @@ -2,7 +2,6 @@ package konnect import ( "context" - "reflect" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" @@ -13,8 +12,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/predicate" "sigs.k8s.io/controller-runtime/pkg/reconcile" - operatorerrors "github.com/kong/gateway-operator/internal/errors" - configurationv1 "github.com/kong/kubernetes-configuration/api/configuration/v1" configurationv1alpha1 "github.com/kong/kubernetes-configuration/api/configuration/v1alpha1" konnectv1alpha1 "github.com/kong/kubernetes-configuration/api/konnect/v1alpha1" @@ -37,7 +34,9 @@ func kongCredentialBasicAuthReconciliationWatchOptions( func(b *ctrl.Builder) *ctrl.Builder { return b.For(&configurationv1alpha1.KongCredentialBasicAuth{}, builder.WithPredicates( - predicate.NewPredicateFuncs(kongCredentialBasicAuthRefersToKonnectGatewayControlPlane(cl)), + predicate.NewPredicateFuncs( + kongCredentialRefersToKonnectGatewayControlPlane[*configurationv1alpha1.KongCredentialBasicAuth](cl), + ), ), ) }, @@ -68,34 +67,6 @@ func kongCredentialBasicAuthReconciliationWatchOptions( } } -// kongCredentialBasicAuthRefersToKonnectGatewayControlPlane returns true if the KongCredentialBasicAuth -// refers to a KonnectGatewayControlPlane. -func kongCredentialBasicAuthRefersToKonnectGatewayControlPlane(cl client.Client) func(obj client.Object) bool { - return func(obj client.Object) bool { - KongCredentialBasicAuth, ok := obj.(*configurationv1alpha1.KongCredentialBasicAuth) - if !ok { - ctrllog.FromContext(context.Background()).Error( - operatorerrors.ErrUnexpectedObject, - "failed to run predicate function", - "expected", "KongCredentialBasicAuth", "found", reflect.TypeOf(obj), - ) - return false - } - - consumerRef := KongCredentialBasicAuth.Spec.ConsumerRef - nn := types.NamespacedName{ - Namespace: KongCredentialBasicAuth.Namespace, - Name: consumerRef.Name, - } - consumer := configurationv1.KongConsumer{} - if err := cl.Get(context.Background(), nn, &consumer); client.IgnoreNotFound(err) != nil { - return true - } - - return objHasControlPlaneRefKonnectNamespacedRef(&consumer) - } -} - func kongCredentialBasicAuthForKonnectAPIAuthConfiguration( cl client.Client, ) func(ctx context.Context, obj client.Object) []reconcile.Request { diff --git a/controller/konnect/watch_credentialjwt.go b/controller/konnect/watch_credentialjwt.go new file mode 100644 index 000000000..1fdc314f9 --- /dev/null +++ b/controller/konnect/watch_credentialjwt.go @@ -0,0 +1,220 @@ +package konnect + +import ( + "context" + + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" + ctrllog "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + configurationv1 "github.com/kong/kubernetes-configuration/api/configuration/v1" + configurationv1alpha1 "github.com/kong/kubernetes-configuration/api/configuration/v1alpha1" + konnectv1alpha1 "github.com/kong/kubernetes-configuration/api/konnect/v1alpha1" +) + +// TODO(pmalek): this can be extracted and used in reconciler.go +// as every Konnect entity will have a reference to the KonnectAPIAuthConfiguration. +// This would require: +// - mapping function from non List types to List types +// - a function on each Konnect entity type to get the API Auth configuration +// reference from the object +// - lists have their items stored in Items field, not returned via a method + +// kongCredentialJWTReconciliationWatchOptions returns the watch options for +// the KongCredentialJWT resource. +func kongCredentialJWTReconciliationWatchOptions( + cl client.Client, +) []func(*ctrl.Builder) *ctrl.Builder { + return []func(*ctrl.Builder) *ctrl.Builder{ + func(b *ctrl.Builder) *ctrl.Builder { + return b.For(&configurationv1alpha1.KongCredentialJWT{}, + builder.WithPredicates( + predicate.NewPredicateFuncs( + kongCredentialRefersToKonnectGatewayControlPlane[*configurationv1alpha1.KongCredentialJWT](cl), + ), + ), + ) + }, + func(b *ctrl.Builder) *ctrl.Builder { + return b.Watches( + &configurationv1.KongConsumer{}, + handler.EnqueueRequestsFromMapFunc( + kongCredentialJWTForKongConsumer(cl), + ), + ) + }, + func(b *ctrl.Builder) *ctrl.Builder { + return b.Watches( + &konnectv1alpha1.KonnectAPIAuthConfiguration{}, + handler.EnqueueRequestsFromMapFunc( + kongCredentialJWTForKonnectAPIAuthConfiguration(cl), + ), + ) + }, + func(b *ctrl.Builder) *ctrl.Builder { + return b.Watches( + &konnectv1alpha1.KonnectGatewayControlPlane{}, + handler.EnqueueRequestsFromMapFunc( + kongCredentialJWTForKonnectGatewayControlPlane(cl), + ), + ) + }, + } +} + +func kongCredentialJWTForKonnectAPIAuthConfiguration( + cl client.Client, +) func(ctx context.Context, obj client.Object) []reconcile.Request { + return func(ctx context.Context, obj client.Object) []reconcile.Request { + auth, ok := obj.(*konnectv1alpha1.KonnectAPIAuthConfiguration) + if !ok { + return nil + } + + var l configurationv1.KongConsumerList + if err := cl.List(ctx, &l, + // TODO: change this when cross namespace refs are allowed. + client.InNamespace(auth.GetNamespace()), + ); err != nil { + return nil + } + + var ret []reconcile.Request + for _, consumer := range l.Items { + cpRef := consumer.Spec.ControlPlaneRef + if cpRef == nil || + cpRef.Type != configurationv1alpha1.ControlPlaneRefKonnectNamespacedRef || + cpRef.KonnectNamespacedRef == nil || + cpRef.KonnectNamespacedRef.Name != auth.GetName() { + continue + } + + cpNN := types.NamespacedName{ + Name: cpRef.KonnectNamespacedRef.Name, + Namespace: consumer.Namespace, + } + var cp konnectv1alpha1.KonnectGatewayControlPlane + if err := cl.Get(ctx, cpNN, &cp); err != nil { + ctrllog.FromContext(ctx).Error( + err, + "failed to get KonnectGatewayControlPlane", + "KonnectGatewayControlPlane", cpNN, + ) + continue + } + + // TODO: change this when cross namespace refs are allowed. + if cp.GetKonnectAPIAuthConfigurationRef().Name != auth.Name { + continue + } + + var credList configurationv1alpha1.KongCredentialJWTList + if err := cl.List(ctx, &credList, + client.MatchingFields{ + IndexFieldKongCredentialJWTReferencesKongConsumer: consumer.Name, + }, + client.InNamespace(auth.GetNamespace()), + ); err != nil { + return nil + } + + for _, cred := range credList.Items { + ret = append(ret, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Namespace: cred.Namespace, + Name: cred.Name, + }, + }, + ) + } + } + return ret + } +} + +func kongCredentialJWTForKonnectGatewayControlPlane( + cl client.Client, +) func(ctx context.Context, obj client.Object) []reconcile.Request { + return func(ctx context.Context, obj client.Object) []reconcile.Request { + cp, ok := obj.(*konnectv1alpha1.KonnectGatewayControlPlane) + if !ok { + return nil + } + var l configurationv1.KongConsumerList + if err := cl.List(ctx, &l, + // TODO: change this when cross namespace refs are allowed. + client.InNamespace(cp.GetNamespace()), + ); err != nil { + return nil + } + + var ret []reconcile.Request + for _, consumer := range l.Items { + cpRef := consumer.Spec.ControlPlaneRef + if cpRef.Type != configurationv1alpha1.ControlPlaneRefKonnectNamespacedRef || + cpRef.KonnectNamespacedRef == nil || + cpRef.KonnectNamespacedRef.Name != cp.GetName() { + continue + } + + var credList configurationv1alpha1.KongCredentialJWTList + if err := cl.List(ctx, &credList, + client.MatchingFields{ + IndexFieldKongCredentialJWTReferencesKongConsumer: consumer.Name, + }, + client.InNamespace(cp.GetNamespace()), + ); err != nil { + return nil + } + + for _, cred := range credList.Items { + ret = append(ret, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Namespace: cred.Namespace, + Name: cred.Name, + }, + }, + ) + } + } + return ret + } +} + +func kongCredentialJWTForKongConsumer( + cl client.Client, +) func(ctx context.Context, obj client.Object) []reconcile.Request { + return func(ctx context.Context, obj client.Object) []reconcile.Request { + consumer, ok := obj.(*configurationv1.KongConsumer) + if !ok { + return nil + } + var l configurationv1alpha1.KongCredentialJWTList + if err := cl.List(ctx, &l, + client.MatchingFields{ + IndexFieldKongCredentialJWTReferencesKongConsumer: consumer.Name, + }, + // TODO: change this when cross namespace refs are allowed. + client.InNamespace(consumer.GetNamespace()), + ); err != nil { + return nil + } + + var ret []reconcile.Request + for _, cred := range l.Items { + ret = append(ret, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Namespace: cred.Namespace, + Name: cred.Name, + }, + }, + ) + } + return ret + } +} diff --git a/go.mod b/go.mod index 29b9aff41..133e75d03 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,7 @@ require ( github.com/go-logr/logr v1.4.2 github.com/google/go-containerregistry v0.20.2 github.com/google/uuid v1.6.0 - github.com/kong/kubernetes-configuration v0.0.23 + github.com/kong/kubernetes-configuration v0.0.24 github.com/kong/kubernetes-telemetry v0.1.5 github.com/kong/kubernetes-testing-framework v0.47.2 github.com/kong/semver/v4 v4.0.1 diff --git a/go.sum b/go.sum index 161058c3e..0e65a6e12 100644 --- a/go.sum +++ b/go.sum @@ -224,8 +224,12 @@ github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2 github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/kong/go-kong v0.59.1 h1:AJZtyCD+Zyqe/mF/m+x3/qN/GPVxAH7jq9zGJTHRfjc= github.com/kong/go-kong v0.59.1/go.mod h1:8Vt6HmtgLNgL/7bSwAlz3DIWqBtzG7qEt9+OnMiQOa0= -github.com/kong/kubernetes-configuration v0.0.23 h1:rE18l+BN5443MnOGUmsThgXWU+M4yWHdwsMTr5f5n9o= -github.com/kong/kubernetes-configuration v0.0.23/go.mod h1:DXrWtdZzewUyPZBR4zvDoY/B8rHxeqcqCBDbyHA+B0Q= +github.com/kong/kubernetes-configuration v0.0.24-0.20241001115459-c352e5229f8c h1:LLU0dCeln9PLdMkt6yTnx2Q9AZ62f7aas7LjyneIfrc= +github.com/kong/kubernetes-configuration v0.0.24-0.20241001115459-c352e5229f8c/go.mod h1:DXrWtdZzewUyPZBR4zvDoY/B8rHxeqcqCBDbyHA+B0Q= +github.com/kong/kubernetes-configuration v0.0.24-0.20241001135007-d03056a341ef h1:xrpbHFxoaHjeXmCS/9XnTzXZBN9nfuc9jLpOFEZB1nQ= +github.com/kong/kubernetes-configuration v0.0.24-0.20241001135007-d03056a341ef/go.mod h1:DXrWtdZzewUyPZBR4zvDoY/B8rHxeqcqCBDbyHA+B0Q= +github.com/kong/kubernetes-configuration v0.0.24 h1:XoZcJRgdPnzCDtmQ0y0PWVOkJoLAqkB0ODLEdhuc9og= +github.com/kong/kubernetes-configuration v0.0.24/go.mod h1:DXrWtdZzewUyPZBR4zvDoY/B8rHxeqcqCBDbyHA+B0Q= github.com/kong/kubernetes-telemetry v0.1.5 h1:xHwU1q0IvfEYqpj03po73ZKbVarnFPUwzkoFkdVnr9w= github.com/kong/kubernetes-telemetry v0.1.5/go.mod h1:1UXyZ6N3e8Fl6YguToQ6tKNveonkhjSqxzY7HVW+Ba4= github.com/kong/kubernetes-testing-framework v0.47.2 h1:+2Z9anTpbV/hwNeN+NFQz53BMU+g3QJydkweBp3tULo= diff --git a/modules/manager/controller_setup.go b/modules/manager/controller_setup.go index f14ba58dc..a53a0b767 100644 --- a/modules/manager/controller_setup.go +++ b/modules/manager/controller_setup.go @@ -90,6 +90,8 @@ const ( KongCredentialAPIKeyControllerName = "KongCredentialAPIKey" //nolint:gosec // KongCredentialACLControllerName is the name of the KongCredentialACL controller. KongCredentialACLControllerName = "KongCredentialACL" //nolint:gosec + // KongCredentialJWTControllerName is the name of the KongCredentialJWT controller. + KongCredentialJWTControllerName = "KongCredentialJWT" //nolint:gosec // KongCACertificateControllerName is the name of the KongCACertificate controller. KongCACertificateControllerName = "KongCACertificate" // KongCertificateControllerName is the name of the KongCertificate controller. @@ -484,6 +486,15 @@ func SetupControllers(mgr manager.Manager, c *Config) (map[string]ControllerDef, konnect.WithKonnectEntitySyncPeriod[configurationv1alpha1.KongCredentialACL](c.KonnectSyncPeriod), ), }, + KongCredentialJWTControllerName: { + Enabled: c.KonnectControllersEnabled, + Controller: konnect.NewKonnectEntityReconciler( + sdkFactory, + c.DevelopmentMode, + mgr.GetClient(), + konnect.WithKonnectEntitySyncPeriod[configurationv1alpha1.KongCredentialJWT](c.KonnectSyncPeriod), + ), + }, KongKeyControllerName: { Enabled: c.KonnectControllersEnabled, Controller: konnect.NewKonnectEntityReconciler( @@ -554,46 +565,62 @@ func SetupControllers(mgr manager.Manager, c *Config) (map[string]ControllerDef, // This is done only once because 1 manager's cache can only have one index with // a predefined key and so that different controllers can share the same indices. func SetupCacheIndicesForKonnectTypes(ctx context.Context, mgr manager.Manager, developmentMode bool) error { - if err := setupCacheIndicesForKonnectType[configurationv1alpha1.KongPluginBinding](ctx, mgr, developmentMode); err != nil { - return err - } - if err := setupCacheIndicesForKonnectType[configurationv1alpha1.KongCredentialBasicAuth](ctx, mgr, developmentMode); err != nil { - return err - } - if err := setupCacheIndicesForKonnectType[configurationv1.KongConsumer](ctx, mgr, developmentMode); err != nil { - return err - } - if err := setupCacheIndicesForKonnectType[configurationv1alpha1.KongService](ctx, mgr, developmentMode); err != nil { - return err - } - if err := setupCacheIndicesForKonnectType[configurationv1alpha1.KongRoute](ctx, mgr, developmentMode); err != nil { - return err - } - if err := setupCacheIndicesForKonnectType[configurationv1alpha1.KongSNI](ctx, mgr, developmentMode); err != nil { - return err + types := []struct { + Object interface { + client.Object + GetTypeName() string + } + IndexOptions []konnect.ReconciliationIndexOption + }{ + { + Object: &configurationv1alpha1.KongPluginBinding{}, + IndexOptions: konnect.IndexOptionsForKongPluginBinding(), + }, + { + Object: &configurationv1alpha1.KongCredentialBasicAuth{}, + IndexOptions: konnect.IndexOptionsForCredentialsBasicAuth(), + }, + { + Object: &configurationv1alpha1.KongCredentialACL{}, + IndexOptions: konnect.IndexOptionsForCredentialsACL(), + }, + { + Object: &configurationv1alpha1.KongCredentialJWT{}, + IndexOptions: konnect.IndexOptionsForCredentialsJWT(), + }, + { + Object: &configurationv1.KongConsumer{}, + IndexOptions: konnect.IndexOptionsForKongConsumer(), + }, + { + Object: &configurationv1alpha1.KongService{}, + IndexOptions: konnect.IndexOptionsForKongService(), + }, + { + Object: &configurationv1alpha1.KongRoute{}, + IndexOptions: konnect.IndexOptionsForKongRoute(), + }, + { + Object: &configurationv1alpha1.KongSNI{}, + IndexOptions: konnect.IndexOptionsForKongSNI(), + }, } - return nil -} -func setupCacheIndicesForKonnectType[ - T constraints.SupportedKonnectEntityType, - TEnt constraints.EntityType[T], -](ctx context.Context, mgr manager.Manager, developmentMode bool) error { - var ( - entityTypeName = constraints.EntityTypeName[T]() - logger = log.GetLogger(ctx, entityTypeName, developmentMode) - ) - for _, ind := range konnect.ReconciliationIndexOptionsForEntity[TEnt]() { - logger.Info( - "creating index", - "indexField", ind.IndexField, + for _, t := range types { + var ( + entityTypeName = constraints.EntityTypeNameForObj(t.Object) + logger = log.GetLogger(ctx, entityTypeName, developmentMode) ) - err := mgr. - GetCache(). - IndexField(ctx, ind.IndexObject, ind.IndexField, ind.ExtractValue) - if err != nil { - return fmt.Errorf("failed to setup cache indices for %s: %w", constraints.EntityTypeName[T](), err) + for _, ind := range t.IndexOptions { + logger.Info("creating index", "indexField", ind.IndexField) + err := mgr. + GetCache(). + IndexField(ctx, ind.IndexObject, ind.IndexField, ind.ExtractValue) + if err != nil { + return fmt.Errorf("failed to setup cache indices for %s: %w", entityTypeName, err) + } } } + return nil } diff --git a/test/envtest/kongconsumercredential_jwt_test.go b/test/envtest/kongconsumercredential_jwt_test.go new file mode 100644 index 000000000..e72ab304d --- /dev/null +++ b/test/envtest/kongconsumercredential_jwt_test.go @@ -0,0 +1,164 @@ +package envtest + +import ( + "context" + "testing" + + sdkkonnectcomp "github.com/Kong/sdk-konnect-go/models/components" + sdkkonnectops "github.com/Kong/sdk-konnect-go/models/operations" + "github.com/google/uuid" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/watch" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/kong/gateway-operator/controller/konnect" + "github.com/kong/gateway-operator/controller/konnect/ops" + "github.com/kong/gateway-operator/modules/manager" + "github.com/kong/gateway-operator/modules/manager/scheme" + "github.com/kong/gateway-operator/test/helpers/deploy" + + configurationv1 "github.com/kong/kubernetes-configuration/api/configuration/v1" + configurationv1alpha1 "github.com/kong/kubernetes-configuration/api/configuration/v1alpha1" + "github.com/kong/kubernetes-configuration/api/konnect/v1alpha1" +) + +func TestKongConsumerCredential_JWT(t *testing.T) { + t.Parallel() + ctx, cancel := Context(t, context.Background()) + defer cancel() + + // Setup up the envtest environment. + cfg, ns := Setup(t, ctx, scheme.Get()) + + mgr, logs := NewManager(t, ctx, cfg, scheme.Get()) + + clientWithWatch, err := client.NewWithWatch(mgr.GetConfig(), client.Options{ + Scheme: scheme.Get(), + }) + require.NoError(t, err) + clientNamespaced := client.NewNamespacedClient(mgr.GetClient(), ns.Name) + + apiAuth := deploy.KonnectAPIAuthConfigurationWithProgrammed(t, ctx, clientNamespaced) + cp := deploy.KonnectGatewayControlPlaneWithID(t, ctx, clientNamespaced, apiAuth) + + consumerID := uuid.NewString() + consumer := deploy.KongConsumerWithProgrammed(t, ctx, clientNamespaced, &configurationv1.KongConsumer{ + Username: "username1", + Spec: configurationv1.KongConsumerSpec{ + ControlPlaneRef: &configurationv1alpha1.ControlPlaneRef{ + Type: configurationv1alpha1.ControlPlaneRefKonnectNamespacedRef, + KonnectNamespacedRef: &configurationv1alpha1.KonnectNamespacedRef{ + Name: cp.Name, + }, + }, + }, + }) + consumer.Status.Konnect = &v1alpha1.KonnectEntityStatusWithControlPlaneRef{ + ControlPlaneID: cp.GetKonnectStatus().GetKonnectID(), + KonnectEntityStatus: v1alpha1.KonnectEntityStatus{ + ID: consumerID, + ServerURL: cp.GetKonnectStatus().GetServerURL(), + OrgID: cp.GetKonnectStatus().GetOrgID(), + }, + } + require.NoError(t, clientNamespaced.Status().Update(ctx, consumer)) + + kongCredentialJWT := deploy.KongCredentialJWT(t, ctx, clientNamespaced, consumer.Name) + jwtID := uuid.NewString() + tags := []string{ + "k8s-generation:1", + "k8s-group:configuration.konghq.com", + "k8s-kind:KongCredentialJWT", + "k8s-name:" + kongCredentialJWT.Name, + "k8s-namespace:" + ns.Name, + "k8s-uid:" + string(kongCredentialJWT.GetUID()), + "k8s-version:v1alpha1", + } + + factory := ops.NewMockSDKFactory(t) + factory.SDK.KongCredentialsJWTSDK.EXPECT(). + CreateJwtWithConsumer( + mock.Anything, + sdkkonnectops.CreateJwtWithConsumerRequest{ + ControlPlaneID: cp.GetKonnectStatus().GetKonnectID(), + ConsumerIDForNestedEntities: consumerID, + JWTWithoutParents: sdkkonnectcomp.JWTWithoutParents{ + Key: lo.ToPtr("key"), + Algorithm: lo.ToPtr(sdkkonnectcomp.JWTWithoutParentsAlgorithmHs256), + Tags: tags, + }, + }, + ). + Return( + &sdkkonnectops.CreateJwtWithConsumerResponse{ + Jwt: &sdkkonnectcomp.Jwt{ + ID: lo.ToPtr(jwtID), + }, + }, + nil, + ) + factory.SDK.KongCredentialsJWTSDK.EXPECT(). + UpsertJwtWithConsumer(mock.Anything, mock.Anything, mock.Anything).Maybe(). + Return( + &sdkkonnectops.UpsertJwtWithConsumerResponse{ + Jwt: &sdkkonnectcomp.Jwt{ + ID: lo.ToPtr(jwtID), + }, + }, + nil, + ) + + require.NoError(t, manager.SetupCacheIndicesForKonnectTypes(ctx, mgr, false)) + reconcilers := []Reconciler{ + konnect.NewKonnectEntityReconciler(factory, false, mgr.GetClient(), + konnect.WithKonnectEntitySyncPeriod[configurationv1alpha1.KongCredentialJWT](konnectSyncTime), + ), + } + + StartReconcilers(ctx, t, mgr, logs, reconcilers...) + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + assert.True(c, factory.SDK.KongCredentialsJWTSDK.AssertExpectations(t)) + }, waitTime, tickTime) + + factory.SDK.KongCredentialsJWTSDK.EXPECT(). + DeleteJwtWithConsumer( + mock.Anything, + sdkkonnectops.DeleteJwtWithConsumerRequest{ + ControlPlaneID: cp.GetKonnectStatus().GetKonnectID(), + ConsumerIDForNestedEntities: consumerID, + JWTID: jwtID, + }, + ). + Return( + &sdkkonnectops.DeleteJwtWithConsumerResponse{ + StatusCode: 200, + }, + nil, + ) + require.NoError(t, clientNamespaced.Delete(ctx, kongCredentialJWT)) + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + assert.True(c, factory.SDK.KongCredentialsJWTSDK.AssertExpectations(t)) + }, waitTime, tickTime) + + w := setupWatch[configurationv1alpha1.KongCredentialJWTList](t, ctx, clientWithWatch, client.InNamespace(ns.Name)) + + kongCredentialJWT = deploy.KongCredentialJWT(t, ctx, clientNamespaced, consumer.Name) + t.Logf("redeployed %s KongCredentialJWT resource", client.ObjectKeyFromObject(kongCredentialJWT)) + t.Logf("checking if KongConsumer %s removal will delete the associated credentials %s", + client.ObjectKeyFromObject(consumer), + client.ObjectKeyFromObject(kongCredentialJWT), + ) + + require.NoError(t, clientNamespaced.Delete(ctx, consumer)) + _ = watchFor(t, ctx, w, watch.Modified, + func(c *configurationv1alpha1.KongCredentialJWT) bool { + return c.Name == kongCredentialJWT.Name + }, + "KongCredentialJWT wasn't deleted but it should have been", + ) +} diff --git a/test/helpers/deploy/deploy_resources.go b/test/helpers/deploy/deploy_resources.go index 3410146c9..1926046d5 100644 --- a/test/helpers/deploy/deploy_resources.go +++ b/test/helpers/deploy/deploy_resources.go @@ -364,6 +364,35 @@ func KongCredentialACL( return c } +// KongCredentialJWT deploys a KongCredentialJWT resource and returns the resource. +func KongCredentialJWT( + t *testing.T, + ctx context.Context, + cl client.Client, + consumerName string, +) *configurationv1alpha1.KongCredentialJWT { + t.Helper() + + c := &configurationv1alpha1.KongCredentialJWT{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "jwt-", + }, + Spec: configurationv1alpha1.KongCredentialJWTSpec{ + ConsumerRef: corev1.LocalObjectReference{ + Name: consumerName, + }, + KongCredentialJWTAPISpec: configurationv1alpha1.KongCredentialJWTAPISpec{ + Key: lo.ToPtr("key"), + }, + }, + } + + require.NoError(t, cl.Create(ctx, c)) + t.Logf("deployed new unmanaged KongCredentialJWT %s", client.ObjectKeyFromObject(c)) + + return c +} + // KongCACertificateAttachedToCP deploys a KongCACertificate resource attached to a CP and returns the resource. func KongCACertificateAttachedToCP( t *testing.T,