From 066cd7fe41fbcd535dbd0b6a11be157025af3098 Mon Sep 17 00:00:00 2001 From: Ron Gummich Date: Tue, 12 May 2026 17:00:09 +0200 Subject: [PATCH 1/6] feat(admin,organization): add realm claims, rover realm, and refactor TeamApis to ManagedRoutes - Add originZone, originStargate (HardcodedClaim) and clientId (SessionNote) claims to the default identity realm so all tokens carry zone-of-origin metadata. - Create a dedicated "rover" identity realm per zone for internal admin-config clients (InternalIdentityRealm in ZoneStatus). - Replace ApiConfig/TeamApiConfig with ManagedRouteConfig/ManagedRoutesConfig, introducing a required ManagedRouteType field (TeamAPI or Proxy). TeamAPI routes behave as before (auth, no ACL, team-api realm). Proxy routes are pure passthrough on the default gateway realm. - Rename ZoneSpec.TeamApis to ManagedRoutes and ZoneStatus.TeamApiRoutes to ManagedRoutes across admin and organization modules. - Update organization field index to filter on spec.managedRoutes and only match zones with at least one TeamAPI-type route. - Remove emoji from remoteorganization error messages. --- admin/README.md | 2 +- admin/api/v1/zone_types.go | 37 +++-- admin/api/v1/zz_generated.deepcopy.go | 84 ++++++------ .../bases/admin.cp.ei.telekom.de_zones.yaml | 126 +++++++++++------- admin/config/samples/admin_v1_zone.yaml | 5 +- .../controller/zone_controller_test.go | 40 +++++- .../handler/remoteorganization/handler.go | 4 +- admin/internal/handler/util/naming/naming.go | 7 +- admin/internal/handler/util/urls/urls.go | 2 +- admin/internal/handler/zone/handler.go | 124 ++++++++++++----- .../admin/zones/dataplane1.example.yaml | 4 +- .../admin/zones/dataplane2.example.yaml | 4 +- .../controller/team_controller_test.go | 3 +- organization/internal/handler/util/zone.go | 4 +- organization/internal/index/index.go | 19 +-- .../internal/team_webhook_reconciler_test.go | 3 +- .../internal/webhook/v1/mutator/mutate.go | 4 +- .../internal/webhook/v1/team_webhook_test.go | 3 +- 18 files changed, 319 insertions(+), 156 deletions(-) diff --git a/admin/README.md b/admin/README.md index d7ef26009..518e8c5a4 100644 --- a/admin/README.md +++ b/admin/README.md @@ -64,7 +64,7 @@ This CRD represents a physical or logical deployment target with gateway and ide - Each Zone creates its own dedicated namespace (stored in `status.namespace`) for managing related resources. - Zones define gateway configuration, identity provider settings, and Redis connection details. - The `visibility` field controls subscription behavior and can be either `World` or `Enterprise`. -- Zones can optionally define Team APIs through the `teamApis` field, which creates routes on the gateway. +- Zones can optionally define managed routes through the `managedRoutes` field. Each route has a `type`: `TeamAPI` (authenticated, no ACL) or `Proxy` (passthrough reverse proxy). - The Zone controller creates and manages related resources in its handlers. - All managed resources are labeled with both `cp.ei.telekom.de/environment` and `cp.ei.telekom.de/zone` labels. diff --git a/admin/api/v1/zone_types.go b/admin/api/v1/zone_types.go index c1099b128..96f6581fb 100644 --- a/admin/api/v1/zone_types.go +++ b/admin/api/v1/zone_types.go @@ -78,7 +78,23 @@ type GatewayConfig struct { CircuitBreaker bool `json:"circuitBreaker"` } -type ApiConfig struct { +// ManagedRouteType defines the type of a managed route. +// +kubebuilder:validation:Enum=TeamAPI;Proxy +type ManagedRouteType string + +const ( + // ManagedRouteTypeTeamAPI creates a route with authentication (PassThrough=false) + // and disabled access control on the zone's team-api gateway realm. + // Used for team APIs that require token validation but no per-consumer ACLs. + ManagedRouteTypeTeamAPI ManagedRouteType = "TeamAPI" + + // ManagedRouteTypeProxy creates a fully passthrough route (PassThrough=true) + // on the zone's default gateway realm that acts as a pure reverse proxy + // without any authentication or authorization. + ManagedRouteTypeProxy ManagedRouteType = "Proxy" +) + +type ManagedRouteConfig struct { // Name is the name of the created route. It must be unique within the zone. // +kubebuilder:validation:Required // +kubebuilder:validation:Pattern=^[a-z0-9]+(-?[a-z0-9]+)*$ @@ -91,10 +107,14 @@ type ApiConfig struct { // +kubebuilder:validation:Required // +kubebuilder:validation:Format=uri Url string `json:"url"` + // Type selects the route behavior: Api (authenticated, no ACL) or Proxy (passthrough reverse proxy). + // +kubebuilder:validation:Required + Type ManagedRouteType `json:"type"` } -type TeamApiConfig struct { - Apis []ApiConfig `json:"apis"` +type ManagedRoutesConfig struct { + // +kubebuilder:validation:MinItems=1 + Routes []ManagedRouteConfig `json:"routes"` } type PermissionsConfig struct { @@ -142,7 +162,7 @@ type ZoneSpec struct { IdentityProvider IdentityProviderConfig `json:"identityProvider"` Gateway GatewayConfig `json:"gateway"` Redis RedisConfig `json:"redis"` - TeamApis *TeamApiConfig `json:"teamApis,omitempty"` + ManagedRoutes *ManagedRoutesConfig `json:"managedRoutes,omitempty"` // +kubebuilder:validation:Enum=World;Enterprise // Visibility controls what subscriptions are allowed from and to this zone. It's also relevant for features like failover Visibility ZoneVisibility `json:"visibility"` @@ -194,9 +214,10 @@ type ZoneStatus struct { // +optional Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"` - Namespace string `json:"namespace,omitempty"` - IdentityProvider *types.ObjectRef `json:"identityProvider,omitempty"` - IdentityRealm *types.ObjectRef `json:"identityRealm,omitempty"` + Namespace string `json:"namespace,omitempty"` + IdentityProvider *types.ObjectRef `json:"identityProvider,omitempty"` + IdentityRealm *types.ObjectRef `json:"identityRealm,omitempty"` + InternalIdentityRealm *types.ObjectRef `json:"internalIdentityRealm,omitempty"` Gateway *types.ObjectRef `json:"gateway,omitempty"` GatewayRealm *types.ObjectRef `json:"gatewayRealm,omitempty"` @@ -205,7 +226,7 @@ type ZoneStatus struct { TeamApiIdentityRealm *types.ObjectRef `json:"teamApiIdentityRealm,omitempty"` TeamApiGatewayRealm *types.ObjectRef `json:"teamApiGatewayRealm,omitempty"` - TeamApiRoutes []types.ObjectRef `json:"teamApiRoutes,omitempty"` + ManagedRoutes []types.ObjectRef `json:"managedRoutes,omitempty"` Links Links `json:"links,omitempty"` // Features is a list of features that are enabled or disabled for this zone. diff --git a/admin/api/v1/zz_generated.deepcopy.go b/admin/api/v1/zz_generated.deepcopy.go index ad36c3ba8..ae7b8d9f8 100644 --- a/admin/api/v1/zz_generated.deepcopy.go +++ b/admin/api/v1/zz_generated.deepcopy.go @@ -15,21 +15,6 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" ) -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ApiConfig) DeepCopyInto(out *ApiConfig) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApiConfig. -func (in *ApiConfig) DeepCopy() *ApiConfig { - if in == nil { - return nil - } - out := new(ApiConfig) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Environment) DeepCopyInto(out *Environment) { *out = *in @@ -248,6 +233,41 @@ func (in *Links) DeepCopy() *Links { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ManagedRouteConfig) DeepCopyInto(out *ManagedRouteConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ManagedRouteConfig. +func (in *ManagedRouteConfig) DeepCopy() *ManagedRouteConfig { + if in == nil { + return nil + } + out := new(ManagedRouteConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ManagedRoutesConfig) DeepCopyInto(out *ManagedRoutesConfig) { + *out = *in + if in.Routes != nil { + in, out := &in.Routes, &out.Routes + *out = make([]ManagedRouteConfig, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ManagedRoutesConfig. +func (in *ManagedRoutesConfig) DeepCopy() *ManagedRoutesConfig { + if in == nil { + return nil + } + out := new(ManagedRoutesConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PermissionsConfig) DeepCopyInto(out *PermissionsConfig) { *out = *in @@ -404,26 +424,6 @@ func (in *SecretRotationConfig) DeepCopy() *SecretRotationConfig { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *TeamApiConfig) DeepCopyInto(out *TeamApiConfig) { - *out = *in - if in.Apis != nil { - in, out := &in.Apis, &out.Apis - *out = make([]ApiConfig, len(*in)) - copy(*out, *in) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TeamApiConfig. -func (in *TeamApiConfig) DeepCopy() *TeamApiConfig { - if in == nil { - return nil - } - out := new(TeamApiConfig) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Zone) DeepCopyInto(out *Zone) { *out = *in @@ -489,9 +489,9 @@ func (in *ZoneSpec) DeepCopyInto(out *ZoneSpec) { in.IdentityProvider.DeepCopyInto(&out.IdentityProvider) in.Gateway.DeepCopyInto(&out.Gateway) out.Redis = in.Redis - if in.TeamApis != nil { - in, out := &in.TeamApis, &out.TeamApis - *out = new(TeamApiConfig) + if in.ManagedRoutes != nil { + in, out := &in.ManagedRoutes, &out.ManagedRoutes + *out = new(ManagedRoutesConfig) (*in).DeepCopyInto(*out) } if in.Permissions != nil { @@ -534,6 +534,10 @@ func (in *ZoneStatus) DeepCopyInto(out *ZoneStatus) { in, out := &in.IdentityRealm, &out.IdentityRealm *out = (*in).DeepCopy() } + if in.InternalIdentityRealm != nil { + in, out := &in.InternalIdentityRealm, &out.InternalIdentityRealm + *out = (*in).DeepCopy() + } if in.Gateway != nil { in, out := &in.Gateway, &out.Gateway *out = (*in).DeepCopy() @@ -558,8 +562,8 @@ func (in *ZoneStatus) DeepCopyInto(out *ZoneStatus) { in, out := &in.TeamApiGatewayRealm, &out.TeamApiGatewayRealm *out = (*in).DeepCopy() } - if in.TeamApiRoutes != nil { - in, out := &in.TeamApiRoutes, &out.TeamApiRoutes + if in.ManagedRoutes != nil { + in, out := &in.ManagedRoutes, &out.ManagedRoutes *out = make([]types.ObjectRef, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) diff --git a/admin/config/crd/bases/admin.cp.ei.telekom.de_zones.yaml b/admin/config/crd/bases/admin.cp.ei.telekom.de_zones.yaml index 4d59737a2..950313520 100644 --- a/admin/config/crd/bases/admin.cp.ei.telekom.de_zones.yaml +++ b/admin/config/crd/bases/admin.cp.ei.telekom.de_zones.yaml @@ -189,6 +189,43 @@ spec: - admin - url type: object + managedRoutes: + properties: + routes: + items: + properties: + name: + description: Name is the name of the created route. It must + be unique within the zone. + pattern: ^[a-z0-9]+(-?[a-z0-9]+)*$ + type: string + path: + description: Path is the path of the route exposed on the + gateway. + pattern: ^/.*$ + type: string + type: + description: 'Type selects the route behavior: Api (authenticated, + no ACL) or Proxy (passthrough reverse proxy).' + enum: + - TeamAPI + - Proxy + type: string + url: + description: Url is the upstream URL of the route. + format: uri + type: string + required: + - name + - path + - type + - url + type: object + minItems: 1 + type: array + required: + - routes + type: object permissions: description: Permissions configuration for permission service integration properties: @@ -223,34 +260,6 @@ spec: - password - port type: object - teamApis: - properties: - apis: - items: - properties: - name: - description: Name is the name of the created route. It must - be unique within the zone. - pattern: ^[a-z0-9]+(-?[a-z0-9]+)*$ - type: string - path: - description: Path is the path of the route exposed on the - gateway. - pattern: ^/.*$ - type: string - url: - description: Url is the upstream URL of the route. - format: uri - type: string - required: - - name - - path - - url - type: object - type: array - required: - - apis - type: object visibility: description: Visibility controls what subscriptions are allowed from and to this zone. It's also relevant for features like failover @@ -458,6 +467,25 @@ spec: - name - namespace type: object + internalIdentityRealm: + description: |- + ObjectRef is a reference to a Kubernetes object + It is similar to types.NamespacedName but has the required json tags for serialization + properties: + name: + type: string + namespace: + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + type: string + required: + - name + - namespace + type: object links: properties: gatewayIssuer: @@ -491,6 +519,27 @@ spec: - gatewayIssuer - gatewayUrl type: object + managedRoutes: + items: + description: |- + ObjectRef is a reference to a Kubernetes object + It is similar to types.NamespacedName but has the required json tags for serialization + properties: + name: + type: string + namespace: + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + type: string + required: + - name + - namespace + type: object + type: array namespace: type: string teamApiGatewayRealm: @@ -531,27 +580,6 @@ spec: - name - namespace type: object - teamApiRoutes: - items: - description: |- - ObjectRef is a reference to a Kubernetes object - It is similar to types.NamespacedName but has the required json tags for serialization - properties: - name: - type: string - namespace: - type: string - uid: - description: |- - UID is a type that holds unique ID values, including UUIDs. Because we - don't ONLY use UUIDs, this is an alias to string. Being a type captures - intent and helps make sure that UIDs and names do not get conflated. - type: string - required: - - name - - namespace - type: object - type: array type: object type: object x-kubernetes-validations: diff --git a/admin/config/samples/admin_v1_zone.yaml b/admin/config/samples/admin_v1_zone.yaml index 9ee86bef8..e9d4dca01 100644 --- a/admin/config/samples/admin_v1_zone.yaml +++ b/admin/config/samples/admin_v1_zone.yaml @@ -28,9 +28,10 @@ spec: host: bla port: 0 password: password - teamApis: - apis: + managedRoutes: + routes: - name: my-first-team-api path: /my/first/team/api url: https://somewhere.com/other/api/path + type: TeamAPI diff --git a/admin/internal/controller/zone_controller_test.go b/admin/internal/controller/zone_controller_test.go index 5efb7307c..6bbc63ac3 100644 --- a/admin/internal/controller/zone_controller_test.go +++ b/admin/internal/controller/zone_controller_test.go @@ -59,11 +59,12 @@ func NewZone(name, namespace string) *adminv1.Zone { Password: "test-redis-password", EnableTLS: true, }, - TeamApis: &adminv1.TeamApiConfig{ - Apis: []adminv1.ApiConfig{{ + ManagedRoutes: &adminv1.ManagedRoutesConfig{ + Routes: []adminv1.ManagedRouteConfig{{ Name: "test-team-api1", Path: "/test/team/api/v1", Url: "https://test-team-api-host.de/test-team-api-v1", + Type: adminv1.ManagedRouteTypeTeamAPI, }}, }, Visibility: adminv1.ZoneVisibilityWorld, @@ -205,10 +206,45 @@ func VerifyZone(ctx context.Context, g Gomega, namespacedName client.ObjectKey, Name: "test-zone", Namespace: "test--test-zone", }, + Claims: []identityapi.ClaimConfig{ + { + Name: "originZone", + Value: zoneToVerify.Name, + Type: identityapi.ClaimTypeHardcodedClaim, + }, + { + Name: "originStargate", + Value: zoneToVerify.Spec.Gateway.Url, + Type: identityapi.ClaimTypeHardcodedClaim, + }, + { + Name: "clientId", + Type: identityapi.ClaimTypeSessionNote, + }, + }, } g.Expect(identityProviderRealm.Spec).To(Equal(*identityProviderSpecRealm)) g.Expect(zone.Status.IdentityRealm).To(Equal(types.ObjectRefFromObject(identityProviderRealm))) + // Internal identity realm (rover) for admin-config clients + By("Checking if the internal identity realm (rover) is created and spec is valid") + internalIdentityRealm := &identityapi.Realm{} + internalIdentityRealmRef := client.ObjectKey{ + Namespace: "test--test-zone", + Name: "rover", + } + err = k8sClient.Get(ctx, internalIdentityRealmRef, internalIdentityRealm) + g.Expect(err).NotTo(HaveOccurred()) + + internalIdentityRealmSpec := &identityapi.RealmSpec{ + IdentityProvider: &types.ObjectRef{ + Name: "test-zone", + Namespace: "test--test-zone", + }, + } + g.Expect(internalIdentityRealm.Spec).To(Equal(*internalIdentityRealmSpec)) + g.Expect(zone.Status.InternalIdentityRealm).To(Equal(types.ObjectRefFromObject(internalIdentityRealm))) + // Identity provider client (gateway client) By("Checking if the identity provider client (gateway) is created and spec is valid") identityProviderClient := &identityapi.Client{} diff --git a/admin/internal/handler/remoteorganization/handler.go b/admin/internal/handler/remoteorganization/handler.go index 474414f0f..ff01e106c 100644 --- a/admin/internal/handler/remoteorganization/handler.go +++ b/admin/internal/handler/remoteorganization/handler.go @@ -43,7 +43,7 @@ func (h *RemoteOrganizationHandler) CreateOrUpdate(ctx context.Context, obj *adm _, err = c.CreateOrUpdate(ctx, namespace, mutator) if err != nil { - return errors.Wrapf(err, "❌ failed to create or update namespace %s, environment %s", namespace.Name, envName) + return errors.Wrapf(err, "failed to create or update namespace %s, environment %s", namespace.Name, envName) } obj.Status.Namespace = namespace.Name @@ -66,7 +66,7 @@ func (h *RemoteOrganizationHandler) Delete(ctx context.Context, obj *adminv1.Rem if apierrors.IsNotFound(err) { return nil } - return errors.Wrapf(err, "❌ failed to delete namespace %s", namespace.Name) + return errors.Wrapf(err, "failed to delete namespace %s", namespace.Name) } return nil } diff --git a/admin/internal/handler/util/naming/naming.go b/admin/internal/handler/util/naming/naming.go index 6ef7908b9..c09f2b784 100644 --- a/admin/internal/handler/util/naming/naming.go +++ b/admin/internal/handler/util/naming/naming.go @@ -8,6 +8,7 @@ import adminv1 "github.com/telekom/controlplane/admin/api/v1" const ( teamApiIdentityRealmPrefix = "team-" + internalIdentityRealmName = "rover" gatewayClientName = "gateway" gatewayAdminClientId = "rover" gateway = "gateway" @@ -18,6 +19,10 @@ func ForDefaultIdentityRealm(environment *adminv1.Environment) string { return environment.GetName() } +func ForInternalIdentityRealm() string { + return internalIdentityRealmName +} + func ForTeamApiIdentityRealm(environment *adminv1.Environment) string { return teamApiIdentityRealmPrefix + environment.GetName() } @@ -50,6 +55,6 @@ func ForGatewayConsumer() string { return gatewayConsumer } -func ForGatewayRoute(config adminv1.ApiConfig) string { +func ForGatewayRoute(config adminv1.ManagedRouteConfig) string { return config.Name } diff --git a/admin/internal/handler/util/urls/urls.go b/admin/internal/handler/util/urls/urls.go index ac56943e9..005944b35 100644 --- a/admin/internal/handler/util/urls/urls.go +++ b/admin/internal/handler/util/urls/urls.go @@ -43,7 +43,7 @@ func ForGatewayRealm(identityProviderBaseUrl, realmName string) string { return realmIssuerUrl + "auth/realms/" + realmName } -func ForRouteDownstream(gatewayBaseUrl string, config adminv1.ApiConfig) (*url.URL, error) { +func ForRouteDownstream(gatewayBaseUrl string, config adminv1.ManagedRouteConfig) (*url.URL, error) { raw, err := url.JoinPath(gatewayBaseUrl, config.Path) if err != nil { return nil, errors.Wrapf(err, "Cannot combine gatewayBaseUrl %s with team api route path %s", gatewayBaseUrl, config.Path) diff --git a/admin/internal/handler/zone/handler.go b/admin/internal/handler/zone/handler.go index c0766140d..2bcdfb645 100644 --- a/admin/internal/handler/zone/handler.go +++ b/admin/internal/handler/zone/handler.go @@ -45,7 +45,7 @@ func (h *ZoneHandler) CreateOrUpdate(ctx context.Context, obj *adminv1.Zone) err environment := &adminv1.Environment{} err := c.Get(ctx, client.ObjectKey{Name: envName, Namespace: envName}, environment) if err != nil { - return errors.Wrapf(err, "❌ failed to get environment %s", envName) + return errors.Wrapf(err, "failed to get environment %s", envName) } // Namespace @@ -64,7 +64,7 @@ func (h *ZoneHandler) CreateOrUpdate(ctx context.Context, obj *adminv1.Zone) err } _, err = c.CreateOrUpdate(ctx, namespace, mutator) if err != nil { - return errors.Wrapf(err, "❌ failed to create or update namespace %s", namespace.Name) + return errors.Wrapf(err, "failed to create or update namespace %s", namespace.Name) } obj.Status.Namespace = namespace.Name @@ -83,7 +83,23 @@ func (h *ZoneHandler) CreateOrUpdate(ctx context.Context, obj *adminv1.Zone) err obj.Status.IdentityProvider = types.ObjectRefFromObject(identityProvider) // Identity Realm - identityRealm, err := createIdentityRealm(ctx, handlingContext, identityProvider, naming.ForDefaultIdentityRealm(handlingContext.Environment)) + defaultClaims := []identityapi.ClaimConfig{ + { + Name: "originZone", + Value: handlingContext.Zone.Name, + Type: identityapi.ClaimTypeHardcodedClaim, + }, + { + Name: "originStargate", + Value: handlingContext.Zone.Spec.Gateway.Url, + Type: identityapi.ClaimTypeHardcodedClaim, + }, + { + Name: "clientId", + Type: identityapi.ClaimTypeSessionNote, + }, + } + identityRealm, err := createIdentityRealm(ctx, handlingContext, identityProvider, naming.ForDefaultIdentityRealm(handlingContext.Environment), defaultClaims) if err != nil { return err } @@ -93,6 +109,13 @@ func (h *ZoneHandler) CreateOrUpdate(ctx context.Context, obj *adminv1.Zone) err return errors.Wrapf(err, "Cannot combine identityProviderBaseUrl %s with realm name %s", obj.Spec.IdentityProvider.Url, identityRealm.Name) } + // Internal Identity Realm (rover) for admin-config clients + internalIdentityRealm, err := createIdentityRealm(ctx, handlingContext, identityProvider, naming.ForInternalIdentityRealm(), nil) + if err != nil { + return err + } + obj.Status.InternalIdentityRealm = types.ObjectRefFromObject(internalIdentityRealm) + // Identity Client for gateway // TBD - how to handle passwords for this client - will be regenerated with every reconciliation gatewayClient, err := createIdentityClient(ctx, handlingContext, identityRealm) @@ -127,15 +150,15 @@ func (h *ZoneHandler) CreateOrUpdate(ctx context.Context, obj *adminv1.Zone) err } obj.Status.GatewayConsumer = types.ObjectRefFromObject(gatewayConsumer) - // Team apis configuration - if obj.Spec.TeamApis != nil { - if err := reconcileTeamApis(ctx, handlingContext, obj, identityProvider, gateway); err != nil { + // Internal routes configuration + if obj.Spec.ManagedRoutes != nil { + if err := reconcileManagedRoutes(ctx, handlingContext, obj, identityProvider, gateway, gatewayRealm); err != nil { return err } } else { obj.Status.TeamApiIdentityRealm = nil obj.Status.TeamApiGatewayRealm = nil - obj.Status.TeamApiRoutes = nil + obj.Status.ManagedRoutes = nil obj.Status.Links.TeamIssuer = "" } @@ -157,8 +180,44 @@ func (h *ZoneHandler) CreateOrUpdate(ctx context.Context, obj *adminv1.Zone) err return nil } -func reconcileTeamApis(ctx context.Context, handlingContext HandlingContext, zone *adminv1.Zone, identityProvider *identityapi.IdentityProvider, gateway *gatewayapi.Gateway) error { - teamApiIdentityRealm, err := createIdentityRealm(ctx, handlingContext, identityProvider, naming.ForTeamApiIdentityRealm(handlingContext.Environment)) +func reconcileManagedRoutes(ctx context.Context, handlingContext HandlingContext, zone *adminv1.Zone, identityProvider *identityapi.IdentityProvider, gateway *gatewayapi.Gateway, defaultGatewayRealm *gatewayapi.Realm) error { + // Partition routes by type + var teamAPIRoutes, proxyRoutes []adminv1.ManagedRouteConfig + for _, r := range zone.Spec.ManagedRoutes.Routes { + switch r.Type { + case adminv1.ManagedRouteTypeTeamAPI: + teamAPIRoutes = append(teamAPIRoutes, r) + case adminv1.ManagedRouteTypeProxy: + proxyRoutes = append(proxyRoutes, r) + } + } + + // TeamAPI routes require a dedicated identity and gateway realm + if err := reconcileTeamAPIRoutes(ctx, handlingContext, zone, identityProvider, gateway, teamAPIRoutes); err != nil { + return err + } + + // Proxy routes use the default gateway realm with full passthrough + for _, routeConfig := range proxyRoutes { + route, err := createManagedRoute(ctx, handlingContext, routeConfig, defaultGatewayRealm, true) + if err != nil { + return err + } + zone.Status.ManagedRoutes = append(zone.Status.ManagedRoutes, *types.ObjectRefFromObject(route)) + } + + return nil +} + +func reconcileTeamAPIRoutes(ctx context.Context, handlingContext HandlingContext, zone *adminv1.Zone, identityProvider *identityapi.IdentityProvider, gateway *gatewayapi.Gateway, routes []adminv1.ManagedRouteConfig) error { + if len(routes) == 0 { + zone.Status.TeamApiIdentityRealm = nil + zone.Status.TeamApiGatewayRealm = nil + zone.Status.Links.TeamIssuer = "" + return nil + } + + teamApiIdentityRealm, err := createIdentityRealm(ctx, handlingContext, identityProvider, naming.ForTeamApiIdentityRealm(handlingContext.Environment), nil) if err != nil { return err } @@ -173,36 +232,35 @@ func reconcileTeamApis(ctx context.Context, handlingContext HandlingContext, zon zone.Status.Links.TeamIssuer = teamApisGatewayRealm.Spec.IssuerUrls[0] } - teamApiRouteRefs := make([]types.ObjectRef, 0, len(zone.Spec.TeamApis.Apis)) - for _, teamApiRoute := range zone.Spec.TeamApis.Apis { - route, err := createTeamApiRoute(ctx, handlingContext, teamApiRoute, teamApisGatewayRealm) + for _, routeConfig := range routes { + route, err := createManagedRoute(ctx, handlingContext, routeConfig, teamApisGatewayRealm, false) if err != nil { return err } - teamApiRouteRefs = append(teamApiRouteRefs, *types.ObjectRefFromObject(route)) + zone.Status.ManagedRoutes = append(zone.Status.ManagedRoutes, *types.ObjectRefFromObject(route)) } - zone.Status.TeamApiRoutes = teamApiRouteRefs + return nil } -func createTeamApiRoute(ctx context.Context, handlingContext HandlingContext, teamRouteConfig adminv1.ApiConfig, gatewayRealm *gatewayapi.Realm) (*gatewayapi.Route, error) { +func createManagedRoute(ctx context.Context, handlingContext HandlingContext, routeConfig adminv1.ManagedRouteConfig, gatewayRealm *gatewayapi.Realm, passThrough bool) (*gatewayapi.Route, error) { scopedClient := cclient.ClientFromContextOrDie(ctx) - teamRoute := &gatewayapi.Route{ + route := &gatewayapi.Route{ ObjectMeta: metav1.ObjectMeta{ - Name: gatewayRealm.Name + "--" + naming.ForGatewayRoute(teamRouteConfig), + Name: gatewayRealm.Name + "--" + naming.ForGatewayRoute(routeConfig), Namespace: handlingContext.Namespace.Name, }, } mutator := func() error { - teamRoute.Labels = map[string]string{ + route.Labels = map[string]string{ cconfig.EnvironmentLabelKey: handlingContext.Environment.Name, cconfig.BuildLabelKey(zoneLabelName): handlingContext.Zone.Name, } - upstreamUrl, err := url.Parse(teamRouteConfig.Url) + upstreamUrl, err := url.Parse(routeConfig.Url) if err != nil { - return errors.Wrapf(err, "Cannot parse upstream url of team route %s", teamRouteConfig.Url) + return errors.Wrapf(err, "Cannot parse upstream url of internal route %s", routeConfig.Url) } upstream := gatewayapi.Upstream{ Scheme: upstreamUrl.Scheme, @@ -211,12 +269,12 @@ func createTeamApiRoute(ctx context.Context, handlingContext HandlingContext, te Path: upstreamUrl.Path, } - downstreamUrl, err := urls.ForRouteDownstream(handlingContext.Zone.Spec.Gateway.Url, teamRouteConfig) + downstreamUrl, err := urls.ForRouteDownstream(handlingContext.Zone.Spec.Gateway.Url, routeConfig) if err != nil { return err } issuerUrl := "" - if len(gatewayRealm.Spec.IssuerUrls) > 0 { + if !passThrough && len(gatewayRealm.Spec.IssuerUrls) > 0 { issuerUrl = gatewayRealm.Spec.IssuerUrls[0] } downstream := gatewayapi.Downstream{ @@ -226,25 +284,28 @@ func createTeamApiRoute(ctx context.Context, handlingContext HandlingContext, te IssuerUrl: issuerUrl, } - teamRoute.Spec = gatewayapi.RouteSpec{ + route.Spec = gatewayapi.RouteSpec{ Realm: *types.ObjectRefFromObject(gatewayRealm), - PassThrough: false, + PassThrough: passThrough, Upstreams: []gatewayapi.Upstream{upstream}, Downstreams: []gatewayapi.Downstream{downstream}, Traffic: gatewayapi.Traffic{}, - Security: &gatewayapi.Security{ - DisableAccessControl: true, // Team APIs are not protected by ACLs - }, + } + + if !passThrough { + route.Spec.Security = &gatewayapi.Security{ + DisableAccessControl: true, + } } return nil } - _, err := scopedClient.CreateOrUpdate(ctx, teamRoute, mutator) + _, err := scopedClient.CreateOrUpdate(ctx, route, mutator) if err != nil { - return nil, errors.Wrapf(err, "failed to create or update Gateway route %s in zone %s", teamRoute.GetName(), handlingContext.Zone.Name) + return nil, errors.Wrapf(err, "failed to create or update Gateway route %s in zone %s", route.GetName(), handlingContext.Zone.Name) } - return teamRoute, nil + return route, nil } func createGatewayConsumer(ctx context.Context, handlingContext HandlingContext, gatewayRealm *gatewayapi.Realm) (*gatewayapi.Consumer, error) { @@ -403,7 +464,7 @@ func getIdentityClient(ctx context.Context, ref *types.ObjectRef) (*identityapi. return identityClient, nil } -func createIdentityRealm(ctx context.Context, handlingContext HandlingContext, identityProvider *identityapi.IdentityProvider, realmName string) (*identityapi.Realm, error) { +func createIdentityRealm(ctx context.Context, handlingContext HandlingContext, identityProvider *identityapi.IdentityProvider, realmName string, claims []identityapi.ClaimConfig) (*identityapi.Realm, error) { scopedClient := cclient.ClientFromContextOrDie(ctx) identityRealm := &identityapi.Realm{ @@ -424,6 +485,7 @@ func createIdentityRealm(ctx context.Context, handlingContext HandlingContext, i Name: identityProvider.Name, Namespace: identityProvider.Namespace, }, + Claims: claims, } secretRotationConfig := handlingContext.Zone.Spec.IdentityProvider.SecretRotation diff --git a/install/overlays/local/resources/admin/zones/dataplane1.example.yaml b/install/overlays/local/resources/admin/zones/dataplane1.example.yaml index c6ea908a3..ffec3126c 100644 --- a/install/overlays/local/resources/admin/zones/dataplane1.example.yaml +++ b/install/overlays/local/resources/admin/zones/dataplane1.example.yaml @@ -27,6 +27,6 @@ spec: host: my-redis-host port: 6379 password: somePassword - teamApis: - apis: [] + managedRoutes: + routes: [] visibility: Enterprise \ No newline at end of file diff --git a/install/overlays/local/resources/admin/zones/dataplane2.example.yaml b/install/overlays/local/resources/admin/zones/dataplane2.example.yaml index aaa813ee2..28cc9b268 100644 --- a/install/overlays/local/resources/admin/zones/dataplane2.example.yaml +++ b/install/overlays/local/resources/admin/zones/dataplane2.example.yaml @@ -27,6 +27,6 @@ spec: host: my-redis-host port: 6379 password: somePassword - teamApis: - apis: [] + managedRoutes: + routes: [] visibility: Enterprise \ No newline at end of file diff --git a/organization/internal/controller/team_controller_test.go b/organization/internal/controller/team_controller_test.go index 0673731eb..0d6bdf08f 100644 --- a/organization/internal/controller/team_controller_test.go +++ b/organization/internal/controller/team_controller_test.go @@ -78,10 +78,11 @@ var _ = Describe("Team Controller", Ordered, func() { }, }, Spec: adminv1.ZoneSpec{ - TeamApis: &adminv1.TeamApiConfig{Apis: []adminv1.ApiConfig{{ + ManagedRoutes: &adminv1.ManagedRoutesConfig{Routes: []adminv1.ManagedRouteConfig{{ Name: "team-api-1", Path: "/teamAPI", Url: "http://example.org", + Type: adminv1.ManagedRouteTypeTeamAPI, }}}, Visibility: adminv1.ZoneVisibilityWorld, }, diff --git a/organization/internal/handler/util/zone.go b/organization/internal/handler/util/zone.go index f7452991a..734d92429 100644 --- a/organization/internal/handler/util/zone.go +++ b/organization/internal/handler/util/zone.go @@ -21,7 +21,7 @@ func GetZoneObjWithTeamInfo(ctx context.Context) (*adminv1.Zone, error) { zoneList := &adminv1.ZoneList{} clientFromContext := cclient.ClientFromContextOrDie(ctx) - err := clientFromContext.List(ctx, zoneList, client.MatchingFields{index.FieldSpecTeamApis: "true"}) + err := clientFromContext.List(ctx, zoneList, client.MatchingFields{index.FieldSpecManagedRoutes: "true"}) if err != nil { return nil, fmt.Errorf("failed to list zones: %w", err) } @@ -31,7 +31,7 @@ func GetZoneObjWithTeamInfo(ctx context.Context) (*adminv1.Zone, error) { if !ok { continue } - if z.Spec.TeamApis != nil { + if z.Spec.ManagedRoutes != nil { teamApiZone = z.DeepCopy() break } diff --git a/organization/internal/index/index.go b/organization/internal/index/index.go index 3bfdb32d2..63a1369ae 100644 --- a/organization/internal/index/index.go +++ b/organization/internal/index/index.go @@ -16,8 +16,8 @@ import ( ) const ( - FieldSpecGroup = "spec.group" - FieldSpecTeamApis = "spec.teamApis" + FieldSpecGroup = "spec.group" + FieldSpecManagedRoutes = "spec.managedRoutes" ) func RegisterIndicesOrDie(ctx context.Context, mgr ctrl.Manager) { @@ -36,11 +36,14 @@ func RegisterIndicesOrDie(ctx context.Context, mgr ctrl.Manager) { return nil } - if zone.Spec.TeamApis == nil { - return []string{"false"} - } else { - return []string{"true"} + if zone.Spec.ManagedRoutes != nil { + for _, r := range zone.Spec.ManagedRoutes.Routes { + if r.Type == adminv1.ManagedRouteTypeTeamAPI { + return []string{"true"} + } + } } + return []string{"false"} } err := mgr.GetFieldIndexer().IndexField(ctx, &organizationv1.Team{}, FieldSpecGroup, filterTeamGroup) @@ -49,9 +52,9 @@ func RegisterIndicesOrDie(ctx context.Context, mgr ctrl.Manager) { os.Exit(1) } - err = mgr.GetFieldIndexer().IndexField(ctx, &adminv1.Zone{}, FieldSpecTeamApis, filterZoneWithTeamRealmInfos) + err = mgr.GetFieldIndexer().IndexField(ctx, &adminv1.Zone{}, FieldSpecManagedRoutes, filterZoneWithTeamRealmInfos) if err != nil { - ctrl.Log.Error(err, "unable to create fieldIndex for zone", "FieldIndex", FieldSpecTeamApis) + ctrl.Log.Error(err, "unable to create fieldIndex for zone", "FieldIndex", FieldSpecManagedRoutes) os.Exit(1) } } diff --git a/organization/internal/team_webhook_reconciler_test.go b/organization/internal/team_webhook_reconciler_test.go index d2411c492..275dc93ef 100644 --- a/organization/internal/team_webhook_reconciler_test.go +++ b/organization/internal/team_webhook_reconciler_test.go @@ -86,10 +86,11 @@ var _ = Describe("Team Reconciler, Group Reconciler and Team Webhook", Ordered, }, }, Spec: adminv1.ZoneSpec{ - TeamApis: &adminv1.TeamApiConfig{Apis: []adminv1.ApiConfig{{ + ManagedRoutes: &adminv1.ManagedRoutesConfig{Routes: []adminv1.ManagedRouteConfig{{ Name: "team-api-1", Path: "/teamAPI", Url: "http://example.org", + Type: adminv1.ManagedRouteTypeTeamAPI, }}}, Visibility: adminv1.ZoneVisibilityWorld, }, diff --git a/organization/internal/webhook/v1/mutator/mutate.go b/organization/internal/webhook/v1/mutator/mutate.go index e88417d22..746509697 100644 --- a/organization/internal/webhook/v1/mutator/mutate.go +++ b/organization/internal/webhook/v1/mutator/mutate.go @@ -107,7 +107,7 @@ func GetZoneObjWithTeamInfo(ctx context.Context, k8sClient client.Client) (*admi return nil, errors.NewInternalError(fmt.Errorf("k8sClient is nil")) } - err := k8sClient.List(ctx, zoneList, client.MatchingFields{index.FieldSpecTeamApis: "true"}) + err := k8sClient.List(ctx, zoneList, client.MatchingFields{index.FieldSpecManagedRoutes: "true"}) if err != nil { return nil, errors.NewInternalError(err) } @@ -117,7 +117,7 @@ func GetZoneObjWithTeamInfo(ctx context.Context, k8sClient client.Client) (*admi if !ok { continue } - if zoneObj.Spec.TeamApis != nil { + if zoneObj.Spec.ManagedRoutes != nil { teamApiZone = zoneObj.DeepCopy() break } diff --git a/organization/internal/webhook/v1/team_webhook_test.go b/organization/internal/webhook/v1/team_webhook_test.go index dbb81ddf1..95e32d7fa 100644 --- a/organization/internal/webhook/v1/team_webhook_test.go +++ b/organization/internal/webhook/v1/team_webhook_test.go @@ -42,10 +42,11 @@ var _ = Describe("Team Webhook", func() { }, }, Spec: adminv1.ZoneSpec{ - TeamApis: &adminv1.TeamApiConfig{Apis: []adminv1.ApiConfig{{ + ManagedRoutes: &adminv1.ManagedRoutesConfig{Routes: []adminv1.ManagedRouteConfig{{ Name: "team-api-1", Path: "/teamAPI", Url: "https://example.org", + Type: adminv1.ManagedRouteTypeTeamAPI, }}}, Visibility: adminv1.ZoneVisibilityWorld, }, From dbe375e3c149585c3d0c8b83be2f92e8994b91ff Mon Sep 17 00:00:00 2001 From: Ron Gummich Date: Wed, 13 May 2026 11:05:11 +0200 Subject: [PATCH 2/6] fix(admin): review comments --- admin/api/v1/zone_types.go | 2 +- .../bases/admin.cp.ei.telekom.de_zones.yaml | 2 +- .../controller/zone_controller_test.go | 54 +++++++++++++++++++ admin/internal/handler/util/urls/urls.go | 2 +- admin/internal/handler/zone/handler.go | 7 ++- .../admin-journey/environments-and-zones.md | 39 ++++++++++++++ docs/docs/architecture/admin.mdx | 6 +-- .../admin/zones/dataplane1.example.yaml | 2 - .../admin/zones/dataplane2.example.yaml | 2 - 9 files changed, 105 insertions(+), 11 deletions(-) diff --git a/admin/api/v1/zone_types.go b/admin/api/v1/zone_types.go index 96f6581fb..7858107d7 100644 --- a/admin/api/v1/zone_types.go +++ b/admin/api/v1/zone_types.go @@ -107,7 +107,7 @@ type ManagedRouteConfig struct { // +kubebuilder:validation:Required // +kubebuilder:validation:Format=uri Url string `json:"url"` - // Type selects the route behavior: Api (authenticated, no ACL) or Proxy (passthrough reverse proxy). + // Type selects the route behavior: TeamAPI (authenticated, no ACL) or Proxy (passthrough reverse proxy). // +kubebuilder:validation:Required Type ManagedRouteType `json:"type"` } diff --git a/admin/config/crd/bases/admin.cp.ei.telekom.de_zones.yaml b/admin/config/crd/bases/admin.cp.ei.telekom.de_zones.yaml index 950313520..80b46c384 100644 --- a/admin/config/crd/bases/admin.cp.ei.telekom.de_zones.yaml +++ b/admin/config/crd/bases/admin.cp.ei.telekom.de_zones.yaml @@ -205,7 +205,7 @@ spec: pattern: ^/.*$ type: string type: - description: 'Type selects the route behavior: Api (authenticated, + description: 'Type selects the route behavior: TeamAPI (authenticated, no ACL) or Proxy (passthrough reverse proxy).' enum: - TeamAPI diff --git a/admin/internal/controller/zone_controller_test.go b/admin/internal/controller/zone_controller_test.go index 6bbc63ac3..1a0191629 100644 --- a/admin/internal/controller/zone_controller_test.go +++ b/admin/internal/controller/zone_controller_test.go @@ -132,6 +132,60 @@ var _ = Describe("Zone Controller", func() { }) }) + Context("When reconciling a zone with a Proxy managed route", func() { + It("should create a passthrough route on the default gateway realm", func() { + zone := NewZone("test-zone-proxy", testNamespace) + zone.Spec.ManagedRoutes = &adminv1.ManagedRoutesConfig{ + Routes: []adminv1.ManagedRouteConfig{ + { + Name: "test-team-api1", + Path: "/test/team/api/v1", + Url: "https://test-team-api-host.de/test-team-api-v1", + Type: adminv1.ManagedRouteTypeTeamAPI, + }, + { + Name: "test-proxy", + Path: "/proxy/path", + Url: "https://proxy-upstream.de/backend", + Type: adminv1.ManagedRouteTypeProxy, + }, + }, + } + Expect(k8sClient.Create(ctx, zone)).To(Succeed()) + DeferCleanup(func() { + Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, zone))).To(Succeed()) + }) + + Eventually(func(g Gomega) { + got := &adminv1.Zone{} + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(zone), got) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(meta.IsStatusConditionTrue(got.Status.Conditions, condition.ConditionTypeReady)).To(BeTrue()) + + // The proxy route should be created on the default gateway realm (named "test") + proxyRoute := &gatewayapi.Route{} + proxyRouteRef := client.ObjectKey{ + Namespace: "test--test-zone-proxy", + Name: "test--test-proxy", + } + err = k8sClient.Get(ctx, proxyRouteRef, proxyRoute) + g.Expect(err).NotTo(HaveOccurred()) + + g.Expect(proxyRoute.Spec.PassThrough).To(BeTrue()) + g.Expect(proxyRoute.Spec.Security).To(BeNil()) + g.Expect(proxyRoute.Spec.Upstreams).To(HaveLen(1)) + g.Expect(proxyRoute.Spec.Upstreams[0].Host).To(Equal("proxy-upstream.de")) + g.Expect(proxyRoute.Spec.Upstreams[0].Path).To(Equal("/backend")) + g.Expect(proxyRoute.Spec.Downstreams).To(HaveLen(1)) + g.Expect(proxyRoute.Spec.Downstreams[0].IssuerUrl).To(BeEmpty()) + g.Expect(proxyRoute.Spec.Downstreams[0].Path).To(Equal("/proxy/path")) + + // Verify ManagedRoutes status contains refs for both routes + g.Expect(got.Status.ManagedRoutes).To(HaveLen(2)) + }, timeout, interval).Should(Succeed()) + }) + }) + Context("ExternalIdPolicies round-trip", func() { It("persists ExternalIdPolicies on the Zone spec", func() { zone := NewZone("test-zone-extids", testNamespace) diff --git a/admin/internal/handler/util/urls/urls.go b/admin/internal/handler/util/urls/urls.go index 005944b35..357ac6f21 100644 --- a/admin/internal/handler/util/urls/urls.go +++ b/admin/internal/handler/util/urls/urls.go @@ -46,7 +46,7 @@ func ForGatewayRealm(identityProviderBaseUrl, realmName string) string { func ForRouteDownstream(gatewayBaseUrl string, config adminv1.ManagedRouteConfig) (*url.URL, error) { raw, err := url.JoinPath(gatewayBaseUrl, config.Path) if err != nil { - return nil, errors.Wrapf(err, "Cannot combine gatewayBaseUrl %s with team api route path %s", gatewayBaseUrl, config.Path) + return nil, errors.Wrapf(err, "Cannot combine gatewayBaseUrl %s with managed route path %s", gatewayBaseUrl, config.Path) } return url.Parse(raw) } diff --git a/admin/internal/handler/zone/handler.go b/admin/internal/handler/zone/handler.go index 2bcdfb645..630ea3a23 100644 --- a/admin/internal/handler/zone/handler.go +++ b/admin/internal/handler/zone/handler.go @@ -181,6 +181,9 @@ func (h *ZoneHandler) CreateOrUpdate(ctx context.Context, obj *adminv1.Zone) err } func reconcileManagedRoutes(ctx context.Context, handlingContext HandlingContext, zone *adminv1.Zone, identityProvider *identityapi.IdentityProvider, gateway *gatewayapi.Gateway, defaultGatewayRealm *gatewayapi.Realm) error { + // Reset status to avoid stale/duplicate entries across reconciliations + zone.Status.ManagedRoutes = nil + // Partition routes by type var teamAPIRoutes, proxyRoutes []adminv1.ManagedRouteConfig for _, r := range zone.Spec.ManagedRoutes.Routes { @@ -189,6 +192,8 @@ func reconcileManagedRoutes(ctx context.Context, handlingContext HandlingContext teamAPIRoutes = append(teamAPIRoutes, r) case adminv1.ManagedRouteTypeProxy: proxyRoutes = append(proxyRoutes, r) + default: + return fmt.Errorf("unsupported managed route type %q for route %q", r.Type, r.Name) } } @@ -264,7 +269,7 @@ func createManagedRoute(ctx context.Context, handlingContext HandlingContext, ro } upstream := gatewayapi.Upstream{ Scheme: upstreamUrl.Scheme, - Host: upstreamUrl.Host, + Host: upstreamUrl.Hostname(), Port: gatewayapi.GetPortOrDefaultFromScheme(upstreamUrl), Path: upstreamUrl.Path, } diff --git a/docs/docs/admin-journey/environments-and-zones.md b/docs/docs/admin-journey/environments-and-zones.md index 48f5309aa..510ca083e 100644 --- a/docs/docs/admin-journey/environments-and-zones.md +++ b/docs/docs/admin-journey/environments-and-zones.md @@ -93,6 +93,45 @@ Zones can be configured with different visibility levels: - **World** — The zone is accessible from outside the platform (public-facing APIs). - **Enterprise** — The zone is accessible only within the organization's network. +### Managed Routes + +Zones can optionally define **managed routes** — platform-managed gateway routes that are configured directly on the Zone resource rather than being created dynamically through Rover or API resources. + +Each managed route has a **type** that determines its behavior: + +| Type | Behavior | +|------|----------| +| **TeamAPI** | Authenticated route on the zone's team-api gateway realm. Requires token validation but does not enforce per-consumer ACLs. Used for team-facing platform APIs. | +| **Proxy** | Fully passthrough route on the zone's default gateway realm. Acts as a pure reverse proxy without any authentication or authorization. | + +Example: + +```yaml +spec: + managedRoutes: + routes: + - name: team-api + path: /team/api/v1 + url: https://my-team-api.internal.example.com/api/v1 + type: TeamAPI + - name: health-proxy + path: /health + url: https://health-service.internal.example.com/ + type: Proxy +``` + +### Token Claims + +The Control Plane automatically injects the following claims into all tokens issued for clients in a zone's default identity realm: + +| Claim | Type | Description | +|-------|------|-------------| +| `originZone` | Hardcoded | The name of the zone that issued the token. | +| `originStargate` | Hardcoded | The public gateway URL of the zone. | +| `clientId` | Session note | The OAuth2 client ID of the authenticated caller, populated automatically by Keycloak. | + +These claims allow downstream services to identify the origin of a request without additional lookups. + ## Remote Organizations :::caution Planned Feature diff --git a/docs/docs/architecture/admin.mdx b/docs/docs/architecture/admin.mdx index e1a6b7313..ad5ac5204 100644 --- a/docs/docs/architecture/admin.mdx +++ b/docs/docs/architecture/admin.mdx @@ -14,9 +14,9 @@ This domain is typically managed by platform administrators, not application tea ## Domain Interactions -- **Gateway domain** — Zones define which gateway instance is used. The Gateway operator reads the zone's gateway configuration when provisioning routes. -- **Identity domain** — Zones define which identity provider is used. The Identity operator reads the zone's IDP configuration when provisioning clients and realms. -- **Organization domain** — Teams are created within environments. Zones determine where team resources are provisioned. +- **Gateway domain** — Zones define which gateway instance is used. The Gateway operator reads the zone's gateway configuration when provisioning routes. Managed routes (TeamAPI and Proxy) are created directly by the zone handler on the appropriate gateway realm. +- **Identity domain** — Zones define which identity provider is used. The Identity operator reads the zone's IDP configuration when provisioning clients and realms. The zone handler creates a default identity realm with token claims (`originZone`, `originStargate`, `clientId`) and a dedicated internal "rover" realm for admin-config clients. +- **Organization domain** — Teams are created within environments. Zones determine where team resources are provisioned. If a zone has TeamAPI-type managed routes, a team-api identity realm and gateway realm are created for team-facing APIs. - **Event domain** — EventConfig resources reference zones for event routing and meshing. ## Related Pages diff --git a/install/overlays/local/resources/admin/zones/dataplane1.example.yaml b/install/overlays/local/resources/admin/zones/dataplane1.example.yaml index ffec3126c..d8ad40f64 100644 --- a/install/overlays/local/resources/admin/zones/dataplane1.example.yaml +++ b/install/overlays/local/resources/admin/zones/dataplane1.example.yaml @@ -27,6 +27,4 @@ spec: host: my-redis-host port: 6379 password: somePassword - managedRoutes: - routes: [] visibility: Enterprise \ No newline at end of file diff --git a/install/overlays/local/resources/admin/zones/dataplane2.example.yaml b/install/overlays/local/resources/admin/zones/dataplane2.example.yaml index 28cc9b268..8dadfcad8 100644 --- a/install/overlays/local/resources/admin/zones/dataplane2.example.yaml +++ b/install/overlays/local/resources/admin/zones/dataplane2.example.yaml @@ -27,6 +27,4 @@ spec: host: my-redis-host port: 6379 password: somePassword - managedRoutes: - routes: [] visibility: Enterprise \ No newline at end of file From d5cc0d81ae363df006fb5c25dac7317a5994aacd Mon Sep 17 00:00:00 2001 From: Ron Gummich Date: Wed, 13 May 2026 15:51:26 +0200 Subject: [PATCH 3/6] fix(gateway,secret-manager): improve error classification and wrapping Replace github.com/pkg/errors with fmt.Errorf and %w to preserve error chains for upstream classification by ctrlerrors.HandleError. Kong client: - Enhance apiError with IsRetryable/IsBlocked/RetryDelay duck-typing so errors propagate correctly through the controller error handler - Add 429 Too Many Requests handling with retry delay - Include HTTP status codes in all error messages for debuggability - Add IsNotFound helper and comprehensive tests Secret-manager API: - Wrap network errors as retryable, 401 as blocked - Add handleError classifier that distinguishes retryable 4xx (408/429) from blocked 4xx and retryable 5xx Also fix consumer handler error message typo ("route" -> "consumer") and add context to last-mile-security secret resolution errors. --- .../features/feature/circuit_breaker.go | 10 +- .../features/feature/last_mile_security.go | 3 +- gateway/internal/handler/consumer/handler.go | 2 +- gateway/pkg/kong/client/client.go | 58 +++--- gateway/pkg/kong/client/error.go | 80 ++++++-- gateway/pkg/kong/client/error_test.go | 188 ++++++++++++++++++ secret-manager/api/api.go | 116 ++++++----- 7 files changed, 362 insertions(+), 95 deletions(-) create mode 100644 gateway/pkg/kong/client/error_test.go diff --git a/gateway/internal/features/feature/circuit_breaker.go b/gateway/internal/features/feature/circuit_breaker.go index 2febcdd31..58b7805b4 100644 --- a/gateway/internal/features/feature/circuit_breaker.go +++ b/gateway/internal/features/feature/circuit_breaker.go @@ -7,8 +7,8 @@ package feature import ( "context" "fmt" + "github.com/go-logr/logr" - "github.com/pkg/errors" "github.com/telekom/controlplane/common/pkg/util/contextutil" gatewayv1 "github.com/telekom/controlplane/gateway/api/v1" "github.com/telekom/controlplane/gateway/internal/features" @@ -161,10 +161,10 @@ func handleApply(ctx context.Context, builder features.FeaturesBuilder, route *g upstreamResponse, err := kongAdminApi.UpsertUpstreamWithResponse(ctx, upstreamName, upstreamBody) if err != nil { - return errors.Wrap(err, "failed to create upstream") + return fmt.Errorf("failed to create upstream: %w", err) } if err := client.CheckStatusCode(upstreamResponse, 200); err != nil { - return errors.Wrap(fmt.Errorf("error body from kong admin api: %s", string(upstreamResponse.Body)), "failed to create upstream") + return fmt.Errorf("failed to create upstream (%d): %s: %w", upstreamResponse.StatusCode(), string(upstreamResponse.Body), err) } route.SetUpstreamId(*upstreamResponse.JSON200.Id) @@ -184,10 +184,10 @@ func handleApply(ctx context.Context, builder features.FeaturesBuilder, route *g // this is a special case with the kong admin API - this endpoint /upstreams/:upstreamName/targets actually accepts multiple POST requests, so this is not a mistake targetsResponse, err := kongAdminApi.CreateTargetForUpstreamWithResponse(ctx, upstreamName, targetsBody) if err != nil { - return errors.Wrap(err, "failed to create targets for upstream") + return fmt.Errorf("failed to create targets for upstream: %w", err) } if err := client.CheckStatusCode(targetsResponse, 200, 201); err != nil { - return errors.Wrap(fmt.Errorf("error body from kong admin api: %s", string(targetsResponse.Body)), "failed to create targets for upstream") + return fmt.Errorf("failed to create targets for upstream (%d): %s: %w", targetsResponse.StatusCode(), string(targetsResponse.Body), err) } route.SetTargetsId(*targetsResponse.JSON200.Id) diff --git a/gateway/internal/features/feature/last_mile_security.go b/gateway/internal/features/feature/last_mile_security.go index 512d8fc67..7ac6a86b0 100644 --- a/gateway/internal/features/feature/last_mile_security.go +++ b/gateway/internal/features/feature/last_mile_security.go @@ -6,6 +6,7 @@ package feature import ( "context" + "fmt" "strconv" "strings" @@ -69,7 +70,7 @@ func (f *LastMileSecurityFeature) Apply(ctx context.Context, builder features.Fe log.V(1).Info("Resolving client secret from secret manager", "secretRef", clientSecret) clientSecret, err = secrets.Get(ctx, clientSecret) if err != nil { - return err + return fmt.Errorf("failed to resolve upstream client secret for route %s: %w", route.Name, err) } } diff --git a/gateway/internal/handler/consumer/handler.go b/gateway/internal/handler/consumer/handler.go index 0e76f6758..b9dae92d3 100644 --- a/gateway/internal/handler/consumer/handler.go +++ b/gateway/internal/handler/consumer/handler.go @@ -30,7 +30,7 @@ func (h *ConsumerHandler) CreateOrUpdate(ctx context.Context, consumer *gatewayv } if err := builder.BuildForConsumer(ctx); err != nil { - return errors.Wrap(err, "failed to build route") + return errors.Wrap(err, "failed to build consumer") } consumer.SetCondition(condition.NewDoneProcessingCondition("Consumer is ready")) diff --git a/gateway/pkg/kong/client/client.go b/gateway/pkg/kong/client/client.go index eb04fe83d..f8e3d0675 100644 --- a/gateway/pkg/kong/client/client.go +++ b/gateway/pkg/kong/client/client.go @@ -7,6 +7,7 @@ package client import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -15,7 +16,6 @@ import ( "github.com/go-logr/logr" "github.com/google/uuid" - "github.com/pkg/errors" "github.com/telekom/controlplane/common/pkg/util/contextutil" kong "github.com/telekom/controlplane/gateway/pkg/kong/api" @@ -115,7 +115,7 @@ func (c *kongClient) LoadPlugin( return nil, err } if err := CheckStatusCode(response, 200, 404); err != nil { - return nil, fmt.Errorf("failed to get plugin: (%d): %s", response.StatusCode(), string(response.Body)) + return nil, fmt.Errorf("failed to get plugin (%d): %s: %w", response.StatusCode(), string(response.Body), err) } if response.StatusCode() == 404 { log.V(1).Info("plugin not found", "id", pluginId) @@ -125,7 +125,7 @@ func (c *kongClient) LoadPlugin( if copyConfig { err = json.Unmarshal(response.Body, &plugin) if err != nil { - return nil, errors.Wrap(err, "failed to unmarshal plugin response") + return nil, fmt.Errorf("failed to unmarshal plugin response: %w", err) } } @@ -148,7 +148,7 @@ loadByTags: if copyConfig { err = deepCopy(kongPlugin, plugin) if err != nil { - return nil, errors.Wrap(err, "failed to copy plugin config") + return nil, fmt.Errorf("failed to copy plugin config: %w", err) } } } @@ -241,7 +241,7 @@ func (c *kongClient) CreateOrReplacePlugin( log.V(1).Info("upserting plugin for consumer", "consumer", *plugin.GetConsumer(), "id", pluginId) response, err = client.UpsertPluginForConsumer(ctx, *plugin.GetConsumer(), pluginId, body) if err != nil { - return nil, errors.Wrap(err, "failed to create plugin") + return nil, fmt.Errorf("failed to create plugin: %w", err) } } else if isRouteSpecific { @@ -251,7 +251,7 @@ func (c *kongClient) CreateOrReplacePlugin( log.V(1).Info("upserting plugin for route", "route", *plugin.GetRoute(), "id", pluginId) response, err = client.UpsertPluginForRoute(ctx, *plugin.GetRoute(), pluginId, body) if err != nil { - return nil, errors.Wrap(err, "failed to upsert plugin for route") + return nil, fmt.Errorf("failed to upsert plugin for route: %w", err) } } else { @@ -259,23 +259,23 @@ func (c *kongClient) CreateOrReplacePlugin( log.V(1).Info("upserting global plugin", "id", pluginId) response, err = client.UpsertPlugin(ctx, pluginId, body) if err != nil { - return nil, errors.Wrap(err, "failed to create plugin") + return nil, fmt.Errorf("failed to create plugin: %w", err) } } apiResponse := WrapApiResponse(response) responseBody, err := io.ReadAll(response.Body) if err != nil { - return nil, errors.Wrap(err, "failed to read response body") + return nil, fmt.Errorf("failed to read response body: %w", err) } response.Body.Close() //nolint:errcheck if err := CheckStatusCode(apiResponse, 200); err != nil { - return nil, fmt.Errorf("failed to create plugin: (%d): %s", apiResponse.StatusCode(), string(responseBody)) + return nil, fmt.Errorf("failed to create plugin (%d): %s: %w", apiResponse.StatusCode(), string(responseBody), err) } err = json.Unmarshal(responseBody, &kongPlugin) if err != nil { - return nil, errors.Wrap(err, "failed to unmarshal plugin response") + return nil, fmt.Errorf("failed to unmarshal plugin response: %w", err) } plugin.SetId(pluginId) @@ -319,7 +319,7 @@ func (c *kongClient) DeletePlugin(ctx context.Context, plugin CustomPlugin) (err return err } if err := CheckStatusCode(response, 200, 204); err != nil { - return fmt.Errorf("failed to delete plugin: (%d): %s", response.StatusCode(), string(response.Body)) + return fmt.Errorf("failed to delete plugin (%d): %s: %w", response.StatusCode(), string(response.Body), err) } return nil } @@ -344,7 +344,7 @@ func (c *kongClient) CleanupPlugins(ctx context.Context, route CustomRoute, cons kongPlugins, err := c.getPluginsMatchingTags(ctx, tags) if err != nil { - return errors.Wrap(err, "failed to list plugins") + return fmt.Errorf("failed to list plugins: %w", err) } pluginIds := make([]string, 0, len(plugins)) @@ -363,7 +363,7 @@ func (c *kongClient) CleanupPlugins(ctx context.Context, route CustomRoute, cons log.V(1).Info("deleting plugin", "name", *kongPlugin.Name, "id", *kongPlugin.Id) _, err := c.client.DeletePluginWithResponse(ctx, *kongPlugin.Id) if err != nil { - return errors.Wrap(err, "failed to delete plugin") + return fmt.Errorf("failed to delete plugin: %w", err) } } } @@ -382,7 +382,7 @@ func (c *kongClient) getPluginsMatchingTags( return nil, err } if err := CheckStatusCode(response, 200); err != nil { - return nil, fmt.Errorf("failed to list plugins: (%d): %s", response.StatusCode(), string(response.Body)) + return nil, fmt.Errorf("failed to list plugins (%d): %s: %w", response.StatusCode(), string(response.Body), err) } // ListPluginWithResponse does not return an array of plugins @@ -444,10 +444,10 @@ func (c *kongClient) CreateOrReplaceRoute(ctx context.Context, route CustomRoute } serviceResponse, err := c.client.UpsertServiceWithResponse(ctx, route.GetName(), serviceBody) if err != nil { - return errors.Wrap(err, "failed to create service") + return fmt.Errorf("failed to create service: %w", err) } if err := CheckStatusCode(serviceResponse, 200); err != nil { - return errors.Wrap(fmt.Errorf("failed to create service: %s", string(serviceResponse.Body)), "failed to create service") + return fmt.Errorf("failed to create service (%d): %s: %w", serviceResponse.StatusCode(), string(serviceResponse.Body), err) } service := serviceResponse.JSON200 @@ -479,10 +479,10 @@ func (c *kongClient) CreateOrReplaceRoute(ctx context.Context, route CustomRoute } routeResponse, err := c.client.UpsertRouteWithResponse(ctx, route.GetName(), routeBody) if err != nil { - return errors.Wrap(err, "failed to create route") + return fmt.Errorf("failed to create route: %w", err) } if err := CheckStatusCode(routeResponse, 200); err != nil { - return errors.Wrap(fmt.Errorf("failed to create route: %s", string(routeResponse.Body)), "failed to create route") + return fmt.Errorf("failed to create route (%d): %s: %w", routeResponse.StatusCode(), string(routeResponse.Body), err) } route.SetRouteId(*routeResponse.JSON200.Id) @@ -497,7 +497,7 @@ func (c *kongClient) DeleteRoute(ctx context.Context, route CustomRoute) error { return err } if err := CheckStatusCode(routeResponse, 200, 204, 404); err != nil { - return fmt.Errorf("failed to delete route: %s", string(routeResponse.Body)) + return fmt.Errorf("failed to delete route (%d): %s: %w", routeResponse.StatusCode(), string(routeResponse.Body), err) } serviceResponse, err := c.client.DeleteServiceWithResponse(ctx, routeName) @@ -505,7 +505,7 @@ func (c *kongClient) DeleteRoute(ctx context.Context, route CustomRoute) error { return err } if err := CheckStatusCode(serviceResponse, 200, 204, 404); err != nil { - return fmt.Errorf("failed to delete service: %s", string(serviceResponse.Body)) + return fmt.Errorf("failed to delete service (%d): %s: %w", serviceResponse.StatusCode(), string(serviceResponse.Body), err) } err = c.DeleteUpstream(ctx, route) @@ -532,7 +532,7 @@ func (c *kongClient) CreateOrReplaceConsumer(ctx context.Context, consumer Custo return nil, err } if err := CheckStatusCode(response, 200); err != nil { - return nil, fmt.Errorf("failed to create consumer: (%d): %s", response.StatusCode(), string(response.Body)) + return nil, fmt.Errorf("failed to create consumer (%d): %s: %w", response.StatusCode(), string(response.Body), err) } isInGroup, err := c.isConsumerInGroup(ctx, consumerName) @@ -542,14 +542,14 @@ func (c *kongClient) CreateOrReplaceConsumer(ctx context.Context, consumer Custo if !isInGroup { err = c.addConsumerToGroup(ctx, consumerName) if err != nil { - return nil, errors.Wrap(err, "failed to add consumer to group") + return nil, fmt.Errorf("failed to add consumer to group: %w", err) } } // The Api-Spec defines a wrong type for the response body, so we need to unmarshal it manually err = json.Unmarshal(response.Body, &kongConsumer) if err != nil { - return nil, errors.Wrap(err, "failed to unmarshal consumer response") + return nil, fmt.Errorf("failed to unmarshal consumer response: %w", err) } consumer.SetId(*kongConsumer.Id) @@ -562,7 +562,7 @@ func (c *kongClient) DeleteConsumer(ctx context.Context, consumer CustomConsumer return err } if err := CheckStatusCode(response, 200, 204, 404); err != nil { - return fmt.Errorf("failed to delete consumer (%d): %s", response.StatusCode(), string(response.Body)) + return fmt.Errorf("failed to delete consumer (%d): %s: %w", response.StatusCode(), string(response.Body), err) } return nil } @@ -576,7 +576,7 @@ func (c *kongClient) addConsumerToGroup(ctx context.Context, consumerName string return err } if err := CheckStatusCode(response, 200, 201); err != nil { - return fmt.Errorf("failed to add consumer to group (%d): %s", response.StatusCode(), string(response.Body)) + return fmt.Errorf("failed to add consumer to group (%d): %s: %w", response.StatusCode(), string(response.Body), err) } return nil @@ -585,11 +585,11 @@ func (c *kongClient) addConsumerToGroup(ctx context.Context, consumerName string func (c *kongClient) isConsumerInGroup(ctx context.Context, consumerName string) (bool, error) { response, err := c.client.ViewGroupConsumerWithResponse(ctx, consumerName) if err != nil { - return false, errors.Wrap(err, "error occurred when getting consumer group") + return false, fmt.Errorf("error occurred when getting consumer group: %w", err) } if err := CheckStatusCode(response, 200); err != nil { - return false, errors.Wrap(err, "error occurred when getting consumer group") + return false, fmt.Errorf("error occurred when getting consumer group: %w", err) } if len(*response.JSON200.Data) == 0 { @@ -605,7 +605,7 @@ func (c *kongClient) DeleteUpstream(ctx context.Context, route CustomRoute) erro return err } if err := CheckStatusCode(upstreamResponse, 200, 204, 404); err != nil { - return fmt.Errorf("failed to delete upstream: %s", string(upstreamResponse.Body)) + return fmt.Errorf("failed to delete upstream (%d): %s: %w", upstreamResponse.StatusCode(), string(upstreamResponse.Body), err) } if route.GetTargetsId() != "" { @@ -615,7 +615,7 @@ func (c *kongClient) DeleteUpstream(ctx context.Context, route CustomRoute) erro return err } if err := CheckStatusCode(targetsResponse, 200, 204, 404); err != nil { - return fmt.Errorf("failed to delete upstream targets: %s", string(targetsResponse.Body)) + return fmt.Errorf("failed to delete upstream targets (%d): %s: %w", targetsResponse.StatusCode(), string(targetsResponse.Body), err) } } return nil diff --git a/gateway/pkg/kong/client/error.go b/gateway/pkg/kong/client/error.go index c725a6fec..2d7060857 100644 --- a/gateway/pkg/kong/client/error.go +++ b/gateway/pkg/kong/client/error.go @@ -5,53 +5,103 @@ package client import ( + stderrors "errors" + "fmt" "net/http" "slices" + "time" ) type ApiResponse interface { StatusCode() int } +// ApiError represents an error from the Kong Admin API. +// It implements the ctrlerrors.BlockedError, ctrlerrors.RetryableError, and +// ctrlerrors.RetryableWithDelayError interfaces via duck-typing, so that +// errors propagated up the call stack are correctly classified by +// ctrlerrors.HandleError without introducing a direct import dependency. type ApiError interface { error Retriable() bool + IsRetryable() bool + IsBlocked() bool + RetryDelay() time.Duration } type apiError struct { - StatusCode int - Message string - RetryAllowed bool + statusCode int + message string + retryAllowed bool + retryDelay time.Duration } func (e *apiError) Error() string { - return e.Message + return e.message } +// Retriable is the original Kong-specific method, kept for backward compatibility. func (e *apiError) Retriable() bool { - return e.RetryAllowed + return e.retryAllowed } +// IsRetryable satisfies the ctrlerrors.RetryableError interface. +func (e *apiError) IsRetryable() bool { + return e.retryAllowed +} + +// IsBlocked satisfies the ctrlerrors.BlockedError interface. +// A 4xx error (non-retryable) is considered blocked — the request is invalid +// and retrying with the same parameters will not succeed. +func (e *apiError) IsBlocked() bool { + return !e.retryAllowed +} + +// RetryDelay satisfies the ctrlerrors.RetryableWithDelayError interface. +func (e *apiError) RetryDelay() time.Duration { + return e.retryDelay +} + +// CheckStatusCode classifies a Kong Admin API response into an ApiError. +// It returns nil when the response status code is in okStatusCodes. func CheckStatusCode(res ApiResponse, okStatusCodes ...int) ApiError { if slices.Contains(okStatusCodes, res.StatusCode()) { return nil } - if res.StatusCode() >= 500 { + if res.StatusCode() == http.StatusTooManyRequests { return &apiError{ - StatusCode: res.StatusCode(), - Message: "Kong server error", - RetryAllowed: true, + statusCode: res.StatusCode(), + message: fmt.Sprintf("Kong rate limit error (%d)", res.StatusCode()), + retryAllowed: true, + retryDelay: 3 * time.Second, + } + } + + if res.StatusCode() >= http.StatusInternalServerError { + return &apiError{ + statusCode: res.StatusCode(), + message: fmt.Sprintf("Kong server error (%d)", res.StatusCode()), + retryAllowed: true, } } return &apiError{ - StatusCode: res.StatusCode(), - Message: "Kong client error", - RetryAllowed: false, + statusCode: res.StatusCode(), + message: fmt.Sprintf("Kong client error (%d)", res.StatusCode()), + retryAllowed: false, } } +// IsNotFound returns true if the error is an ApiError with a 404 status code. +func IsNotFound(err error) bool { + var ae *apiError + if ok := errorAs(err, &ae); ok { + return ae.statusCode == http.StatusNotFound + } + return false +} + func WrapApiResponse(res *http.Response) ApiResponse { return &responseWrapper{ response: res, @@ -68,3 +118,9 @@ func (r *responseWrapper) StatusCode() int { } return r.response.StatusCode } + +// errorAs is a thin wrapper around errors.As to allow testing with the +// unexported apiError type. Production code uses the standard library. +var errorAs = func(err error, target interface{}) bool { + return stderrors.As(err, target) +} diff --git a/gateway/pkg/kong/client/error_test.go b/gateway/pkg/kong/client/error_test.go new file mode 100644 index 000000000..724ac3b99 --- /dev/null +++ b/gateway/pkg/kong/client/error_test.go @@ -0,0 +1,188 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "errors" + "fmt" + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestClient(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Kong Client Suite") +} + +type fakeResponse struct { + code int +} + +func (f *fakeResponse) StatusCode() int { + return f.code +} + +var _ = Describe("CheckStatusCode", func() { + It("returns nil for an OK status code", func() { + err := CheckStatusCode(&fakeResponse{code: 200}, 200) + Expect(err).To(BeNil()) + }) + + It("returns nil when status matches one of multiple OK codes", func() { + err := CheckStatusCode(&fakeResponse{code: 204}, 200, 204, 404) + Expect(err).To(BeNil()) + }) + + Context("429 Too Many Requests", func() { + var apiErr ApiError + + BeforeEach(func() { + apiErr = CheckStatusCode(&fakeResponse{code: 429}, 200) + Expect(apiErr).NotTo(BeNil()) + }) + + It("is retryable", func() { + Expect(apiErr.IsRetryable()).To(BeTrue()) + Expect(apiErr.Retriable()).To(BeTrue()) + }) + + It("is not blocked", func() { + Expect(apiErr.IsBlocked()).To(BeFalse()) + }) + + It("has a retry delay", func() { + Expect(apiErr.RetryDelay()).To(Equal(3 * time.Second)) + }) + + It("includes status code in message", func() { + Expect(apiErr.Error()).To(ContainSubstring("429")) + }) + }) + + Context("5xx Server Error", func() { + var apiErr ApiError + + BeforeEach(func() { + apiErr = CheckStatusCode(&fakeResponse{code: 502}, 200) + Expect(apiErr).NotTo(BeNil()) + }) + + It("is retryable", func() { + Expect(apiErr.IsRetryable()).To(BeTrue()) + }) + + It("is not blocked", func() { + Expect(apiErr.IsBlocked()).To(BeFalse()) + }) + + It("has no retry delay", func() { + Expect(apiErr.RetryDelay()).To(Equal(time.Duration(0))) + }) + + It("includes status code in message", func() { + Expect(apiErr.Error()).To(ContainSubstring("502")) + }) + }) + + Context("4xx Client Error", func() { + var apiErr ApiError + + BeforeEach(func() { + apiErr = CheckStatusCode(&fakeResponse{code: 400}, 200) + Expect(apiErr).NotTo(BeNil()) + }) + + It("is not retryable", func() { + Expect(apiErr.IsRetryable()).To(BeFalse()) + }) + + It("is blocked", func() { + Expect(apiErr.IsBlocked()).To(BeTrue()) + }) + + It("includes status code in message", func() { + Expect(apiErr.Error()).To(ContainSubstring("400")) + }) + }) +}) + +var _ = Describe("IsNotFound", func() { + It("returns true for a 404 error", func() { + err := CheckStatusCode(&fakeResponse{code: 404}, 200) + Expect(IsNotFound(err)).To(BeTrue()) + }) + + It("returns false for a non-404 error", func() { + err := CheckStatusCode(&fakeResponse{code: 400}, 200) + Expect(IsNotFound(err)).To(BeFalse()) + }) + + It("returns false for a nil error", func() { + Expect(IsNotFound(nil)).To(BeFalse()) + }) + + It("returns true when wrapped with fmt.Errorf %%w", func() { + apiErr := CheckStatusCode(&fakeResponse{code: 404}, 200) + wrapped := fmt.Errorf("context: %w", apiErr) + Expect(IsNotFound(wrapped)).To(BeTrue()) + }) +}) + +// ctrlerrors interface types for duck-type verification. +type blockedError interface { + IsBlocked() bool +} + +type retryableError interface { + IsRetryable() bool +} + +type retryableWithDelayError interface { + IsRetryable() bool + RetryDelay() time.Duration +} + +var _ = Describe("Duck-typing through error chain", func() { + It("preserves IsBlocked through fmt.Errorf %%w", func() { + apiErr := CheckStatusCode(&fakeResponse{code: 400}, 200) + wrapped := fmt.Errorf("failed to create route: %w", apiErr) + + var be blockedError + Expect(errors.As(wrapped, &be)).To(BeTrue()) + Expect(be.IsBlocked()).To(BeTrue()) + }) + + It("preserves IsRetryable through fmt.Errorf %%w", func() { + apiErr := CheckStatusCode(&fakeResponse{code: 500}, 200) + wrapped := fmt.Errorf("failed to create route: %w", apiErr) + + var re retryableError + Expect(errors.As(wrapped, &re)).To(BeTrue()) + Expect(re.IsRetryable()).To(BeTrue()) + }) + + It("preserves RetryDelay through fmt.Errorf %%w for 429", func() { + apiErr := CheckStatusCode(&fakeResponse{code: 429}, 200) + wrapped := fmt.Errorf("failed to create route: %w", apiErr) + + var rde retryableWithDelayError + Expect(errors.As(wrapped, &rde)).To(BeTrue()) + Expect(rde.IsRetryable()).To(BeTrue()) + Expect(rde.RetryDelay()).To(Equal(3 * time.Second)) + }) + + It("preserves interfaces through multiple layers of wrapping", func() { + apiErr := CheckStatusCode(&fakeResponse{code: 502}, 200) + wrapped1 := fmt.Errorf("inner: %w", apiErr) + wrapped2 := fmt.Errorf("outer: %w", wrapped1) + + var re retryableError + Expect(errors.As(wrapped2, &re)).To(BeTrue()) + Expect(re.IsRetryable()).To(BeTrue()) + }) +}) diff --git a/secret-manager/api/api.go b/secret-manager/api/api.go index 65cfefd4b..eec5aa24d 100644 --- a/secret-manager/api/api.go +++ b/secret-manager/api/api.go @@ -8,9 +8,9 @@ import ( "context" "encoding/json" "fmt" + "net/http" "strings" - "github.com/pkg/errors" "github.com/telekom/controlplane/common-server/pkg/client" "github.com/telekom/controlplane/secret-manager/api/gen" ) @@ -114,15 +114,17 @@ func (s *secretManagerAPI) Get(ctx context.Context, secretID string) (value stri secretID, _ = FromRef(secretID) res, err := s.client.GetSecretWithResponse(ctx, secretID) if err != nil { - return "", err + return "", fmt.Errorf("secret-manager request failed for %q: %w", secretID, client.RetryableErrorf("network error: %s", err)) } switch res.StatusCode() { - case 200: + case http.StatusOK: return res.JSON200.Value, nil - case 404: + case http.StatusNotFound: return "", ErrNotFound + case http.StatusUnauthorized: + return "", client.BlockedErrorf("unauthorized (%d): %s", res.StatusCode(), string(res.Body)) default: - return "", client.HandleError(res.StatusCode(), string(res.Body)) + return "", handleError(res.StatusCode(), string(res.Body)) } } func (s *secretManagerAPI) Set(ctx context.Context, secretID string, secretValue string) (newID string, err error) { @@ -131,17 +133,19 @@ func (s *secretManagerAPI) Set(ctx context.Context, secretID string, secretValue secretID, _ = FromRef(secretID) res, err := s.client.PutSecretWithResponse(ctx, secretID, gen.PutSecretJSONRequestBody{Value: secretValue}) if err != nil { - return "", err + return "", fmt.Errorf("secret-manager request failed for %q: %w", secretID, client.RetryableErrorf("network error: %s", err)) } switch res.StatusCode() { - case 200: + case http.StatusOK: return ToRef(res.JSON200.Id), nil - case 204: + case http.StatusNoContent: return secretID, nil - case 404: + case http.StatusNotFound: return "", ErrNotFound + case http.StatusUnauthorized: + return "", client.BlockedErrorf("unauthorized (%d): %s", res.StatusCode(), string(res.Body)) default: - return "", client.HandleError(res.StatusCode(), string(res.Body)) + return "", handleError(res.StatusCode(), string(res.Body)) } } @@ -164,17 +168,19 @@ func (s *secretManagerAPI) UpsertEnvironment(ctx context.Context, envID string, res, err := s.client.UpsertEnvironmentWithResponse(ctx, envID, reqBody) if err != nil { - return nil, err + return nil, fmt.Errorf("secret-manager request failed for environment %q: %w", envID, client.RetryableErrorf("network error: %s", err)) } switch res.StatusCode() { - case 200: + case http.StatusOK: return toMap(res.JSON200.Items), nil - case 204: + case http.StatusNoContent: return nil, nil - case 404: + case http.StatusNotFound: return nil, ErrNotFound + case http.StatusUnauthorized: + return nil, client.BlockedErrorf("unauthorized (%d): %s", res.StatusCode(), string(res.Body)) default: - return nil, client.HandleError(res.StatusCode(), string(res.Body)) + return nil, handleError(res.StatusCode(), string(res.Body)) } } @@ -193,17 +199,19 @@ func (s *secretManagerAPI) UpsertTeam(ctx context.Context, envID, teamID string, res, err := s.client.UpsertTeamWithResponse(ctx, envID, teamID, reqBody) if err != nil { - return nil, err + return nil, fmt.Errorf("secret-manager request failed for team %q/%q: %w", envID, teamID, client.RetryableErrorf("network error: %s", err)) } switch res.StatusCode() { - case 200: + case http.StatusOK: return toMap(res.JSON200.Items), nil - case 204: + case http.StatusNoContent: return nil, nil - case 404: + case http.StatusNotFound: return nil, ErrNotFound + case http.StatusUnauthorized: + return nil, client.BlockedErrorf("unauthorized (%d): %s", res.StatusCode(), string(res.Body)) default: - return nil, client.HandleError(res.StatusCode(), string(res.Body)) + return nil, handleError(res.StatusCode(), string(res.Body)) } } @@ -222,69 +230,83 @@ func (s *secretManagerAPI) UpsertApplication(ctx context.Context, envID, teamID, res, err := s.client.UpsertAppWithResponse(ctx, envID, teamID, appID, reqBody) if err != nil { - return nil, err + return nil, fmt.Errorf("secret-manager request failed for app %q/%q/%q: %w", envID, teamID, appID, client.RetryableErrorf("network error: %s", err)) } switch res.StatusCode() { - case 200: + case http.StatusOK: return toMap(res.JSON200.Items), nil - case 204: + case http.StatusNoContent: return nil, nil - case 404: + case http.StatusNotFound: return nil, ErrNotFound + case http.StatusUnauthorized: + return nil, client.BlockedErrorf("unauthorized (%d): %s", res.StatusCode(), string(res.Body)) default: - return nil, client.HandleError(res.StatusCode(), string(res.Body)) + return nil, handleError(res.StatusCode(), string(res.Body)) } } func (s *secretManagerAPI) DeleteEnvironment(ctx context.Context, envID string) (err error) { res, err := s.client.DeleteEnvironmentWithResponse(ctx, envID) if err != nil { - return err + return fmt.Errorf("secret-manager request failed for environment %q: %w", envID, client.RetryableErrorf("network error: %s", err)) } switch res.StatusCode() { - case 200: - return nil - case 204: - return nil - case 404: + case http.StatusOK, http.StatusNoContent, http.StatusNotFound: return nil + case http.StatusUnauthorized: + return client.BlockedErrorf("unauthorized (%d): %s", res.StatusCode(), string(res.Body)) default: - return client.HandleError(res.StatusCode(), string(res.Body)) + return handleError(res.StatusCode(), string(res.Body)) } } func (s *secretManagerAPI) DeleteTeam(ctx context.Context, envID, teamID string) (err error) { res, err := s.client.DeleteTeamWithResponse(ctx, envID, teamID) if err != nil { - return err + return fmt.Errorf("secret-manager request failed for team %q/%q: %w", envID, teamID, client.RetryableErrorf("network error: %s", err)) } switch res.StatusCode() { - case 200: - return nil - case 204: - return nil - case 404: + case http.StatusOK, http.StatusNoContent, http.StatusNotFound: return nil + case http.StatusUnauthorized: + return client.BlockedErrorf("unauthorized (%d): %s", res.StatusCode(), string(res.Body)) default: - return client.HandleError(res.StatusCode(), string(res.Body)) + return handleError(res.StatusCode(), string(res.Body)) } } func (s *secretManagerAPI) DeleteApplication(ctx context.Context, envID, teamID, appID string) (err error) { res, err := s.client.DeleteAppWithResponse(ctx, envID, teamID, appID) if err != nil { - return err + return fmt.Errorf("secret-manager request failed for app %q/%q/%q: %w", envID, teamID, appID, client.RetryableErrorf("network error: %s", err)) } switch res.StatusCode() { - case 200: - return nil - case 204: - return nil - case 404: + case http.StatusOK, http.StatusNoContent, http.StatusNotFound: return nil + case http.StatusUnauthorized: + return client.BlockedErrorf("unauthorized (%d): %s", res.StatusCode(), string(res.Body)) default: - return client.HandleError(res.StatusCode(), string(res.Body)) + return handleError(res.StatusCode(), string(res.Body)) + } +} + +// handleError classifies HTTP status codes from the secret-manager API following +// HTTP semantics: 4xx errors (client errors) are blocked because retrying with the +// same parameters will not succeed; 5xx errors are retryable (transient server failures). +// 408 (Request Timeout) and 429 (Too Many Requests) are special 4xx codes that are +// retryable and are handled by client.HandleError. +func handleError(httpStatus int, msg string) error { + // 408 and 429 are retryable 4xx codes — delegate to client.HandleError which handles them. + if httpStatus == http.StatusRequestTimeout || httpStatus == http.StatusTooManyRequests { + return client.HandleError(httpStatus, msg) + } + // All other 4xx are client errors — blocked, retrying won't help. + if httpStatus >= 400 && httpStatus < 500 { + return client.BlockedErrorf("client error (%d): %s", httpStatus, msg) } + // 5xx and anything else — delegate to client.HandleError. + return client.HandleError(httpStatus, msg) } // FindSecretId will find the secret ID for the given name in the list of secrets. @@ -340,7 +362,7 @@ func toNamedSecrets(secretValues map[string]any) ([]gen.NamedSecret, error) { case map[string]any: jsonValue, err := json.Marshal(v) if err != nil { - return nil, errors.Wrapf(err, "failed to marshal secret value for %s", name) + return nil, fmt.Errorf("failed to marshal secret value for %s: %w", name, err) } secrets = append(secrets, gen.NamedSecret{ Name: name, From fdca182701155df869254ae75725b8ce1f41433f Mon Sep 17 00:00:00 2001 From: Ron Gummich Date: Wed, 13 May 2026 15:51:51 +0200 Subject: [PATCH 4/6] feat(gateway): add RouteOverwrite support for realm identity routes Introduce RouteOverwrite on RealmSpec to allow per-route control of identity routes (issuer, certs, discovery). Each overwrite can disable a route or prepend a custom path prefix to the downstream path. - Move RouteType enum from handler to gateway API for cross-module use - CreateRoute returns nil when a route is disabled via overwrite - Realm handler now cleans up orphaned Route objects via gc.Cleanup - Add owner index on Route for cleanup lookups - Handle nil route returns in createRoutes by clearing status fields --- gateway/api/v1/realm_types.go | 34 ++++++++++++ gateway/api/v1/zz_generated.deepcopy.go | 20 +++++++ .../gateway.cp.ei.telekom.de_realms.yaml | 36 +++++++++++++ gateway/internal/controller/index.go | 6 +++ gateway/internal/handler/realm/handler.go | 53 +++++++++++++----- gateway/internal/handler/realm/routes.go | 54 ++++++++++++++----- 6 files changed, 176 insertions(+), 27 deletions(-) diff --git a/gateway/api/v1/realm_types.go b/gateway/api/v1/realm_types.go index 18abb5aee..c32504a27 100644 --- a/gateway/api/v1/realm_types.go +++ b/gateway/api/v1/realm_types.go @@ -14,6 +14,17 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// RouteType defines the type of route to create for a realm. +// It is used to determine the path format for the route and the downstream URL to use for the route. +// +kubebuilder:validation:Enum=issuer;certs;discovery +type RouteType string + +const ( + RouteTypeIssuer RouteType = "issuer" + RouteTypeCerts RouteType = "certs" + RouteTypeDiscovery RouteType = "discovery" +) + // RealmSpec defines the desired state of Realm type RealmSpec struct { @@ -34,6 +45,29 @@ type RealmSpec struct { // +listType=set // +kubebuilder:default={} DefaultConsumers []string `json:"defaultConsumers"` + + // RouteOverwrites is a list of route overwrites for this realm. If empty, the default routes will be used + // +listType=map + // +listMapKey=type + // +patchStrategy=merge + // +patchMergeKey=type + // +optional + RouteOverwrites []RouteOverwrite `json:"routeOverwrites,omitempty"` +} + +// RouteOverwrite defines the configuration for overwriting a route for a realm. +// It allows to enable/disable a route and to specify a custom path prefix for the route. +// Per default all routes are enabled and use the default path prefix. If a route is disabled, it will not be created for the realm. +type RouteOverwrite struct { + // Type is the type of route to overwrite. It is used to determine which route to overwrite + Type RouteType `json:"type"` + // Enabled indicates whether the route is enabled or not. If false, the route will be disabled and not created + // +kubebuilder:default=true + Enabled bool `json:"enabled"` + // PathPrefix is the path prefix to use for the route. If empty, no prefix will be used + // +kubebuilder:default="" + // +kubebuilder:validation:Pattern=`^(\/[a-zA-Z0-9\-\/]*)?$` + PathPrefix string `json:"pathPrefix,omitempty"` } // RealmStatus defines the observed state of Realm diff --git a/gateway/api/v1/zz_generated.deepcopy.go b/gateway/api/v1/zz_generated.deepcopy.go index 889f1a542..54a7c0d23 100644 --- a/gateway/api/v1/zz_generated.deepcopy.go +++ b/gateway/api/v1/zz_generated.deepcopy.go @@ -808,6 +808,11 @@ func (in *RealmSpec) DeepCopyInto(out *RealmSpec) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.RouteOverwrites != nil { + in, out := &in.RouteOverwrites, &out.RouteOverwrites + *out = make([]RouteOverwrite, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RealmSpec. @@ -944,6 +949,21 @@ func (in *RouteList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RouteOverwrite) DeepCopyInto(out *RouteOverwrite) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RouteOverwrite. +func (in *RouteOverwrite) DeepCopy() *RouteOverwrite { + if in == nil { + return nil + } + out := new(RouteOverwrite) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RouteSpec) DeepCopyInto(out *RouteSpec) { *out = *in diff --git a/gateway/config/crd/bases/gateway.cp.ei.telekom.de_realms.yaml b/gateway/config/crd/bases/gateway.cp.ei.telekom.de_realms.yaml index b2c467abf..4ff7992de 100644 --- a/gateway/config/crd/bases/gateway.cp.ei.telekom.de_realms.yaml +++ b/gateway/config/crd/bases/gateway.cp.ei.telekom.de_realms.yaml @@ -78,6 +78,42 @@ spec: minItems: 1 type: array x-kubernetes-list-type: set + routeOverwrites: + description: RouteOverwrites is a list of route overwrites for this + realm. If empty, the default routes will be used + items: + description: |- + RouteOverwrite defines the configuration for overwriting a route for a realm. + It allows to enable/disable a route and to specify a custom path prefix for the route. + Per default all routes are enabled and use the default path prefix. If a route is disabled, it will not be created for the realm. + properties: + enabled: + default: true + description: Enabled indicates whether the route is enabled + or not. If false, the route will be disabled and not created + type: boolean + pathPrefix: + default: "" + description: PathPrefix is the path prefix to use for the route. + If empty, no prefix will be used + pattern: ^(\/[a-zA-Z0-9\-\/]*)?$ + type: string + type: + description: Type is the type of route to overwrite. It is used + to determine which route to overwrite + enum: + - issuer + - certs + - discovery + type: string + required: + - enabled + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map urls: description: Urls is the list of Gateway URLs that this realm should accept traffic from diff --git a/gateway/internal/controller/index.go b/gateway/internal/controller/index.go index 5d4815b0a..cc2e59d08 100644 --- a/gateway/internal/controller/index.go +++ b/gateway/internal/controller/index.go @@ -8,6 +8,7 @@ import ( "context" "os" + "github.com/telekom/controlplane/common/pkg/controller/index" gatewayv1 "github.com/telekom/controlplane/gateway/api/v1" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" @@ -78,4 +79,9 @@ func RegisterIndecesOrDie(ctx context.Context, mgr ctrl.Manager) { os.Exit(1) } + err = index.SetOwnerIndex(ctx, mgr.GetFieldIndexer(), &gatewayv1.Route{}) + if err != nil { + ctrl.Log.Error(err, "unable to create field-indexer") + os.Exit(1) + } } diff --git a/gateway/internal/handler/realm/handler.go b/gateway/internal/handler/realm/handler.go index 633474e6b..611f01ebe 100644 --- a/gateway/internal/handler/realm/handler.go +++ b/gateway/internal/handler/realm/handler.go @@ -7,7 +7,9 @@ package realm import ( "context" + "github.com/go-logr/logr" "github.com/pkg/errors" + "github.com/telekom/controlplane/common/pkg/client" "github.com/telekom/controlplane/common/pkg/condition" "github.com/telekom/controlplane/common/pkg/handler" "github.com/telekom/controlplane/common/pkg/types" @@ -19,7 +21,7 @@ var _ handler.Handler[*gatewayv1.Realm] = &RealmHandler{} type RealmHandler struct{} func (h *RealmHandler) CreateOrUpdate(ctx context.Context, realm *gatewayv1.Realm) error { - + logger := logr.FromContextOrDiscard(ctx) realm.Status.Virtual = realm.Spec.Gateway == nil if !realm.Status.Virtual { @@ -28,6 +30,16 @@ func (h *RealmHandler) CreateOrUpdate(ctx context.Context, realm *gatewayv1.Real } } + gc := client.ClientFromContextOrDie(ctx) + + n, err := gc.Cleanup(ctx, &gatewayv1.RouteList{}, client.OwnedBy(realm)) + if err != nil { + return errors.Wrap(err, "failed to cleanup routes") + } + if n > 0 { + logger.V(1).Info("Cleaned up routes", "count", n) + } + realm.SetCondition(condition.NewReadyCondition("RealmReady", "Realm has been provisioned")) realm.SetCondition(condition.NewDoneProcessingCondition("Realm has been provisioned")) @@ -40,26 +52,41 @@ func (h *RealmHandler) Delete(ctx context.Context, realm *gatewayv1.Realm) error func createRoutes(ctx context.Context, realm *gatewayv1.Realm) error { - route, err := CreateRoute(ctx, realm, RouteTypeIssuer) + route, err := CreateRoute(ctx, realm, gatewayv1.RouteTypeIssuer) if err != nil { - return errors.Wrapf(err, "failed to create route '%s'", RouteTypeIssuer) + return errors.Wrapf(err, "failed to create route %q", gatewayv1.RouteTypeIssuer) + } + if route != nil { + realm.Status.IssuerRoute = types.ObjectRefFromObject(route) + realm.Status.IssuerUrl = route.Spec.Downstreams[0].Url() + } else { + realm.Status.IssuerRoute = nil + realm.Status.IssuerUrl = "" } - realm.Status.IssuerRoute = types.ObjectRefFromObject(route) - realm.Status.IssuerUrl = route.Spec.Downstreams[0].Url() - route, err = CreateRoute(ctx, realm, RouteTypeCerts) + route, err = CreateRoute(ctx, realm, gatewayv1.RouteTypeCerts) if err != nil { - return errors.Wrapf(err, "failed to create route '%s'", RouteTypeCerts) + return errors.Wrapf(err, "failed to create route %q", gatewayv1.RouteTypeCerts) + } + if route != nil { + realm.Status.CertsRoute = types.ObjectRefFromObject(route) + realm.Status.CertsUrl = route.Spec.Downstreams[0].Url() + } else { + realm.Status.CertsRoute = nil + realm.Status.CertsUrl = "" } - realm.Status.CertsRoute = types.ObjectRefFromObject(route) - realm.Status.CertsUrl = route.Spec.Downstreams[0].Url() - route, err = CreateRoute(ctx, realm, RouteTypeDiscovery) + route, err = CreateRoute(ctx, realm, gatewayv1.RouteTypeDiscovery) if err != nil { - return errors.Wrapf(err, "failed to create route '%s'", RouteTypeDiscovery) + return errors.Wrapf(err, "failed to create route %q", gatewayv1.RouteTypeDiscovery) + } + if route != nil { + realm.Status.DiscoveryRoute = types.ObjectRefFromObject(route) + realm.Status.DiscoveryUrl = route.Spec.Downstreams[0].Url() + } else { + realm.Status.DiscoveryRoute = nil + realm.Status.DiscoveryUrl = "" } - realm.Status.DiscoveryRoute = types.ObjectRefFromObject(route) - realm.Status.DiscoveryUrl = route.Spec.Downstreams[0].Url() return nil } diff --git a/gateway/internal/handler/realm/routes.go b/gateway/internal/handler/realm/routes.go index 7b4e548a7..b3585e325 100644 --- a/gateway/internal/handler/realm/routes.go +++ b/gateway/internal/handler/realm/routes.go @@ -18,35 +18,51 @@ import ( "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" ) -type RouteType string - -const ( - RouteTypeIssuer RouteType = "issuer" - RouteTypeCerts RouteType = "certs" - RouteTypeDiscovery RouteType = "discovery" -) - type routeConfig struct { UpstreamPathFormat string DownstreamPathFormat string } -var routeMap = map[RouteType]routeConfig{ - RouteTypeIssuer: { +func (c routeConfig) UpstreamPath(realmName string) string { + return fmt.Sprintf(c.UpstreamPathFormat, realmName) +} + +func (c routeConfig) DownstreamPath(realmName string) string { + return fmt.Sprintf(c.DownstreamPathFormat, realmName) +} + +func (c routeConfig) DownstreamPathWithPrefix(realmName, prefix string) string { + if prefix == "" { + return c.DownstreamPath(realmName) + } + return path.Join(prefix, c.DownstreamPath(realmName)) +} + +var routeMap = map[gatewayv1.RouteType]routeConfig{ + gatewayv1.RouteTypeIssuer: { UpstreamPathFormat: "/api/v1/issuer/%s", DownstreamPathFormat: "/auth/realms/%s", }, - RouteTypeCerts: { + gatewayv1.RouteTypeCerts: { UpstreamPathFormat: "/api/v1/certs/%s", DownstreamPathFormat: "/auth/realms/%s/protocol/openid-connect/certs", }, - RouteTypeDiscovery: { + gatewayv1.RouteTypeDiscovery: { UpstreamPathFormat: "/api/v1/discovery/%s", DownstreamPathFormat: "/auth/realms/%s/.well-known/openid-configuration", }, } -func CreateRoute(ctx context.Context, realm *gatewayv1.Realm, routeType RouteType) (*gatewayv1.Route, error) { +func findRouteOverwrite(realm *gatewayv1.Realm, routeType gatewayv1.RouteType) *gatewayv1.RouteOverwrite { + for i := range realm.Spec.RouteOverwrites { + if realm.Spec.RouteOverwrites[i].Type == routeType { + return &realm.Spec.RouteOverwrites[i] + } + } + return nil +} + +func CreateRoute(ctx context.Context, realm *gatewayv1.Realm, routeType gatewayv1.RouteType) (*gatewayv1.Route, error) { c := client.ClientFromContextOrDie(ctx) cfg, exists := routeMap[routeType] @@ -54,6 +70,16 @@ func CreateRoute(ctx context.Context, realm *gatewayv1.Realm, routeType RouteTyp return nil, errors.Errorf("route type %s not found", routeType) } + overwrite := findRouteOverwrite(realm, routeType) + if overwrite != nil && !overwrite.Enabled { + return nil, nil + } + + var downstreamPrefix string + if overwrite != nil { + downstreamPrefix = overwrite.PathPrefix + } + route := &gatewayv1.Route{ ObjectMeta: metav1.ObjectMeta{ Name: realm.Name + "--" + string(routeType), @@ -90,7 +116,7 @@ func CreateRoute(ctx context.Context, realm *gatewayv1.Realm, routeType RouteTyp { Host: url.Hostname(), Port: gatewayv1.GetPortOrDefaultFromScheme(url), - Path: path.Join(url.Path, fmt.Sprintf(cfg.DownstreamPathFormat, realm.Name)), + Path: path.Join(url.Path, cfg.DownstreamPathWithPrefix(realm.Name, downstreamPrefix)), IssuerUrl: "", }, }, From dc7e03552cb2f30b5e3883e3076d843e9f4b8d00 Mon Sep 17 00:00:00 2001 From: Ron Gummich Date: Wed, 13 May 2026 15:52:15 +0200 Subject: [PATCH 5/6] feat(admin): add spacegate route overwrites for World-visible zones When a zone has Visibility=World, populate RouteOverwrites on the gateway realm with /spacegate prefix for issuer, certs, and discovery routes. This keeps the actual IDP unexposed on internet-facing gateways by proxying identity endpoints under a common prefix. - Enterprise zones get no route overwrites (tested) - Update Zone CRD: rename routes->realm, drop minItems constraint - Update existing zone controller tests with expected RouteOverwrites --- admin/api/v1/zone_types.go | 6 ++- .../bases/admin.cp.ei.telekom.de_zones.yaml | 9 +++-- .../controller/zone_controller_test.go | 37 +++++++++++++++++++ admin/internal/handler/zone/handler.go | 25 +++++++++++++ 4 files changed, 73 insertions(+), 4 deletions(-) diff --git a/admin/api/v1/zone_types.go b/admin/api/v1/zone_types.go index 7858107d7..d822b55c8 100644 --- a/admin/api/v1/zone_types.go +++ b/admin/api/v1/zone_types.go @@ -112,8 +112,12 @@ type ManagedRouteConfig struct { Type ManagedRouteType `json:"type"` } +// ManagedRoutesConfig defines the configuration for managed routes in a zone. +// Managed routes are automatically created and managed by the system based on this configuration. type ManagedRoutesConfig struct { - // +kubebuilder:validation:MinItems=1 + // Routes is the list of routes to be created for this zone. + // It may be used to create additional routes that are required for operating the zone + // +optional Routes []ManagedRouteConfig `json:"routes"` } diff --git a/admin/config/crd/bases/admin.cp.ei.telekom.de_zones.yaml b/admin/config/crd/bases/admin.cp.ei.telekom.de_zones.yaml index 80b46c384..66bed5dc7 100644 --- a/admin/config/crd/bases/admin.cp.ei.telekom.de_zones.yaml +++ b/admin/config/crd/bases/admin.cp.ei.telekom.de_zones.yaml @@ -190,8 +190,14 @@ spec: - url type: object managedRoutes: + description: |- + ManagedRoutesConfig defines the configuration for managed routes in a zone. + Managed routes are automatically created and managed by the system based on this configuration. properties: routes: + description: |- + Routes is the list of routes to be created for this zone. + It may be used to create additional routes that are required for operating the zone items: properties: name: @@ -221,10 +227,7 @@ spec: - type - url type: object - minItems: 1 type: array - required: - - routes type: object permissions: description: Permissions configuration for permission service integration diff --git a/admin/internal/controller/zone_controller_test.go b/admin/internal/controller/zone_controller_test.go index 1a0191629..c1a62c0e0 100644 --- a/admin/internal/controller/zone_controller_test.go +++ b/admin/internal/controller/zone_controller_test.go @@ -186,6 +186,33 @@ var _ = Describe("Zone Controller", func() { }) }) + Context("When reconciling an Enterprise zone", func() { + It("should not add RouteOverwrites to the gateway realm", func() { + zone := NewZone("test-zone-enterprise", testNamespace) + zone.Spec.Visibility = adminv1.ZoneVisibilityEnterprise + zone.Spec.ManagedRoutes = nil + Expect(k8sClient.Create(ctx, zone)).To(Succeed()) + DeferCleanup(func() { + Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, zone))).To(Succeed()) + }) + + Eventually(func(g Gomega) { + got := &adminv1.Zone{} + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(zone), got) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(meta.IsStatusConditionTrue(got.Status.Conditions, condition.ConditionTypeReady)).To(BeTrue()) + + gatewayRealm := &gatewayapi.Realm{} + err = k8sClient.Get(ctx, client.ObjectKey{ + Namespace: "test--test-zone-enterprise", + Name: "test", + }, gatewayRealm) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(gatewayRealm.Spec.RouteOverwrites).To(BeEmpty()) + }, timeout, interval).Should(Succeed()) + }) + }) + Context("ExternalIdPolicies round-trip", func() { It("persists ExternalIdPolicies on the Zone spec", func() { zone := NewZone("test-zone-extids", testNamespace) @@ -364,6 +391,11 @@ func VerifyZone(ctx context.Context, g Gomega, namespacedName client.ObjectKey, Urls: []string{"https://test-stargate.de/"}, IssuerUrls: []string{"https://test-iris.de/auth/realms/test"}, DefaultConsumers: []string{}, + RouteOverwrites: []gatewayapi.RouteOverwrite{ + {Type: gatewayapi.RouteTypeIssuer, Enabled: true, PathPrefix: "/spacegate"}, + {Type: gatewayapi.RouteTypeCerts, Enabled: true, PathPrefix: "/spacegate"}, + {Type: gatewayapi.RouteTypeDiscovery, Enabled: true, PathPrefix: "/spacegate"}, + }, } g.Expect(gatewayRealm.Spec).To(Equal(gatewayRealmSpec)) g.Expect(zone.Status.GatewayRealm).To(Equal(types.ObjectRefFromObject(gatewayRealm))) @@ -400,6 +432,11 @@ func VerifyZone(ctx context.Context, g Gomega, namespacedName client.ObjectKey, Urls: []string{"https://test-stargate.de/"}, IssuerUrls: []string{"https://test-iris.de/auth/realms/team-test"}, DefaultConsumers: []string{}, + RouteOverwrites: []gatewayapi.RouteOverwrite{ + {Type: gatewayapi.RouteTypeIssuer, Enabled: true, PathPrefix: "/spacegate"}, + {Type: gatewayapi.RouteTypeCerts, Enabled: true, PathPrefix: "/spacegate"}, + {Type: gatewayapi.RouteTypeDiscovery, Enabled: true, PathPrefix: "/spacegate"}, + }, } g.Expect(teamApiGatewayRealm.Spec).To(Equal(teamApiGatewayRealmSpec)) g.Expect(zone.Status.TeamApiGatewayRealm).To(Equal(types.ObjectRefFromObject(teamApiGatewayRealm))) diff --git a/admin/internal/handler/zone/handler.go b/admin/internal/handler/zone/handler.go index 630ea3a23..f24ec25f9 100644 --- a/admin/internal/handler/zone/handler.go +++ b/admin/internal/handler/zone/handler.go @@ -32,6 +32,10 @@ import ( const ( zoneLabelName = "zone" + + // spacegatePathPrefix is the downstream path prefix added to all identity + // routes (issuer, certs, discovery) when a zone's visibility is World. + spacegatePathPrefix = "/spacegate" ) var _ handler.Handler[*adminv1.Zone] = &ZoneHandler{} @@ -357,11 +361,32 @@ func createGatewayRealm(ctx context.Context, handlingContext HandlingContext, ga cconfig.BuildLabelKey(zoneLabelName): handlingContext.Zone.Name, } + var routeOverwrites []gatewayapi.RouteOverwrite + // If the zone is WORLD visible, the gateway is considered a "SpaceGate" + // to reduce internet-facing exposure the actual IDP routes are not exposed directly + // but via a proxy route "/auth/realms/". However, this path is already used for + // the Gateway Realm itself, so we need to add another prefix to avoid conflicts. + // The SpaceGate route will then be available under a common-prefix + if handlingContext.Zone.Spec.Visibility == adminv1.ZoneVisibilityWorld { + for _, rt := range []gatewayapi.RouteType{ + gatewayapi.RouteTypeIssuer, + gatewayapi.RouteTypeCerts, + gatewayapi.RouteTypeDiscovery, + } { + routeOverwrites = append(routeOverwrites, gatewayapi.RouteOverwrite{ + Type: rt, + Enabled: true, + PathPrefix: spacegatePathPrefix, + }) + } + } + gatewayRealm.Spec = gatewayapi.RealmSpec{ Gateway: types.ObjectRefFromObject(gateway), Urls: []string{handlingContext.Zone.Spec.Gateway.Url}, IssuerUrls: []string{urls.ForGatewayRealm(handlingContext.Zone.Spec.IdentityProvider.Url, realmName)}, DefaultConsumers: []string{}, + RouteOverwrites: routeOverwrites, } return nil } From 0ba14249814d4aaf1b3c5f8f0c6bb8b963848dd2 Mon Sep 17 00:00:00 2001 From: Ron Gummich Date: Wed, 13 May 2026 16:38:52 +0200 Subject: [PATCH 6/6] feat(admin): cleanup stale managed routes using JanitorClient Label managed routes with the Zone's owner UID and use OwnedByLabel with the JanitorClient to clean up routes that were not created or updated during reconciliation. This handles both the case where routes are modified and where managed routes are removed from the spec entirely. OwnedByLabel is used instead of OwnedBy because routes live in a different namespace than the Zone CR, preventing cross-namespace controller references. --- admin/internal/handler/zone/handler.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/admin/internal/handler/zone/handler.go b/admin/internal/handler/zone/handler.go index f24ec25f9..b5675db9b 100644 --- a/admin/internal/handler/zone/handler.go +++ b/admin/internal/handler/zone/handler.go @@ -166,6 +166,12 @@ func (h *ZoneHandler) CreateOrUpdate(ctx context.Context, obj *adminv1.Zone) err obj.Status.Links.TeamIssuer = "" } + // Cleanup managed routes that were not created or updated during this reconciliation. + // Using OwnedByLabel because routes live in a different namespace than the Zone CR. + if _, err := c.Cleanup(ctx, &gatewayapi.RouteList{}, cclient.OwnedByLabel(obj)); err != nil { + return errors.Wrapf(err, "failed to cleanup stale managed routes for zone %s", obj.Name) + } + // Populate Permissions URL if configured and feature enabled if cconfig.FeaturePermission.IsEnabled() && obj.Spec.Permissions != nil { // Use url.JoinPath to properly handle slashes when combining gateway URL with ApiBasePath @@ -265,6 +271,7 @@ func createManagedRoute(ctx context.Context, handlingContext HandlingContext, ro route.Labels = map[string]string{ cconfig.EnvironmentLabelKey: handlingContext.Environment.Name, cconfig.BuildLabelKey(zoneLabelName): handlingContext.Zone.Name, + cconfig.OwnerUidLabelKey: string(handlingContext.Zone.GetUID()), } upstreamUrl, err := url.Parse(routeConfig.Url)