Repository: Kong/kubernetes-ingress-controller
Pull request #6010 (2024-05-13 18:08:08)

feat: scaffold config dependency graph implementation
**What this PR does / why we need it**:

Scaffolds code for building config dependency graph:
- adds a `hack/generators/config-graph` code generator that outputs `NewConfigGraphFromCacheStores` function ensuring that we include all objects from the store in the graph
- adds a "framework" for resolving particular object types dependencies (public `ResolveDependencies` along with the internal `resolveIngressDependencies` for Ingress - leaving the rest to be implemented in separate PR(s))

**Which issue this PR fixes**:

Part of https://github.com/Kong/kubernetes-ingress-controller/issues/5929.

**PR Readiness Checklist**:

Complete these before marking the PR as `ready to review`:

- [x] the `CHANGELOG.md` release notes have been updated to reflect any significant (and particularly user-facing) changes introduced by this PR


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/Kong/kubernetes-ingress-controller/pull/6010?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Kong) Report
Attention: Patch coverage is `75.96154%` with `25 lines` in your changes are missing coverage. Please review.
> Project coverage is 74.1%. Comparing base [(`d8c30ad`)](https://app.codecov.io/gh/Kong/kubernetes-ingress-controller/commit/d8c30ad0bdf84b11f9f60bd2b164d7d9398f60b0?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Kong) to head [(`50a3c2b`)](https://app.codecov.io/gh/Kong/kubernetes-ingress-controller/pull/6010?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Kong).
> Report is 5 commits behind head on main.

| [Files](https://app.codecov.io/gh/Kong/kubernetes-ingress-controller/pull/6010?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Kong) | Patch % | Lines |
|---|---|---|
| [internal/dataplane/fallback/graph.go](https://app.codecov.io/gh/Kong/kubernetes-ingress-controller/pull/6010?src=pr&el=tree&filepath=internal%2Fdataplane%2Ffallback%2Fgraph.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Kong#diff-aW50ZXJuYWwvZGF0YXBsYW5lL2ZhbGxiYWNrL2dyYXBoLmdv) | 69.6% | [5 Missing and 5 partials :warning: ](https://app.codecov.io/gh/Kong/kubernetes-ingress-controller/pull/6010?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Kong) |
| [internal/dataplane/fallback/graph\_dependencies.go](https://app.codecov.io/gh/Kong/kubernetes-ingress-controller/pull/6010?src=pr&el=tree&filepath=internal%2Fdataplane%2Ffallback%2Fgraph_dependencies.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Kong#diff-aW50ZXJuYWwvZGF0YXBsYW5lL2ZhbGxiYWNrL2dyYXBoX2RlcGVuZGVuY2llcy5nbw==) | 40.0% | [9 Missing :warning: ](https://app.codecov.io/gh/Kong/kubernetes-ingress-controller/pull/6010?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Kong) |
| [...l/dataplane/fallback/graph\_dependencies\_ingress.go](https://app.codecov.io/gh/Kong/kubernetes-ingress-controller/pull/6010?src=pr&el=tree&filepath=internal%2Fdataplane%2Ffallback%2Fgraph_dependencies_ingress.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Kong#diff-aW50ZXJuYWwvZGF0YXBsYW5lL2ZhbGxiYWNrL2dyYXBoX2RlcGVuZGVuY2llc19pbmdyZXNzLmdv) | 88.4% | [4 Missing and 2 partials :warning: ](https://app.codecov.io/gh/Kong/kubernetes-ingress-controller/pull/6010?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Kong) |

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##            main   #6010     +/-   ##
=======================================
+ Coverage   74.0%   74.1%   +0.1%     
=======================================
  Files        182     186      +4     
  Lines      18345   18452    +107     
=======================================
+ Hits       13582   13686    +104     
- Misses      3825    3826      +1     
- Partials     938     940      +2     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/Kong/kubernetes-ingress-controller/pull/6010?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Kong).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Kong).


Patch:
diff --git a/.golangci.yaml b/.golangci.yaml
index 3238a69f4..85efd4395 100644
--- a/.golangci.yaml
+++ b/.golangci.yaml
@@ -33,7 +33,6 @@ linters:
   - importas
   - ineffassign
   - loggercheck
-  - megacheck
   - misspell
   - nakedret
   - nilerr
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cadb722e2..84479a42a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -135,6 +135,7 @@ Adding a new version? You'll need three changes:
 - Added the CRD `KongCustomEntity` to support custom Kong entities that are not
   defined in KIC yet.
   [#5982](https://github.com/Kong/kubernetes-ingress-controller/pull/5982)
+  [#6006](https://github.com/Kong/kubernetes-ingress-controller/pull/6006)
 - Added `FallbackConfiguration` feature gate to enable the controller to generate a fallback configuration
   for Kong when it fails to apply the configuration. The feature gate is disabled by default.
   [#5993](https://github.com/Kong/kubernetes-ingress-controller/pull/5993)
diff --git a/Makefile b/Makefile
index c17e6a62c..2e3dc904c 100644
--- a/Makefile
+++ b/Makefile
@@ -289,7 +289,7 @@ manifests.single: kustomize ## Compose single-file deployment manifests from bui
 # ------------------------------------------------------------------------------
 
 .PHONY: generate
-generate: generate.controllers generate.clientsets generate.gateway-api-consts generate.docs generate.store fmt
+generate: generate.controllers generate.clientsets generate.gateway-api-consts generate.docs generate.go fmt
 
 .PHONY: generate.controllers
 generate.controllers: controller-gen
@@ -328,9 +328,10 @@ generate.apidocs: crd-ref-docs
 generate.cli-arguments-docs:
 	go run ./scripts/cli-arguments-docs-gen/main.go > ./docs/cli-arguments.md
 
-.PHONY: generate.store
-generate.store:
-	go generate ./internal/store
+.PHONY: generate.go
+generate.go:
+	go generate ./...
+
 
 # ------------------------------------------------------------------------------
 # Build - Container Images
diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml
index 3281b2066..71a95d9fc 100644
--- a/config/webhook/manifests.yaml
+++ b/config/webhook/manifests.yaml
@@ -132,6 +132,27 @@ webhooks:
     resources:
     - kongconsumers
   sideEffects: None
+- admissionReviewVersions:
+  - v1
+  clientConfig:
+    service:
+      name: webhook-service
+      namespace: system
+      path: /
+  failurePolicy: Fail
+  matchPolicy: Equivalent
+  name: kongcustomentities.validation.ingress-controller.konghq.com
+  rules:
+  - apiGroups:
+    - configuration.konghq.com
+    apiVersions:
+    - v1alpha1
+    operations:
+    - CREATE
+    - UPDATE
+    resources:
+    - kongcustomentities
+  sideEffects: None
 - admissionReviewVersions:
   - v1
   clientConfig:
diff --git a/go.mod b/go.mod
index 96277727b..3e22329c7 100644
--- a/go.mod
+++ b/go.mod
@@ -25,6 +25,7 @@ require (
 	github.com/Masterminds/sprig/v3 v3.2.3
 	github.com/avast/retry-go/v4 v4.6.0
 	github.com/blang/semver/v4 v4.0.0
+	github.com/dominikbraun/graph v0.23.0
 	github.com/go-logr/logr v1.4.1
 	github.com/go-logr/zapr v1.3.0
 	github.com/goccy/go-json v0.10.2
@@ -32,7 +33,7 @@ require (
 	github.com/google/uuid v1.6.0
 	github.com/jpillora/backoff v1.0.0
 	github.com/kong/go-database-reconciler v1.10.0
-	github.com/kong/go-kong v0.54.0
+	github.com/kong/go-kong v0.55.0
 	github.com/kong/kubernetes-telemetry v0.1.3
 	github.com/kong/kubernetes-testing-framework v0.47.0
 	github.com/lithammer/dedent v1.1.0
@@ -51,11 +52,11 @@ require (
 	github.com/testcontainers/testcontainers-go v0.31.0
 	go.uber.org/zap v1.27.0
 	google.golang.org/api v0.180.0
-	k8s.io/api v0.30.0
-	k8s.io/apiextensions-apiserver v0.30.0
-	k8s.io/apimachinery v0.30.0
-	k8s.io/client-go v0.30.0
-	k8s.io/component-base v0.30.0
+	k8s.io/api v0.30.1
+	k8s.io/apiextensions-apiserver v0.30.1
+	k8s.io/apimachinery v0.30.1
+	k8s.io/client-go v0.30.1
+	k8s.io/component-base v0.30.1
 	sigs.k8s.io/controller-runtime v0.18.2
 	sigs.k8s.io/gateway-api v1.1.0
 	sigs.k8s.io/kustomize/api v0.17.1
diff --git a/go.sum b/go.sum
index 8b9aaf37c..bbbe5eef2 100644
--- a/go.sum
+++ b/go.sum
@@ -84,6 +84,8 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj
 github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
 github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
+github.com/dominikbraun/graph v0.23.0 h1:TdZB4pPqCLFxYhdyMFb1TBdFxp8XLcJfTTBQucVPgCo=
+github.com/dominikbraun/graph v0.23.0/go.mod h1:yOjYyogZLY1LSG9E33JWZJiq5k83Qy2C6POAuiViluc=
 github.com/emicklei/go-restful/v3 v3.12.0 h1:y2DdzBAURM29NFF94q6RaY4vjIH1rtwDapwQtU84iWk=
 github.com/emicklei/go-restful/v3 v3.12.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
 github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
@@ -246,8 +248,8 @@ github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJw
 github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
 github.com/kong/go-database-reconciler v1.10.0 h1:502Nn7CTsUNZyClL5bjrhkFrendVrPxDkP4ZGlBfsdg=
 github.com/kong/go-database-reconciler v1.10.0/go.mod h1:88/u23NIhkQr7SeTP1p4nLcnWCm8AMCwCF1f7Telw+w=
-github.com/kong/go-kong v0.54.0 h1:HZkZJRREJs/azkgJxMWr2jANQQfg8xXsAiFKu+d1nrs=
-github.com/kong/go-kong v0.54.0/go.mod h1:51rSSjgSZKukXgn5nNYbJUx0Et/islqR+LTltwCyjG8=
+github.com/kong/go-kong v0.55.0 h1:lonKRzsDGk12dh9E+y+pWnY2ThXhKuMHjzBHSpCvQLw=
+github.com/kong/go-kong v0.55.0/go.mod h1:i1cMgTu6RYPHSyMpviShddRnc+DML/vlpgKC00hr8kU=
 github.com/kong/kubernetes-telemetry v0.1.3 h1:Hz2tkHGIIUqbn1x46QRDmmNjbEtJyxyOvHSPne3uPto=
 github.com/kong/kubernetes-telemetry v0.1.3/go.mod h1:wB7o8dOKa5R396CyiU0sPa8am/g3c5DKd/qrn/Vmb+k=
 github.com/kong/kubernetes-testing-framework v0.47.0 h1:+9AfTinsGwtxVOcQ/dsDDpwHr4YPpXogKc0THauYJjA=
@@ -635,18 +637,18 @@ gotest.tools/v3 v3.5.0 h1:Ljk6PdHdOhAb5aDMWXjDLMMhph+BpztA4v1QdqEW2eY=
 gotest.tools/v3 v3.5.0/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-k8s.io/api v0.30.0 h1:siWhRq7cNjy2iHssOB9SCGNCl2spiF1dO3dABqZ8niA=
-k8s.io/api v0.30.0/go.mod h1:OPlaYhoHs8EQ1ql0R/TsUgaRPhpKNxIMrKQfWUp8QSE=
-k8s.io/apiextensions-apiserver v0.30.0 h1:jcZFKMqnICJfRxTgnC4E+Hpcq8UEhT8B2lhBcQ+6uAs=
-k8s.io/apiextensions-apiserver v0.30.0/go.mod h1:N9ogQFGcrbWqAY9p2mUAL5mGxsLqwgtUce127VtRX5Y=
-k8s.io/apimachinery v0.30.0 h1:qxVPsyDM5XS96NIh9Oj6LavoVFYff/Pon9cZeDIkHHA=
-k8s.io/apimachinery v0.30.0/go.mod h1:iexa2somDaxdnj7bha06bhb43Zpa6eWH8N8dbqVjTUc=
+k8s.io/api v0.30.1 h1:kCm/6mADMdbAxmIh0LBjS54nQBE+U4KmbCfIkF5CpJY=
+k8s.io/api v0.30.1/go.mod h1:ddbN2C0+0DIiPntan/bye3SW3PdwLa11/0yqwvuRrJM=
+k8s.io/apiextensions-apiserver v0.30.1 h1:4fAJZ9985BmpJG6PkoxVRpXv9vmPUOVzl614xarePws=
+k8s.io/apiextensions-apiserver v0.30.1/go.mod h1:R4GuSrlhgq43oRY9sF2IToFh7PVlF1JjfWdoG3pixk4=
+k8s.io/apimachinery v0.30.1 h1:ZQStsEfo4n65yAdlGTfP/uSHMQSoYzU/oeEbkmF7P2U=
+k8s.io/apimachinery v0.30.1/go.mod h1:iexa2somDaxdnj7bha06bhb43Zpa6eWH8N8dbqVjTUc=
 k8s.io/cli-runtime v0.29.4 h1:QvUrddBxVX6XFJ6z64cGpEk7e4bQduKweqbqq+qBd9g=
 k8s.io/cli-runtime v0.29.4/go.mod h1:NmklYuZ4DLfOr2XEIT8Nzl883KMZUCv7KMj3wMHayCA=
-k8s.io/client-go v0.30.0 h1:sB1AGGlhY/o7KCyCEQ0bPWzYDL0pwOZO4vAtTSh/gJQ=
-k8s.io/client-go v0.30.0/go.mod h1:g7li5O5256qe6TYdAMyX/otJqMhIiGgTapdLchhmOaY=
-k8s.io/component-base v0.30.0 h1:cj6bp38g0ainlfYtaOQuRELh5KSYjhKxM+io7AUIk4o=
-k8s.io/component-base v0.30.0/go.mod h1:V9x/0ePFNaKeKYA3bOvIbrNoluTSG+fSJKjLdjOoeXQ=
+k8s.io/client-go v0.30.1 h1:uC/Ir6A3R46wdkgCV3vbLyNOYyCJ8oZnjtJGKfytl/Q=
+k8s.io/client-go v0.30.1/go.mod h1:wrAqLNs2trwiCH/wxxmT/x3hKVH9PuV0GGW0oDoHVqc=
+k8s.io/component-base v0.30.1 h1:bvAtlPh1UrdaZL20D9+sWxsJljMi0QZ3Lmw+kmZAaxQ=
+k8s.io/component-base v0.30.1/go.mod h1:e/X9kDiOebwlI41AvBHuWdqFriSRrX50CdwA9TFaHLI=
 k8s.io/klog/v2 v2.120.1 h1:QXU6cPEOIslTGvZaXvFWiP9VKyeet3sawzTOvdXb4Vw=
 k8s.io/klog/v2 v2.120.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
 k8s.io/kube-openapi v0.0.0-20240430033511-f0e62f92d13f h1:0LQagt0gDpKqvIkAMPaRGcXawNMouPECM1+F9BVxEaM=
diff --git a/hack/generators/cache-stores/templates.go b/hack/generators/cache-stores/templates.go
index eaaca25ce..2883c9956 100644
--- a/hack/generators/cache-stores/templates.go
+++ b/hack/generators/cache-stores/templates.go
@@ -15,6 +15,7 @@ import (
 	netv1 "k8s.io/api/networking/v1"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/client-go/tools/cache"
+	"sigs.k8s.io/controller-runtime/pkg/client"
 
 	"github.com/kong/kubernetes-ingress-controller/v3/internal/gatewayapi"
 	kongv1 "github.com/kong/kubernetes-ingress-controller/v3/pkg/apis/configuration/v1"
@@ -99,8 +100,8 @@ func (c CacheStores) ListAllStores() []cache.Store {
 }
 
 // SupportedTypes returns a list of supported types for the cache.
-func (c CacheStores) SupportedTypes() []runtime.Object {
-	return []runtime.Object{
+func (c CacheStores) SupportedTypes() []client.Object {
+	return []client.Object{
 		{{- range . }}
 		&{{ .Package }}.{{ .Type }}{},
 		{{- end }}
diff --git a/internal/admission/adminapi_provider.go b/internal/admission/adminapi_provider.go
index 7868c3d67..9736aafd1 100644
--- a/internal/admission/adminapi_provider.go
+++ b/internal/admission/adminapi_provider.go
@@ -69,6 +69,14 @@ func (p DefaultAdminAPIServicesProvider) GetVaultsService() (kong.AbstractVaultS
 	return c.Vaults, true
 }
 
+func (p DefaultAdminAPIServicesProvider) GetSchemasService() (kong.AbstractSchemaService, bool) {
+	c, ok := p.designatedAdminAPIClient()
+	if !ok {
+		return nil, ok
+	}
+	return c.Schemas, true
+}
+
 func (p DefaultAdminAPIServicesProvider) designatedAdminAPIClient() (*kong.Client, bool) {
 	gwClients := p.gatewayClientsProvider.GatewayClients()
 	if len(gwClients) == 0 {
diff --git a/internal/admission/errors.go b/internal/admission/errors.go
index 33917f8be..83e876af4 100644
--- a/internal/admission/errors.go
+++ b/internal/admission/errors.go
@@ -9,6 +9,8 @@ const (
 	ErrTextConsumerGroupUnsupported           = "consumer group support requires Kong Enterprise"
 	ErrTextConsumerGroupUnlicensed            = "consumer group support requires a valid Kong Enterprise license"
 	ErrTextConsumerGroupUnexpected            = "unexpected error during checking support for consumer group"
+	ErrTextCustomEntityFieldsUnmarshalFailed  = "failed to unmarshal fields of custom entity: %v"
+	ErrTextCustomEntityGetSchemaFailed        = "failed to get schema of Kong entity type '%s': %v"
 	ErrTextFailedToRetrieveSecret             = "could not retrieve secrets from the kubernetes API" //nolint:revive,gosec
 	ErrTextPluginConfigInvalid                = "could not parse plugin configuration"
 	ErrTextPluginConfigValidationFailed       = "unable to validate plugin schema"
diff --git a/internal/admission/handler.go b/internal/admission/handler.go
index 9c77afe37..0b3fd95db 100644
--- a/internal/admission/handler.go
+++ b/internal/admission/handler.go
@@ -103,6 +103,11 @@ var (
 		Version:  kongv1alpha1.SchemeGroupVersion.Version,
 		Resource: "kongvaults",
 	}
+	kongCustomEntityGVResource = metav1.GroupVersionResource{
+		Group:    kongv1alpha1.SchemeGroupVersion.Group,
+		Version:  kongv1alpha1.SchemeGroupVersion.Version,
+		Resource: "kongcustomentities",
+	}
 	secretGVResource = metav1.GroupVersionResource{
 		Group:    corev1.SchemeGroupVersion.Group,
 		Version:  corev1.SchemeGroupVersion.Version,
@@ -144,6 +149,8 @@ func (h RequestHandler) handleValidation(ctx context.Context, request admissionv
 		return h.handleKongIngress(ctx, request, responseBuilder)
 	case kongVaultGVResource:
 		return h.handleKongVault(ctx, request, responseBuilder)
+	case kongCustomEntityGVResource:
+		return h.handleKongCustomEntity(ctx, request, responseBuilder)
 	case serviceGVResource:
 		return h.handleService(ctx, request, responseBuilder)
 	case ingressGVResource:
@@ -492,3 +499,21 @@ func (h RequestHandler) handleKongVault(ctx context.Context, request admissionv1
 
 	return responseBuilder.Allowed(ok).WithMessage(message).Build(), nil
 }
+
+// +kubebuilder:webhook:verbs=create;update,groups=configuration.konghq.com,resources=kongcustomentities,versions=v1alpha1,name=kongcustomentities.validation.ingress-controller.konghq.com,path=/,webhookVersions=v1,matchPolicy=equivalent,mutating=false,failurePolicy=fail,sideEffects=None,admissionReviewVersions=v1
+
+func (h RequestHandler) handleKongCustomEntity(ctx context.Context, request admissionv1.AdmissionRequest, responseBuilder *ResponseBuilder) (*admissionv1.AdmissionResponse, error) {
+	kongCustomEntity := kongv1alpha1.KongCustomEntity{}
+
+	_, _, err := codecs.UniversalDeserializer().Decode(request.Object.Raw, nil, &kongCustomEntity)
+	if err != nil {
+		return nil, err
+	}
+
+	ok, message, err := h.Validator.ValidateCustomEntity(ctx, kongCustomEntity)
+	if err != nil {
+		return nil, err
+	}
+
+	return responseBuilder.Allowed(ok).WithMessage(message).Build(), nil
+}
diff --git a/internal/admission/validator.go b/internal/admission/validator.go
index d376bc87e..bb7f05b10 100644
--- a/internal/admission/validator.go
+++ b/internal/admission/validator.go
@@ -38,6 +38,7 @@ type KongValidator interface {
 	ValidatePlugin(ctx context.Context, plugin kongv1.KongPlugin, overrideSecrets []*corev1.Secret) (bool, string, error)
 	ValidateClusterPlugin(ctx context.Context, plugin kongv1.KongClusterPlugin, overrideSecrets []*corev1.Secret) (bool, string, error)
 	ValidateVault(ctx context.Context, vault kongv1alpha1.KongVault) (bool, string, error)
+	ValidateCustomEntity(ctx context.Context, entity kongv1alpha1.KongCustomEntity) (bool, string, error)
 	ValidateCredential(ctx context.Context, secret corev1.Secret) (bool, string)
 	ValidateGateway(ctx context.Context, gateway gatewayapi.Gateway) (bool, string, error)
 	ValidateHTTPRoute(ctx context.Context, httproute gatewayapi.HTTPRoute) (bool, string, error)
@@ -54,6 +55,7 @@ type AdminAPIServicesProvider interface {
 	GetInfoService() (kong.AbstractInfoService, bool)
 	GetRoutesService() (kong.AbstractRouteService, bool)
 	GetVaultsService() (kong.AbstractVaultService, bool)
+	GetSchemasService() (kong.AbstractSchemaService, bool)
 }
 
 // ConsumerGetter is an interface for retrieving KongConsumers.
@@ -630,3 +632,55 @@ func (m *managerClientConsumerGetter) ListAllConsumers(ctx context.Context) ([]k
 	}
 	return consumers.Items, nil
 }
+
+func (validator KongHTTPValidator) ValidateCustomEntity(ctx context.Context, entity kongv1alpha1.KongCustomEntity) (bool, string, error) {
+	logger := validator.Logger.WithValues("namespace", entity.Namespace, "name", entity.Name, "kind", kongv1alpha1.KongCustomEntityKind)
+	// If the spec.contollerName does not match the ingress class name,
+	// and the ingress class annotation does not match the ingress class name either,
+	// ignore it as it is not controlled by the controller.
+	if (!validator.ingressClassMatcher(&entity.ObjectMeta, annotations.IngressClassKey, annotations.ExactClassMatch)) &&
+		(!validator.ingressClassMatcher(&metav1.ObjectMeta{
+			Annotations: map[string]string{
+				annotations.IngressClassKey: entity.Spec.ControllerName,
+			},
+		}, annotations.IngressClassKey, annotations.ExactClassMatch)) {
+		return true, "", nil
+	}
+
+	fields, err := kongstate.RawConfigToConfiguration(entity.Spec.Fields.Raw)
+	if err != nil {
+		return false, fmt.Sprintf(ErrTextCustomEntityFieldsUnmarshalFailed, err), nil
+	}
+
+	schemaService, hasClient := validator.AdminAPIServicesProvider.GetSchemasService()
+	// Skip validation on Kong gateway if we do not have available client.
+	if !hasClient {
+		logger.V(util.DebugLevel).Info("Skipped because no schema service available")
+		return true, "", nil
+	}
+
+	// Retrieve the schema of the entity from Kong gateway.
+	entityType := entity.Spec.EntityType
+	schema, err := schemaService.Get(ctx, entityType)
+	if err != nil {
+		logger.V(util.DebugLevel).Info("Failed to get schema of entity", "entity_type", entityType, "error", err)
+		return false, fmt.Sprintf(ErrTextCustomEntityGetSchemaFailed, entityType, err), nil
+	}
+
+	// Extract field definitions from the response of getting the schema of the entity.
+	s := kongstate.ExtractEntityFieldDefinitions(schema)
+	// Fill in fields that are "foreign" and required if there are such fields.
+	for fieldName, field := range s.Fields {
+		// Fill in an arbitrary UUID if the entity requires a reference to a foreign key.
+		// This format of foreign reference in entities should satisfy most of foreign fields in entities(services/routes/consumers/...).
+		// The admission webhook needs a placeholder value because the actual value isn't present in the KongCustomEntity spec.
+		// It will be derived from the resource(s) used by the parent KongPlugin during translation.
+		if field.Required && field.Type == kongstate.EntityFieldTypeForeign {
+			fields[fieldName] = map[string]interface{}{
+				"id": util.DefaultUUIDGenerator{}.NewString(),
+			}
+		}
+	}
+	// validate the custom entity against Kong gateway.
+	return schemaService.Validate(ctx, kong.EntityType(entity.Spec.EntityType), fields)
+}
diff --git a/internal/dataplane/fallback/graph.go b/internal/dataplane/fallback/graph.go
new file mode 100644
index 000000000..595eaae67
--- /dev/null
+++ b/internal/dataplane/fallback/graph.go
@@ -0,0 +1,116 @@
+package fallback
+
+import (
+	"errors"
+	"fmt"
+
+	"github.com/dominikbraun/graph"
+	"github.com/samber/lo"
+	k8stypes "k8s.io/apimachinery/pkg/types"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	"github.com/kong/kubernetes-ingress-controller/v3/internal/store"
+)
+
+// ConfigGraph is a graph representation of the Kubernetes resources kept in the cache.
+// Vertices are objects, edges are dependencies between objects (dependency -> dependant).
+// It allows to quickly determine which objects are affected by traversing the graph from
+// the affected object to its dependants.
+//
+// If you want to extend the graph with a new object type, you need to ensure ResolveDependencies
+// function is implemented for that object type. If your object type has no dependencies, you can ignore it.
+type ConfigGraph struct {
+	graph graph.Graph[ObjectHash, client.Object]
+}
+
+// ObjectHash is a unique identifier for a given object that is used as a vertex key in the graph.
+// It could consist of the object's UID only, but we also include the object's kind, namespace and name
+// to make it easier to debug and understand the graph.
+type ObjectHash struct {
+	// UID is the unique identifier of the object.
+	UID k8stypes.UID
+
+	// Kind, Namespace and Name are the object's kind, namespace and name - included for debugging purposes.
+	Kind      string
+	Namespace string
+	Name      string
+}
+
+// String returns a string representation of the ObjectHash. It intentionally does not include the UID
+// as it is not human-readable and is not necessary for debugging purposes.
+func (h ObjectHash) String() string {
+	if h.Namespace == "" {
+		return fmt.Sprintf("%s:%s", h.Kind, h.Name)
+	}
+	return fmt.Sprintf("%s:%s/%s", h.Kind, h.Namespace, h.Name)
+}
+
+// GetObjectHash is a function that returns a unique identifier for a given object that is used as a
+// vertex key in the graph.
+func GetObjectHash(obj client.Object) ObjectHash {
+	return ObjectHash{
+		UID:       obj.GetUID(),
+		Kind:      obj.GetObjectKind().GroupVersionKind().Kind,
+		Namespace: obj.GetNamespace(),
+		Name:      obj.GetName(),
+	}
+}
+
+// NewConfigGraphFromCacheStores creates a new ConfigGraph from the given cache stores. It adds all objects
+// from the cache stores to the graph as vertices as well as edges between objects and their dependencies
+// resolved by the ResolveDependencies function.
+func NewConfigGraphFromCacheStores(c store.CacheStores) (*ConfigGraph, error) {
+	g := graph.New[ObjectHash, client.Object](GetObjectHash, graph.Directed())
+
+	for _, s := range c.ListAllStores() {
+		for _, o := range s.List() {
+			obj, ok := o.(client.Object)
+			if !ok {
+				// Should not happen since all objects in the cache are client.Objects, but better safe than sorry.
+				return nil, fmt.Errorf("expected client.Object, got %T", o)
+			}
+			// Add the object to the graph. It can happen that the object is already in the graph (i.e. was already added
+			// as a dependency of another object), in which case we ignore the error.
+			if err := g.AddVertex(obj); err != nil && !errors.Is(err, graph.ErrVertexAlreadyExists) {
+				return nil, fmt.Errorf("failed to add %s to the graph: %w", GetObjectHash(obj), err)
+			}
+
+			deps, err := ResolveDependencies(c, obj)
+			if err != nil {
+				return nil, fmt.Errorf("failed to resolve dependencies for %s: %w", GetObjectHash(obj), err)
+			}
+			// Add the object's dependencies to the graph.
+			for _, dep := range deps {
+				// Add the dependency to the graph in case it wasn't added before. If it was added before, we ignore the
+				// error.
+				if err := g.AddVertex(dep); err != nil && !errors.Is(err, graph.ErrVertexAlreadyExists) {
+					return nil, fmt.Errorf("failed to add %s to the graph: %w", GetObjectHash(obj), err)
+				}
+
+				// Add an edge from a dependency to the object. If the edge was already added before, we ignore the error.
+				if err := g.AddEdge(GetObjectHash(dep), GetObjectHash(obj)); err != nil && !errors.Is(err, graph.ErrEdgeAlreadyExists) {
+					return nil, fmt.Errorf("failed to add edge from %s to %s: %w", GetObjectHash(obj), GetObjectHash(dep), err)
+				}
+			}
+		}
+	}
+
+	return &ConfigGraph{graph: g}, nil
+}
+
+// AdjacencyMap is a map of object hashes to their neighbours' hashes.
+type AdjacencyMap map[ObjectHash][]ObjectHash
+
+// AdjacencyMap returns a map of object hashes to their neighbours' hashes.
+func (g *ConfigGraph) AdjacencyMap() (AdjacencyMap, error) {
+	am, err := g.graph.AdjacencyMap()
+	if err != nil {
+		return nil, fmt.Errorf("failed to get adjacency map: %w", err)
+	}
+
+	m := make(map[ObjectHash][]ObjectHash)
+	for v, neighbours := range am {
+		m[v] = lo.Keys(neighbours)
+	}
+	return m, nil
+}
diff --git a/internal/dataplane/fallback/graph_dependencies.go b/internal/dataplane/fallback/graph_dependencies.go
new file mode 100644
index 000000000..55476c537
--- /dev/null
+++ b/internal/dataplane/fallback/graph_dependencies.go
@@ -0,0 +1,62 @@
+package fallback
+
+import (
+	"fmt"
+
+	corev1 "k8s.io/api/core/v1"
+	discoveryv1 "k8s.io/api/discovery/v1"
+	netv1 "k8s.io/api/networking/v1"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	"github.com/kong/kubernetes-ingress-controller/v3/internal/gatewayapi"
+	"github.com/kong/kubernetes-ingress-controller/v3/internal/store"
+	kongv1 "github.com/kong/kubernetes-ingress-controller/v3/pkg/apis/configuration/v1"
+	kongv1alpha1 "github.com/kong/kubernetes-ingress-controller/v3/pkg/apis/configuration/v1alpha1"
+	kongv1beta1 "github.com/kong/kubernetes-ingress-controller/v3/pkg/apis/configuration/v1beta1"
+	incubatorv1alpha1 "github.com/kong/kubernetes-ingress-controller/v3/pkg/apis/incubator/v1alpha1"
+)
+
+// ResolveDependencies resolves dependencies for a given object. Dependencies are all objects referenced by the
+// given object. For example, an Ingress object might refer to an IngressClass, Services, Plugins, etc.
+// Every supported object type should explicitly have a case in this function.
+func ResolveDependencies(cache store.CacheStores, obj client.Object) ([]client.Object, error) {
+	switch obj := obj.(type) {
+	case *netv1.Ingress:
+		return resolveIngressDependencies(cache, obj), nil
+	case *gatewayapi.HTTPRoute:
+		return resolveHTTPRouteDependencies(cache, obj), nil
+	case *gatewayapi.TLSRoute:
+		return resolveTLSRouteDependencies(cache, obj), nil
+	case *gatewayapi.TCPRoute:
+		return resolveTCPRouteDependencies(cache, obj), nil
+	case *gatewayapi.UDPRoute:
+		return resolveUDPRouteDependencies(cache, obj), nil
+	case *gatewayapi.GRPCRoute:
+		return resolveGRPCRouteDependencies(cache, obj), nil
+	case *kongv1.KongPlugin:
+		return resolveKongPluginDependencies(cache, obj), nil
+	case *kongv1.KongClusterPlugin:
+		return resolveKongClusterPluginDependencies(cache, obj), nil
+	case *kongv1beta1.UDPIngress:
+		return resolveUDPIngressDependencies(cache, obj), nil
+	case *kongv1beta1.TCPIngress:
+		return resolveTCPIngressDependencies(cache, obj), nil
+	case *incubatorv1alpha1.KongServiceFacade:
+		return resolveKongServiceFacadeDependencies(cache, obj), nil
+	case *netv1.IngressClass, // Object types that have no dependencies.
+		*corev1.Service,
+		*corev1.Secret,
+		*discoveryv1.EndpointSlice,
+		*gatewayapi.ReferenceGrant,
+		*gatewayapi.Gateway,
+		*kongv1.KongConsumer,
+		*kongv1beta1.KongConsumerGroup,
+		*kongv1.KongIngress,
+		*kongv1beta1.KongUpstreamPolicy,
+		*kongv1alpha1.IngressClassParameters,
+		*kongv1alpha1.KongVault:
+		return nil, nil
+	default:
+		return nil, fmt.Errorf("unsupported object type: %T", obj)
+	}
+}
diff --git a/internal/dataplane/fallback/graph_dependencies_gatewayapi.go b/internal/dataplane/fallback/graph_dependencies_gatewayapi.go
new file mode 100644
index 000000000..94324e3ad
--- /dev/null
+++ b/internal/dataplane/fallback/graph_dependencies_gatewayapi.go
@@ -0,0 +1,33 @@
+package fallback
+
+import (
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	"github.com/kong/kubernetes-ingress-controller/v3/internal/gatewayapi"
+	"github.com/kong/kubernetes-ingress-controller/v3/internal/store"
+)
+
+// TODO: https://github.com/Kong/kubernetes-ingress-controller/issues/6026
+func resolveHTTPRouteDependencies(_ store.CacheStores, _ *gatewayapi.HTTPRoute) []client.Object {
+	return nil
+}
+
+// TODO: https://github.com/Kong/kubernetes-ingress-controller/issues/6026
+func resolveTLSRouteDependencies(_ store.CacheStores, _ *gatewayapi.TLSRoute) []client.Object {
+	return nil
+}
+
+// TODO: https://github.com/Kong/kubernetes-ingress-controller/issues/6026
+func resolveTCPRouteDependencies(_ store.CacheStores, _ *gatewayapi.TCPRoute) []client.Object {
+	return nil
+}
+
+// TODO: https://github.com/Kong/kubernetes-ingress-controller/issues/6026
+func resolveUDPRouteDependencies(_ store.CacheStores, _ *gatewayapi.UDPRoute) []client.Object {
+	return nil
+}
+
+// TODO: https://github.com/Kong/kubernetes-ingress-controller/issues/6026
+func resolveGRPCRouteDependencies(_ store.CacheStores, _ *gatewayapi.GRPCRoute) []client.Object {
+	return nil
+}
diff --git a/internal/dataplane/fallback/graph_dependencies_ingress.go b/internal/dataplane/fallback/graph_dependencies_ingress.go
new file mode 100644
index 000000000..d97c6541e
--- /dev/null
+++ b/internal/dataplane/fallback/graph_dependencies_ingress.go
@@ -0,0 +1,109 @@
+package fallback
+
+import (
+	"fmt"
+	"slices"
+
+	netv1 "k8s.io/api/networking/v1"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	"github.com/kong/kubernetes-ingress-controller/v3/internal/annotations"
+	"github.com/kong/kubernetes-ingress-controller/v3/internal/dataplane/translator/subtranslator"
+	"github.com/kong/kubernetes-ingress-controller/v3/internal/store"
+)
+
+// resolveIngressDependencies resolves potential dependencies for an Ingress object:
+// - IngressClass
+// - Service
+// - KongServiceFacade
+// - KongUpstreamPolicy
+// - KongPlugin
+// - KongClusterPlugin.
+func resolveIngressDependencies(cache store.CacheStores, ingress *netv1.Ingress) []client.Object {
+	return slices.Concat(
+		resolveIngressDependenciesIngressClass(cache, ingress),
+		resolveIngressDependenciesService(cache, ingress),
+		resolveIngressDependenciesKongUpstreamPolicy(cache, ingress),
+		resolveIngressDependenciesPlugin(cache, ingress),
+	)
+}
+
+// resolveIngressDependenciesIngressClass resolves IngressClass dependencies for an Ingress object.
+func resolveIngressDependenciesIngressClass(cache store.CacheStores, ingress *netv1.Ingress) []client.Object {
+	resolveIngressClassName := func() string {
+		// IngressClass can be referenced by the Ingress object in two ways:
+		// 1. Using the IngressClassName field.
+		if ingress.Spec.IngressClassName != nil && *ingress.Spec.IngressClassName != "" {
+			return *ingress.Spec.IngressClassName
+		}
+		// 2. Using the annotation in the Ingress object.
+		if ingressClassName, ok := ingress.Annotations[annotations.IngressClassKey]; ok && ingressClassName != "" {
+			return ingressClassName
+		}
+		return "" // No IngressClass reference was found.
+	}
+
+	var dependencies []client.Object
+	if ingressClassName := resolveIngressClassName(); ingressClassName != "" {
+		ingressClass, exists, err := cache.IngressClassV1.GetByKey(ingressClassName)
+		if err == nil && exists {
+			dependencies = append(dependencies, ingressClass.(client.Object))
+		}
+	}
+	return dependencies
+}
+
+// resolveIngressDependenciesService resolves Service and KongServiceFacade dependencies for an Ingress object.
+func resolveIngressDependenciesService(cache store.CacheStores, ingress *netv1.Ingress) []client.Object {
+	var dependencies []client.Object
+	for _, rule := range ingress.Spec.Rules {
+		if rule.HTTP == nil {
+			continue
+		}
+		for _, path := range rule.HTTP.Paths {
+			if path.Backend.Service != nil {
+				service, exists, err := cache.Service.GetByKey(fmt.Sprintf("%s/%s", ingress.GetNamespace(), path.Backend.Service.Name))
+				if err == nil && exists {
+					dependencies = append(dependencies, service.(client.Object))
+				}
+			}
+
+			if resource := path.Backend.Resource; resource != nil && subtranslator.IsKongServiceFacade(resource) {
+				kongServiceFacade, exists, err := cache.KongServiceFacade.GetByKey(fmt.Sprintf("%s/%s", ingress.GetNamespace(), resource.Name))
+				if err == nil && exists {
+					dependencies = append(dependencies, kongServiceFacade.(client.Object))
+				}
+			}
+		}
+	}
+	return dependencies
+}
+
+// resolveIngressDependenciesKongUpstreamPolicy resolves KongUpstreamPolicy dependencies for an Ingress object.
+func resolveIngressDependenciesKongUpstreamPolicy(cache store.CacheStores, ingress *netv1.Ingress) []client.Object {
+	upstreamPolicyName, ok := annotations.ExtractUpstreamPolicy(ingress.Annotations)
+	if ok {
+		upstreamPolicy, exists, err := cache.KongUpstreamPolicy.GetByKey(fmt.Sprintf("%s/%s", ingress.GetNamespace(), upstreamPolicyName))
+		if err == nil && exists {
+			return []client.Object{upstreamPolicy.(client.Object)}
+		}
+	}
+	return nil
+}
+
+// resolveIngressDependenciesPlugin resolves KongPlugin and KongClusterPlugin dependencies for an Ingress object.
+func resolveIngressDependenciesPlugin(cache store.CacheStores, ingress *netv1.Ingress) []client.Object {
+	var dependencies []client.Object
+	for _, pluginName := range annotations.ExtractKongPluginsFromAnnotations(ingress.Annotations) {
+		if plugin, exists, err := cache.Plugin.GetByKey(
+			fmt.Sprintf("%s/%s", ingress.GetNamespace(), pluginName),
+		); err == nil && exists {
+			dependencies = append(dependencies, plugin.(client.Object))
+		}
+
+		if plugin, exists, err := cache.ClusterPlugin.GetByKey(pluginName); err == nil && exists {
+			dependencies = append(dependencies, plugin.(client.Object))
+		}
+	}
+	return dependencies
+}
diff --git a/internal/dataplane/fallback/graph_dependencies_kong.go b/internal/dataplane/fallback/graph_dependencies_kong.go
new file mode 100644
index 000000000..ddcf3f7f5
--- /dev/null
+++ b/internal/dataplane/fallback/graph_dependencies_kong.go
@@ -0,0 +1,35 @@
+package fallback
+
+import (
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	"github.com/kong/kubernetes-ingress-controller/v3/internal/store"
+	kongv1 "github.com/kong/kubernetes-ingress-controller/v3/pkg/apis/configuration/v1"
+	kongv1beta1 "github.com/kong/kubernetes-ingress-controller/v3/pkg/apis/configuration/v1beta1"
+	incubatorv1alpha1 "github.com/kong/kubernetes-ingress-controller/v3/pkg/apis/incubator/v1alpha1"
+)
+
+// TODO: https://github.com/Kong/kubernetes-ingress-controller/issues/6026
+func resolveKongPluginDependencies(_ store.CacheStores, _ *kongv1.KongPlugin) []client.Object {
+	return nil
+}
+
+// TODO: https://github.com/Kong/kubernetes-ingress-controller/issues/6026
+func resolveKongClusterPluginDependencies(_ store.CacheStores, _ *kongv1.KongClusterPlugin) []client.Object {
+	return nil
+}
+
+// TODO: https://github.com/Kong/kubernetes-ingress-controller/issues/6026
+func resolveUDPIngressDependencies(_ store.CacheStores, _ *kongv1beta1.UDPIngress) []client.Object {
+	return nil
+}
+
+// TODO: https://github.com/Kong/kubernetes-ingress-controller/issues/6026
+func resolveTCPIngressDependencies(_ store.CacheStores, _ *kongv1beta1.TCPIngress) []client.Object {
+	return nil
+}
+
+// TODO: https://github.com/Kong/kubernetes-ingress-controller/issues/6026
+func resolveKongServiceFacadeDependencies(_ store.CacheStores, _ *incubatorv1alpha1.KongServiceFacade) []client.Object {
+	return nil
+}
diff --git a/internal/dataplane/kongstate/customentity.go b/internal/dataplane/kongstate/customentity.go
new file mode 100644
index 000000000..de9fadf1d
--- /dev/null
+++ b/internal/dataplane/kongstate/customentity.go
@@ -0,0 +1,91 @@
+package kongstate
+
+import "github.com/kong/go-kong/kong"
+
+// EntityFieldType represents type of a Kong entity field.
+// possible field types include boolean, integer, number, string, array, set, map, record, json, foreign.
+type EntityFieldType string
+
+// These types and field properties are defined upstream in the Kong DAO:
+// https://github.com/Kong/kong/blob/3.6.1/kong/db/schema/init.lua#L131-L143
+// https://docs.konghq.com/gateway/latest/plugin-development/custom-entities/#define-a-schema
+
+const (
+	EntityFieldTypeBoolean EntityFieldType = "boolean"
+	EntityFieldTypeInteger EntityFieldType = "integer"
+	EntityFieldTypeNumber  EntityFieldType = "number"
+	EntityFieldTypeString  EntityFieldType = "string"
+	EntityFieldTypeSet     EntityFieldType = "set"
+	EntityFieldTypeArray   EntityFieldType = "array"
+	EntityFieldTypeMap     EntityFieldType = "map"
+	EntityFieldTypeRecord  EntityFieldType = "record"
+	EntityFieldTypeJSON    EntityFieldType = "json"
+	// EntityFieldTypeForeign means that this field refers to another entity by the key (typically ID).
+	EntityFieldTypeForeign EntityFieldType = "foreign"
+)
+
+type EntityField struct {
+	// Name is the name of the field.
+	Name string `json:"name"`
+	// Type stands for the type of the field.
+	Type EntityFieldType `json:"type"`
+	// Required is true means that the field must present in the entity.
+	Required bool `json:"required,omitempty"`
+	// Auto is true means that the field is automatically generated when it is created in Kong gateway.
+	Auto bool `json:"auto,omitempty"`
+	// UUID is true means that the field is in UUID format.
+	UUID bool `json:"uuid,omitempty"`
+	// Default is the default value of the field when it is not given.
+	Default interface{} `json:"default,omitempty"`
+	// Reference is the type referring entity when the field is "foreign" to refer to another entity.
+	Reference string `json:"reference,omitempty"`
+	// Other attributes in field metadata that do not affect validation and translation are omitted.
+}
+
+// EntitySchema is the schema of an entity.
+type EntitySchema struct {
+	Fields map[string]EntityField
+}
+
+// ExtractEntityFieldDefinitions extracts the fields in response of retrieving entity schema from Kong gateway
+// and fill the definition of each field in the `Fields` map of returning value.
+func ExtractEntityFieldDefinitions(schema kong.Schema) EntitySchema {
+	retSchema := EntitySchema{
+		Fields: make(map[string]EntityField),
+	}
+
+	fieldList, ok := schema["fields"].([]interface{})
+	if !ok {
+		return retSchema
+	}
+	for _, item := range fieldList {
+		fieldDef, ok := item.(map[string]interface{})
+		if !ok {
+			continue
+		}
+		for fieldName, fieldAttributes := range fieldDef {
+			fieldAttributesMap, ok := fieldAttributes.(map[string]interface{})
+			if !ok {
+				continue
+			}
+
+			fieldTypeStr, _ := fieldAttributesMap["type"].(string)
+			fieldAuto, _ := fieldAttributesMap["auto"].(bool)
+			fieldIsUUID, _ := fieldAttributesMap["uuid"].(bool)
+			fieldRequired, _ := fieldAttributesMap["required"].(bool)
+			fieldReference, _ := fieldAttributesMap["reference"].(string)
+
+			f := EntityField{
+				Name:      fieldName,
+				Type:      EntityFieldType(fieldTypeStr),
+				Auto:      fieldAuto,
+				UUID:      fieldIsUUID,
+				Required:  fieldRequired,
+				Default:   fieldAttributesMap["default"],
+				Reference: fieldReference,
+			}
+			retSchema.Fields[fieldName] = f
+		}
+	}
+	return retSchema
+}
diff --git a/internal/store/cache_stores_snapshot.go b/internal/store/cache_stores_snapshot.go
index 144e90e12..b58263df3 100644
--- a/internal/store/cache_stores_snapshot.go
+++ b/internal/store/cache_stores_snapshot.go
@@ -65,7 +65,7 @@ func (c CacheStores) TakeSnapshotIfChanged(previousSnapshotHash SnapshotHash) (
 ) {
 	// Initialize all variables that don't need to be guarded by a lock.
 	snapshot = NewCacheStores()
-	listOfStores := c.listAllStores()
+	listOfStores := c.ListAllStores()
 	accessor := meta.NewAccessor()
 	hashCalculator := newHashCalculator()
 
diff --git a/internal/store/store.go b/internal/store/store.go
index 17c237a49..62574c4fe 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -711,6 +711,8 @@ func mkObjFromGVK(gvk schema.GroupVersionKind) (runtime.Object, error) {
 	// ----------------------------------------------------------------------------
 	case netv1.SchemeGroupVersion.WithKind("Ingress"):
 		return &netv1.Ingress{}, nil
+	case netv1.SchemeGroupVersion.WithKind("IngressClass"):
+		return &netv1.IngressClass{}, nil
 	case corev1.SchemeGroupVersion.WithKind("Service"):
 		return &corev1.Service{}, nil
 	case corev1.SchemeGroupVersion.WithKind("Secret"):
diff --git a/internal/store/zz_generated_cache_stores.go b/internal/store/zz_generated_cache_stores.go
index 16200da64..2df2e6615 100644
--- a/internal/store/zz_generated_cache_stores.go
+++ b/internal/store/zz_generated_cache_stores.go
@@ -11,6 +11,7 @@ import (
 	netv1 "k8s.io/api/networking/v1"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/client-go/tools/cache"
+	"sigs.k8s.io/controller-runtime/pkg/client"
 
 	"github.com/kong/kubernetes-ingress-controller/v3/internal/gatewayapi"
 	kongv1 "github.com/kong/kubernetes-ingress-controller/v3/pkg/apis/configuration/v1"
@@ -280,8 +281,8 @@ func (c CacheStores) ListAllStores() []cache.Store {
 }
 
 // SupportedTypes returns a list of supported types for the cache.
-func (c CacheStores) SupportedTypes() []runtime.Object {
-	return []runtime.Object{
+func (c CacheStores) SupportedTypes() []client.Object {
+	return []client.Object{
 		&netv1.Ingress{},
 		&netv1.IngressClass{},
 		&corev1.Service{},


Test patch:
diff --git a/.github/test_dependencies.yaml b/.github/test_dependencies.yaml
index fc79f72f0..b9e73afac 100644
--- a/.github/test_dependencies.yaml
+++ b/.github/test_dependencies.yaml
@@ -1,13 +1,13 @@
 e2e:
   kind:
     # renovate: datasource=docker depName=kindest/node versioning=docker
-    - 'v1.29.2'
+    - 'v1.30.0'
     # renovate: datasource=docker depName=kindest/node@only-patch packageName=kindest/node versioning=docker
-    - 'v1.28.7'
+    - 'v1.28.9'
     # renovate: datasource=docker depName=kindest/node@only-patch packageName=kindest/node versioning=docker
-    - 'v1.27.11'
+    - 'v1.27.13'
     # renovate: datasource=docker depName=kindest/node@only-patch packageName=kindest/node versioning=docker
-    - 'v1.26.14'
+    - 'v1.26.15'
     # renovate: datasource=docker depName=kindest/node@only-patch packageName=kindest/node versioning=docker
     - 'v1.25.16'
   gke:
@@ -18,19 +18,19 @@ e2e:
   # used directly in the test matrix `include` section.
   istio:
     - # renovate: datasource=docker depName=kindest/node versioning=docker
-      kind: 'v1.29.2'
+      kind: 'v1.30.0'
       # renovate: datasource=docker depName=istio/istioctl versioning=docker
       istio: '1.22.0'
     - # renovate: datasource=docker depName=kindest/node@only-patch packageName=kindest/node versioning=docker
-      kind: 'v1.28.7'
+      kind: 'v1.28.9'
       # renovate: datasource=docker depName=istio/istioctl@only-patch packageName=istio/istioctl versioning=docker
       istio: '1.19.10'
     - # renovate: datasource=docker depName=kindest/node@only-patch packageName=kindest/node versioning=docker
-      kind: 'v1.27.11'
+      kind: 'v1.27.13'
       # renovate: datasource=docker depName=istio/istioctl@only-patch packageName=istio/istioctl versioning=docker
       istio: '1.18.7'
     - # renovate: datasource=docker depName=kindest/node@only-patch packageName=kindest/node versioning=docker
-      kind: 'v1.26.14'
+      kind: 'v1.26.15'
       # renovate: datasource=docker depName=istio/istioctl@only-patch packageName=istio/istioctl versioning=docker
       istio: '1.17.8'
 
@@ -42,7 +42,7 @@ integration:
     # renovate: datasource=helm depName=kong registryUrl=https://charts.konghq.com versioning=helm
     kong: '2.38.0'
   # renovate: datasource=docker depName=kindest/node versioning=docker
-  kind: 'v1.29.2'
+  kind: 'v1.30.0'
   # renovate: datasource=docker depName=kong versioning=docker
   kong-oss: '3.6.1'
   # renovate: datasource=docker depName=kong/kong-gateway versioning=docker
diff --git a/.github/workflows/_envtest_tests.yaml b/.github/workflows/_envtest_tests.yaml
index 49a76b59c..882522039 100644
--- a/.github/workflows/_envtest_tests.yaml
+++ b/.github/workflows/_envtest_tests.yaml
@@ -20,10 +20,17 @@ jobs:
         with:
           install: false
 
+      - uses: Kong/kong-license@master
+        id: license
+        with:
+          op-token: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
+
       - name: run envtest tests
         run: make test.envtest.pretty
         env:
           GOTESTSUM_JUNITFILE: envtest-tests.xml
+          TEST_KONG_ENTERPRISE: "true"
+          KONG_LICENSE_DATA: ${{ steps.license.outputs.license }}
 
       - name: collect test coverage
         uses: actions/upload-artifact@v4
diff --git a/internal/admission/server_test.go b/internal/admission/server_test.go
index 01afbf419..076e3c27b 100644
--- a/internal/admission/server_test.go
+++ b/internal/admission/server_test.go
@@ -82,6 +82,10 @@ func (v KongFakeValidator) ValidateVault(_ context.Context, _ kongv1alpha1.KongV
 	return v.Result, v.Message, v.Error
 }
 
+func (v KongFakeValidator) ValidateCustomEntity(_ context.Context, _ kongv1alpha1.KongCustomEntity) (bool, string, error) {
+	return v.Result, v.Message, v.Error
+}
+
 func (v KongFakeValidator) ValidateService(_ context.Context, _ corev1.Service) (bool, string, error) {
 	return v.Result, v.Message, v.Error
 }
diff --git a/internal/admission/validator_test.go b/internal/admission/validator_test.go
index 95aac2362..6a5d83c1d 100644
--- a/internal/admission/validator_test.go
+++ b/internal/admission/validator_test.go
@@ -2,6 +2,7 @@ package admission
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"net/http"
 	"testing"
@@ -23,6 +24,7 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/client/fake"
 
 	"github.com/kong/kubernetes-ingress-controller/v3/internal/annotations"
+	"github.com/kong/kubernetes-ingress-controller/v3/internal/dataplane/kongstate"
 	"github.com/kong/kubernetes-ingress-controller/v3/internal/dataplane/translator"
 	managerscheme "github.com/kong/kubernetes-ingress-controller/v3/internal/manager/scheme"
 	"github.com/kong/kubernetes-ingress-controller/v3/internal/store"
@@ -64,6 +66,7 @@ type fakeServicesProvider struct {
 	infoSvc          kong.AbstractInfoService
 	routeSvc         kong.AbstractRouteService
 	vaultSvc         kong.AbstractVaultService
+	schemaSvc        kong.AbstractSchemaService
 }
 
 func (f fakeServicesProvider) GetConsumersService() (kong.AbstractConsumerService, bool) {
@@ -108,6 +111,13 @@ func (f fakeServicesProvider) GetVaultsService() (kong.AbstractVaultService, boo
 	return nil, false
 }
 
+func (f fakeServicesProvider) GetSchemasService() (kong.AbstractSchemaService, bool) {
+	if f.schemaSvc != nil {
+		return f.schemaSvc, true
+	}
+	return nil, false
+}
+
 func TestKongHTTPValidator_ValidatePlugin(t *testing.T) {
 	store, _ := store.NewFakeStore(store.FakeObjects{
 		Secrets: []*corev1.Secret{
@@ -1413,9 +1423,159 @@ type fakeVaultSvc struct {
 	shouldFail bool
 }
 
-func (s fakeVaultSvc) Validate(context.Context, *kong.Vault) (bool, string, error) {
+func (s fakeVaultSvc) Validate(_ context.Context, _ *kong.Vault) (bool, string, error) {
 	if s.shouldFail {
 		return false, "something is wrong with the vault", nil
 	}
 	return true, "", nil
 }
+
+func TestValidator_ValidateCustomEntity(t *testing.T) {
+	testCases := []struct {
+		name            string
+		entity          kongv1alpha1.KongCustomEntity
+		fields          map[string]kongstate.EntityField
+		entityTypeExist bool
+		validateSvcFail bool
+		expectedOK      bool
+		expectedMessage string
+	}{
+		{
+			name: "entity with non-exist type should fail the validation",
+			entity: kongv1alpha1.KongCustomEntity{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "entity_non_exist",
+				},
+				Spec: kongv1alpha1.KongCustomEntitySpec{
+					EntityType: "non_exist",
+					Fields:     apiextensionsv1.JSON{Raw: []byte("{}")},
+				},
+			},
+			entityTypeExist: false,
+			expectedOK:      false,
+			expectedMessage: "entity type does not exist",
+		},
+		{
+			name: "valid entity",
+			entity: kongv1alpha1.KongCustomEntity{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "valid_entity",
+				},
+				Spec: kongv1alpha1.KongCustomEntitySpec{
+					EntityType: "entity_type_ok",
+					Fields:     apiextensionsv1.JSON{Raw: []byte(`{"foo":"bar"}`)},
+				},
+			},
+			fields: map[string]kongstate.EntityField{
+				"foo": {
+					Name: "foo",
+					Type: kongstate.EntityFieldTypeString,
+				},
+			},
+			entityTypeExist: true,
+			expectedOK:      true,
+		},
+		{
+			name: "valid entity with foreign key",
+			entity: kongv1alpha1.KongCustomEntity{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "entity_with_foreign",
+				},
+				Spec: kongv1alpha1.KongCustomEntitySpec{
+					EntityType: "entity_type_with_foreign",
+					Fields:     apiextensionsv1.JSON{Raw: []byte(`{"foo":"bar"}`)},
+				},
+			},
+			fields: map[string]kongstate.EntityField{
+				"foo": {
+					Name: "foo",
+					Type: kongstate.EntityFieldTypeString,
+				},
+				"baz": {
+					Name:      "baz",
+					Type:      kongstate.EntityFieldTypeForeign,
+					Required:  true,
+					Reference: "service",
+				},
+			},
+			entityTypeExist: true,
+			expectedOK:      true,
+		},
+		{
+			name: "validate service fail",
+			entity: kongv1alpha1.KongCustomEntity{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "validate_service_fail",
+				},
+				Spec: kongv1alpha1.KongCustomEntitySpec{
+					EntityType: "entity_type_ok",
+					Fields:     apiextensionsv1.JSON{Raw: []byte(`{"foo":"bar"}`)},
+				},
+			},
+			fields: map[string]kongstate.EntityField{
+				"foo": {
+					Name: "foo",
+					Type: kongstate.EntityFieldTypeString,
+				},
+			},
+			entityTypeExist: true,
+			validateSvcFail: true,
+			expectedOK:      false,
+			expectedMessage: "something is wrong in the entity",
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			fieldList := make([]interface{}, 0, len(tc.fields))
+			for _, field := range tc.fields {
+				fieldList = append(fieldList, map[string]interface{}{
+					field.Name: map[string]interface{}{
+						"type":      string(field.Type),
+						"required":  field.Required,
+						"reference": field.Reference,
+					},
+				})
+			}
+
+			validator := KongHTTPValidator{
+				AdminAPIServicesProvider: fakeServicesProvider{
+					schemaSvc: fakeSchemaSvc{
+						entityTypeExist: tc.entityTypeExist,
+						schema: map[string]interface{}{
+							"fields": fieldList,
+						},
+						shouldFail: tc.validateSvcFail,
+					},
+				},
+				ingressClassMatcher: fakeClassMatcher,
+			}
+			ok, msg, err := validator.ValidateCustomEntity(context.Background(), tc.entity)
+			require.NoError(t, err)
+			assert.Equal(t, tc.expectedOK, ok)
+			assert.Contains(t, msg, tc.expectedMessage)
+		})
+	}
+}
+
+type fakeSchemaSvc struct {
+	schema          map[string]interface{}
+	entityTypeExist bool
+	shouldFail      bool
+}
+
+var _ kong.AbstractSchemaService = fakeSchemaSvc{}
+
+func (s fakeSchemaSvc) Get(_ context.Context, _ string) (kong.Schema, error) {
+	if !s.entityTypeExist {
+		return nil, errors.New("entity type does not exist")
+	}
+	return s.schema, nil
+}
+
+func (s fakeSchemaSvc) Validate(_ context.Context, _ kong.EntityType, _ interface{}) (bool, string, error) {
+	if s.shouldFail {
+		return false, "something is wrong in the entity", nil
+	}
+	return true, "", nil
+}
diff --git a/internal/dataplane/fallback/graph_dependencies_ingress_test.go b/internal/dataplane/fallback/graph_dependencies_ingress_test.go
new file mode 100644
index 000000000..0e952a8ef
--- /dev/null
+++ b/internal/dataplane/fallback/graph_dependencies_ingress_test.go
@@ -0,0 +1,244 @@
+package fallback_test
+
+import (
+	"testing"
+
+	"github.com/samber/lo"
+	corev1 "k8s.io/api/core/v1"
+	netv1 "k8s.io/api/networking/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	"github.com/kong/kubernetes-ingress-controller/v3/internal/annotations"
+	incubatorv1alpha1 "github.com/kong/kubernetes-ingress-controller/v3/pkg/apis/incubator/v1alpha1"
+)
+
+func TestResolveDependencies_Ingress(t *testing.T) {
+	testCases := []resolveDependenciesTestCase{
+		{
+			name: "no dependencies",
+			object: &netv1.Ingress{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test-ingress",
+					Namespace: "test-namespace",
+				},
+			},
+			cache: cacheStoresFromObjs(t,
+				testIngressClass(t, "1"),
+				testService(t, "1"),
+				testKongServiceFacade(t, "1"),
+				testKongPlugin(t, "1"),
+				testKongClusterPlugin(t, "1"),
+			),
+			expected: []client.Object{},
+		},
+		{
+			name: "Ingress -> IngressClass - annotation",
+			object: &netv1.Ingress{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test-ingress",
+					Namespace: "test-namespace",
+					Annotations: map[string]string{
+						annotations.IngressClassKey: "1",
+					},
+				},
+			},
+			cache: cacheStoresFromObjs(t,
+				testIngressClass(t, "1"),
+				testIngressClass(t, "2"),
+			),
+			expected: []client.Object{testIngressClass(t, "1")},
+		},
+		{
+			name: "Ingress -> IngressClass - field",
+			object: &netv1.Ingress{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test-ingress",
+					Namespace: "test-namespace",
+				},
+				Spec: netv1.IngressSpec{
+					IngressClassName: lo.ToPtr("1"),
+				},
+			},
+			cache: cacheStoresFromObjs(t,
+				testIngressClass(t, "1"),
+				testIngressClass(t, "2"),
+			),
+			expected: []client.Object{testIngressClass(t, "1")},
+		},
+		{
+			name: "Ingress -> Service",
+			object: &netv1.Ingress{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test-ingress",
+					Namespace: "test-namespace",
+				},
+				Spec: netv1.IngressSpec{
+					Rules: []netv1.IngressRule{
+						{
+							IngressRuleValue: netv1.IngressRuleValue{
+								HTTP: &netv1.HTTPIngressRuleValue{
+									Paths: []netv1.HTTPIngressPath{
+										{
+											Backend: netv1.IngressBackend{
+												Service: &netv1.IngressServiceBackend{
+													Name: "1",
+												},
+											},
+										},
+										{
+											Backend: netv1.IngressBackend{
+												Service: &netv1.IngressServiceBackend{
+													Name: "2",
+												},
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+			cache: cacheStoresFromObjs(t,
+				testService(t, "1"),
+				testService(t, "2"),
+				testService(t, "3"),
+			),
+			expected: []client.Object{
+				testService(t, "1"),
+				testService(t, "2"),
+			},
+		},
+		{
+			name: "Ingress -> KongServiceFacade",
+			object: &netv1.Ingress{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test-ingress",
+					Namespace: "test-namespace",
+				},
+				Spec: netv1.IngressSpec{
+					Rules: []netv1.IngressRule{
+						{
+							IngressRuleValue: netv1.IngressRuleValue{
+								HTTP: &netv1.HTTPIngressRuleValue{
+									Paths: []netv1.HTTPIngressPath{
+										{
+											Backend: netv1.IngressBackend{
+												Resource: &corev1.TypedLocalObjectReference{
+													Name:     "1",
+													Kind:     "KongServiceFacade",
+													APIGroup: lo.ToPtr(incubatorv1alpha1.SchemeGroupVersion.Group),
+												},
+											},
+										},
+										{
+											Backend: netv1.IngressBackend{
+												Resource: &corev1.TypedLocalObjectReference{
+													Name:     "2",
+													Kind:     "KongServiceFacade",
+													APIGroup: lo.ToPtr(incubatorv1alpha1.SchemeGroupVersion.Group),
+												},
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+			cache: cacheStoresFromObjs(t,
+				testKongServiceFacade(t, "1"),
+				testKongServiceFacade(t, "2"),
+				testKongServiceFacade(t, "3"),
+			),
+			expected: []client.Object{
+				testKongServiceFacade(t, "1"),
+				testKongServiceFacade(t, "2"),
+			},
+		},
+		{
+			name: "Ingress -> KongPlugin, KongClusterPlugin",
+			object: &netv1.Ingress{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test-ingress",
+					Namespace: "test-namespace",
+					Annotations: map[string]string{
+						annotations.AnnotationPrefix + annotations.PluginsKey: "1,2,cluster-1,cluster-2",
+					},
+				},
+			},
+			cache: cacheStoresFromObjs(t,
+				testKongPlugin(t, "1"),
+				testKongPlugin(t, "2"),
+				testKongPlugin(t, "3"),
+				testKongClusterPlugin(t, "cluster-1"),
+				testKongClusterPlugin(t, "cluster-2"),
+				testKongClusterPlugin(t, "cluster-3"),
+			),
+			expected: []client.Object{
+				testKongPlugin(t, "1"),
+				testKongPlugin(t, "2"),
+				testKongClusterPlugin(t, "cluster-1"),
+				testKongClusterPlugin(t, "cluster-2"),
+			},
+		},
+		{
+			name: "Ingress -> all dependencies at once",
+			object: &netv1.Ingress{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test-ingress",
+					Namespace: "test-namespace",
+					Annotations: map[string]string{
+						annotations.AnnotationPrefix + annotations.PluginsKey: "1,cluster-1",
+					},
+				},
+				Spec: netv1.IngressSpec{
+					Rules: []netv1.IngressRule{
+						{
+							IngressRuleValue: netv1.IngressRuleValue{
+								HTTP: &netv1.HTTPIngressRuleValue{
+									Paths: []netv1.HTTPIngressPath{
+										{
+											Backend: netv1.IngressBackend{
+												Resource: &corev1.TypedLocalObjectReference{
+													Name:     "1",
+													Kind:     "KongServiceFacade",
+													APIGroup: lo.ToPtr(incubatorv1alpha1.SchemeGroupVersion.Group),
+												},
+											},
+										},
+										{
+											Backend: netv1.IngressBackend{
+												Service: &netv1.IngressServiceBackend{
+													Name: "1",
+												},
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+			cache: cacheStoresFromObjs(t,
+				testKongPlugin(t, "1"),
+				testKongClusterPlugin(t, "cluster-1"),
+				testService(t, "1"),
+				testKongServiceFacade(t, "1"),
+			),
+			expected: []client.Object{
+				testKongPlugin(t, "1"),
+				testKongClusterPlugin(t, "cluster-1"),
+				testService(t, "1"),
+				testKongServiceFacade(t, "1"),
+			},
+		},
+	}
+
+	for _, tc := range testCases {
+		runResolveDependenciesTest(t, tc)
+	}
+}
diff --git a/internal/dataplane/fallback/graph_dependencies_test.go b/internal/dataplane/fallback/graph_dependencies_test.go
new file mode 100644
index 000000000..84b65eb0a
--- /dev/null
+++ b/internal/dataplane/fallback/graph_dependencies_test.go
@@ -0,0 +1,62 @@
+package fallback_test
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"k8s.io/apimachinery/pkg/runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	"github.com/kong/kubernetes-ingress-controller/v3/internal/dataplane/fallback"
+	"github.com/kong/kubernetes-ingress-controller/v3/internal/store"
+	"github.com/kong/kubernetes-ingress-controller/v3/test/helpers"
+)
+
+// resolveDependenciesTestCase is a test case for the ResolveDependencies function.
+type resolveDependenciesTestCase struct {
+	name     string
+	object   client.Object
+	cache    store.CacheStores
+	expected []client.Object
+}
+
+func runResolveDependenciesTest(t *testing.T, tc resolveDependenciesTestCase) {
+	t.Run(tc.name, func(t *testing.T) {
+		t.Run("when cache empty, does not panic and gives no dependencies", func(t *testing.T) {
+			require.NotPanics(t, func() {
+				dependencies, err := fallback.ResolveDependencies(store.NewCacheStores(), tc.object)
+				require.NoError(t, err)
+				require.Empty(t, dependencies, "expect no dependencies found in an empty cache")
+			})
+		})
+		t.Run("when cache has objects, resolves dependencies as expected", func(t *testing.T) {
+			dependencies, err := fallback.ResolveDependencies(tc.cache, tc.object)
+			require.NoError(t, err)
+			require.ElementsMatch(t, tc.expected, dependencies)
+		})
+	})
+}
+
+// cacheStoresFromObjs creates a CacheStores from the given objects.
+// It assigns each object a type meta using the scheme.
+func cacheStoresFromObjs(t *testing.T, objs ...runtime.Object) store.CacheStores {
+	for i := range objs {
+		obj := objs[i].(client.Object)
+		obj = helpers.WithTypeMeta(t, obj)
+		objs[i] = obj
+	}
+	s, err := store.NewCacheStoresFromObjs(objs...)
+	require.NoError(t, err)
+	return s
+}
+
+func TestResolveDependencies_ImplementedForAllSupportedCacheStoresTypes(t *testing.T) {
+	cache := store.NewCacheStores()
+	for _, supportedType := range cache.SupportedTypes() {
+		_, err := fallback.ResolveDependencies(cache, supportedType)
+		assert.NoErrorf(t, err, "ResolveDependencies not implemented for %T. If your object type does not have any dependencies,"+
+			"please add it to the ResolveDependencies' switch-case with object types having no dependencies. Otherwise, please add a new"+
+			"switch-case for your type and implement resolve{YourType}Dependencies function.", supportedType)
+	}
+}
diff --git a/internal/dataplane/fallback/graph_test.go b/internal/dataplane/fallback/graph_test.go
new file mode 100644
index 000000000..4a7de5736
--- /dev/null
+++ b/internal/dataplane/fallback/graph_test.go
@@ -0,0 +1,123 @@
+package fallback_test
+
+import (
+	"sort"
+	"testing"
+
+	"github.com/samber/lo"
+	"github.com/stretchr/testify/require"
+	corev1 "k8s.io/api/core/v1"
+	netv1 "k8s.io/api/networking/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	"github.com/kong/kubernetes-ingress-controller/v3/internal/dataplane/fallback"
+	"github.com/kong/kubernetes-ingress-controller/v3/internal/store"
+	incubatorv1alpha1 "github.com/kong/kubernetes-ingress-controller/v3/pkg/apis/incubator/v1alpha1"
+)
+
+func TestNewConfigGraphFromCacheStores(t *testing.T) {
+	// adjacencyGraphStrings returns a map of stringified vertices and their neighbours
+	// in the given graph for easy comparison.
+	adjacencyGraphStrings := func(t *testing.T, g *fallback.ConfigGraph) map[string][]string {
+		am, err := g.AdjacencyMap()
+		require.NoError(t, err)
+		adjacencyMapStrings := make(map[string][]string, len(am))
+		for v, neighbours := range am {
+			neighboursStrings := lo.Map(neighbours, func(n fallback.ObjectHash, _ int) string {
+				return n.String()
+			})
+			sort.Strings(neighboursStrings) // Sort for deterministic output.
+			adjacencyMapStrings[v.String()] = neighboursStrings
+		}
+		return adjacencyMapStrings
+	}
+
+	testCases := []struct {
+		name                 string
+		cache                store.CacheStores
+		expectedAdjacencyMap map[string][]string
+	}{
+		{
+			name:                 "empty cache",
+			cache:                store.NewCacheStores(),
+			expectedAdjacencyMap: map[string][]string{},
+		},
+		{
+			name: "cache with Ingress and its dependencies",
+			cache: cacheStoresFromObjs(t,
+				&netv1.Ingress{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "test-ingress",
+						Namespace: "test-namespace",
+					},
+					Spec: netv1.IngressSpec{
+						IngressClassName: lo.ToPtr("test-ingress-class"),
+						Rules: []netv1.IngressRule{
+							{
+								IngressRuleValue: netv1.IngressRuleValue{
+									HTTP: &netv1.HTTPIngressRuleValue{
+										Paths: []netv1.HTTPIngressPath{
+											{
+												Backend: netv1.IngressBackend{
+													Service: &netv1.IngressServiceBackend{
+														Name: "test-service",
+													},
+												},
+											},
+											{
+												Backend: netv1.IngressBackend{
+													Resource: &corev1.TypedLocalObjectReference{
+														Name:     "test-kong-service-facade",
+														Kind:     "KongServiceFacade",
+														APIGroup: lo.ToPtr(incubatorv1alpha1.GroupVersion.Group),
+													},
+												},
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+				&netv1.IngressClass{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "test-ingress-class",
+					},
+				},
+				&corev1.Service{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "test-service",
+						Namespace: "test-namespace",
+					},
+				},
+				&incubatorv1alpha1.KongServiceFacade{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "test-kong-service-facade",
+						Namespace: "test-namespace",
+					},
+				},
+			),
+			expectedAdjacencyMap: map[string][]string{
+				"Ingress:test-namespace/test-ingress": {},
+				"IngressClass:test-ingress-class": {
+					"Ingress:test-namespace/test-ingress",
+				},
+				"Service:test-namespace/test-service": {
+					"Ingress:test-namespace/test-ingress",
+				},
+				"KongServiceFacade:test-namespace/test-kong-service-facade": {
+					"Ingress:test-namespace/test-ingress",
+				},
+			},
+		},
+	}
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			g, err := fallback.NewConfigGraphFromCacheStores(tc.cache)
+			require.NoError(t, err)
+			require.NotNil(t, g)
+			require.Equal(t, tc.expectedAdjacencyMap, adjacencyGraphStrings(t, g))
+		})
+	}
+}
diff --git a/internal/dataplane/fallback/helpers_test.go b/internal/dataplane/fallback/helpers_test.go
new file mode 100644
index 000000000..1e39d28d6
--- /dev/null
+++ b/internal/dataplane/fallback/helpers_test.go
@@ -0,0 +1,61 @@
+package fallback_test
+
+import (
+	"testing"
+
+	corev1 "k8s.io/api/core/v1"
+	netv1 "k8s.io/api/networking/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	kongv1 "github.com/kong/kubernetes-ingress-controller/v3/pkg/apis/configuration/v1"
+	incubatorv1alpha1 "github.com/kong/kubernetes-ingress-controller/v3/pkg/apis/incubator/v1alpha1"
+	"github.com/kong/kubernetes-ingress-controller/v3/test/helpers"
+)
+
+const (
+	testNamespace = "test-namespace"
+)
+
+func testIngressClass(t *testing.T, name string) *netv1.IngressClass {
+	return helpers.WithTypeMeta(t, &netv1.IngressClass{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: name,
+		},
+	})
+}
+
+func testService(t *testing.T, name string) *corev1.Service {
+	return helpers.WithTypeMeta(t, &corev1.Service{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      name,
+			Namespace: testNamespace,
+		},
+	})
+}
+
+func testKongServiceFacade(t *testing.T, name string) *incubatorv1alpha1.KongServiceFacade {
+	return helpers.WithTypeMeta(t, &incubatorv1alpha1.KongServiceFacade{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      name,
+			Namespace: testNamespace,
+		},
+	})
+}
+
+func testKongPlugin(t *testing.T, name string) *kongv1.KongPlugin {
+	return helpers.WithTypeMeta(t, &kongv1.KongPlugin{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      name,
+			Namespace: testNamespace,
+		},
+	})
+}
+
+func testKongClusterPlugin(t *testing.T, name string) *kongv1.KongClusterPlugin {
+	return helpers.WithTypeMeta(t, &kongv1.KongClusterPlugin{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      name,
+			Namespace: testNamespace,
+		},
+	})
+}
diff --git a/internal/dataplane/kongstate/customentity_test.go b/internal/dataplane/kongstate/customentity_test.go
new file mode 100644
index 000000000..ff754de6d
--- /dev/null
+++ b/internal/dataplane/kongstate/customentity_test.go
@@ -0,0 +1,100 @@
+package kongstate
+
+import (
+	"testing"
+
+	"github.com/kong/go-kong/kong"
+	"github.com/stretchr/testify/require"
+)
+
+func TestExtractEntityFieldDefinitions(t *testing.T) {
+	testCases := []struct {
+		name           string
+		schema         kong.Schema
+		expectedFields map[string]EntityField
+	}{
+		{
+			name: "absent fields should have a nil value",
+			schema: map[string]interface{}{
+				"fields": []interface{}{
+					map[string]interface{}{
+						"foo": map[string]interface{}{
+							"type":     "string",
+							"required": true,
+						},
+					},
+					map[string]interface{}{
+						"bar": map[string]interface{}{
+							"type":      "foreign",
+							"required":  true,
+							"reference": "service",
+						},
+					},
+				},
+			},
+			expectedFields: map[string]EntityField{
+				"foo": {
+					Name:     "foo",
+					Type:     EntityFieldTypeString,
+					Required: true,
+					Auto:     false,
+					UUID:     false,
+				},
+				"bar": {
+					Name:      "bar",
+					Type:      EntityFieldTypeForeign,
+					Required:  true,
+					Reference: "service",
+				},
+			},
+		},
+		{
+			name: "irrelevant fields should be safely ignored",
+			schema: map[string]interface{}{
+				"fields": []interface{}{
+					map[string]interface{}{
+						"protocol": map[string]interface{}{
+							"type":     "string",
+							"required": true,
+							"default":  "http",
+							"one_of":   []string{"http", "https"},
+						},
+						"port": map[string]interface{}{
+							"type":     "integer",
+							"required": true,
+							"default":  80,
+							"min":      1,
+							"max":      65535,
+						},
+					},
+				},
+				"checks": "some_check",
+			},
+			expectedFields: map[string]EntityField{
+				"protocol": {
+					Name:     "protocol",
+					Type:     EntityFieldTypeString,
+					Required: true,
+					Default:  "http",
+				},
+				"port": {
+					Name:     "port",
+					Type:     EntityFieldTypeInteger,
+					Required: true,
+					Default:  int(80),
+				},
+			},
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			fields := ExtractEntityFieldDefinitions(tc.schema).Fields
+			for fieldName, expectedField := range tc.expectedFields {
+				actualField, ok := fields[fieldName]
+				require.Truef(t, ok, "field %s should exist", fieldName)
+				require.Equalf(t, expectedField, actualField, "field %s should be same as expected", fieldName)
+			}
+		})
+	}
+}
diff --git a/test/envtest/admission_webhook_envtest_test.go b/test/envtest/admission_webhook_envtest_test.go
index fc86115f3..d13deb277 100644
--- a/test/envtest/admission_webhook_envtest_test.go
+++ b/test/envtest/admission_webhook_envtest_test.go
@@ -27,6 +27,7 @@ import (
 	kongv1alpha1 "github.com/kong/kubernetes-ingress-controller/v3/pkg/apis/configuration/v1alpha1"
 	"github.com/kong/kubernetes-ingress-controller/v3/test/helpers"
 	"github.com/kong/kubernetes-ingress-controller/v3/test/helpers/certificate"
+	"github.com/kong/kubernetes-ingress-controller/v3/test/internal/testenv"
 )
 
 func TestAdmissionWebhook_KongVault(t *testing.T) {
@@ -1192,6 +1193,160 @@ func TestAdmissionWebhook_SecretCredentials(t *testing.T) {
 	})
 }
 
+func TestAdmissionWebhook_KongCustomEntities(t *testing.T) {
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+
+	var (
+		scheme           = Scheme(t, WithKong)
+		envcfg           = Setup(t, scheme)
+		ctrlClientGlobal = NewControllerClient(t, scheme, envcfg)
+		ns               = CreateNamespace(ctx, t, ctrlClientGlobal)
+		ctrlClient       = client.NewNamespacedClient(ctrlClientGlobal, ns.Name)
+
+		webhookCert, webhookKey = certificate.MustGenerateSelfSignedCertPEMFormat(
+			certificate.WithDNSNames("localhost"),
+		)
+		admissionWebhookPort = helpers.GetFreePort(t)
+
+		kongContainer = runKongEnterprise(ctx, t)
+	)
+
+	_, logs := RunManager(ctx, t, envcfg,
+		AdminAPIOptFns(),
+		WithPublishService(ns.Name),
+		WithAdmissionWebhookEnabled(webhookKey, webhookCert, fmt.Sprintf(":%d", admissionWebhookPort)),
+		WithKongAdminURLs(kongContainer.AdminURL(ctx, t)),
+	)
+	WaitForManagerStart(t, logs)
+	setupValidatingWebhookConfiguration(ctx, t, admissionWebhookPort, webhookCert, ctrlClient)
+
+	testCases := []struct {
+		name                     string
+		requireEnterpriseLicense bool
+		entity                   *kongv1alpha1.KongCustomEntity
+		valid                    bool
+
+		errContains string
+	}{
+		{
+			name: "entity not supported in Kong",
+			entity: &kongv1alpha1.KongCustomEntity{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "not-supported-entity",
+				},
+				Spec: kongv1alpha1.KongCustomEntitySpec{
+					EntityType:     "invalid_entity",
+					ControllerName: annotations.DefaultIngressClass,
+					Fields: apiextensionsv1.JSON{
+						Raw: []byte(`{"key":"value"}`),
+					},
+				},
+			},
+			valid:       false,
+			errContains: "failed to get schema of Kong entity type 'invalid_entity'",
+		},
+		{
+			name: "valid session entity",
+			entity: &kongv1alpha1.KongCustomEntity{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "session-1",
+					Annotations: map[string]string{
+						annotations.IngressClassKey: annotations.DefaultIngressClass,
+					},
+				},
+				Spec: kongv1alpha1.KongCustomEntitySpec{
+					EntityType: "sessions",
+					Fields: apiextensionsv1.JSON{
+						Raw: []byte(`{"session_id":"session1"}`),
+					},
+				},
+			},
+			valid: true,
+		},
+		{
+			name: "invalid session entity",
+			entity: &kongv1alpha1.KongCustomEntity{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "session-1",
+					Annotations: map[string]string{
+						annotations.IngressClassKey: annotations.DefaultIngressClass,
+					},
+				},
+				Spec: kongv1alpha1.KongCustomEntitySpec{
+					EntityType: "sessions",
+					Fields: apiextensionsv1.JSON{
+						Raw: []byte(`{"session_id":"session2","foo":"bar"}`),
+					},
+				},
+			},
+			valid: false,
+		},
+		{
+			name: "valid degraphql_route entity",
+			entity: &kongv1alpha1.KongCustomEntity{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "degraphql-route-1",
+				},
+				Spec: kongv1alpha1.KongCustomEntitySpec{
+					EntityType:     "degraphql_routes",
+					ControllerName: annotations.DefaultIngressClass,
+					Fields: apiextensionsv1.JSON{
+						Raw: []byte(`{"uri":"/me","query":"query{ viewer { login}}"}`),
+					},
+				},
+			},
+			valid: true,
+		},
+		{
+			name: "invalid degraphql_route entity",
+			entity: &kongv1alpha1.KongCustomEntity{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "degraphql-route-1",
+				},
+				Spec: kongv1alpha1.KongCustomEntitySpec{
+					EntityType:     "degraphql_routes",
+					ControllerName: annotations.DefaultIngressClass,
+					Fields: apiextensionsv1.JSON{
+						Raw: []byte(`{"uri":"/me","query":"query{ viewer { login}}","foo":"bar"}`),
+					},
+				},
+			},
+			valid: false,
+		},
+		{
+			name: "KongCustomEntity not controlled by the current controller",
+			entity: &kongv1alpha1.KongCustomEntity{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "unrelated-entity",
+				},
+				Spec: kongv1alpha1.KongCustomEntitySpec{
+					EntityType: "unrelated_entity",
+					Fields: apiextensionsv1.JSON{
+						Raw: []byte("{}"),
+					},
+				},
+			},
+			valid: true,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			if tc.requireEnterpriseLicense && !testenv.KongEnterpriseEnabled() {
+				t.Skip("Skipped because Kong enterprise is not enabled")
+			}
+			err := ctrlClient.Create(ctx, tc.entity)
+			if tc.valid {
+				require.NoError(t, err)
+			} else {
+				require.Error(t, err)
+				require.Contains(t, err.Error(), tc.errContains)
+			}
+		})
+	}
+}
+
 // createKongConsumers creates a provider number of consumers on the cluster.
 // Resources will be created in client's default namespace. When using controller-runtime's
 // client you can specify that by calling client.NewNamespacedClient(client, namespace).
diff --git a/test/helpers/k8s_meta.go b/test/helpers/k8s_meta.go
new file mode 100644
index 000000000..04cdb0125
--- /dev/null
+++ b/test/helpers/k8s_meta.go
@@ -0,0 +1,20 @@
+package helpers
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+	"k8s.io/apimachinery/pkg/runtime"
+
+	"github.com/kong/kubernetes-ingress-controller/v3/internal/manager/scheme"
+	"github.com/kong/kubernetes-ingress-controller/v3/internal/util"
+)
+
+// WithTypeMeta adds type meta to the given object based on its Go type.
+func WithTypeMeta[T runtime.Object](t *testing.T, obj T) T {
+	s, err := scheme.Get()
+	require.NoError(t, err)
+	err = util.PopulateTypeMeta(obj, s)
+	require.NoError(t, err)
+	return obj
+}
diff --git a/test/kongintegration/containers/kong.go b/test/kongintegration/containers/kong.go
index 12467078f..d2d8b31bb 100644
--- a/test/kongintegration/containers/kong.go
+++ b/test/kongintegration/containers/kong.go
@@ -5,6 +5,7 @@ import (
 	"errors"
 	"fmt"
 	"net/url"
+	"os"
 	"testing"
 	"time"
 
@@ -53,6 +54,7 @@ func NewKong(ctx context.Context, t *testing.T, opts ...KongOpt) Kong {
 			"KONG_ADMIN_LISTEN":  fmt.Sprintf("0.0.0.0:%s", kongAdminPort),
 			"KONG_PROXY_LISTEN":  fmt.Sprintf("0.0.0.0:%s", kongProxyPort),
 			"KONG_ROUTER_FLAVOR": defaultRouterFlavor,
+			"KONG_LICENSE_DATA":  os.Getenv("KONG_LICENSE_DATA"),
 		},
 		WaitingFor: wait.ForAll(
 			wait.ForListeningPort(kongAdminPort),