From 5b682f489face54ce0f6507c833e43cc69414ae0 Mon Sep 17 00:00:00 2001 From: Tian Feng Date: Wed, 26 Aug 2026 14:23:35 -0700 Subject: [PATCH] fix(apiserver): allow queryserver egress to Linseed via guardian on managed clusters A managed cluster runs no local Linseed, so the queryserver reaches it through guardian. The apiserver-access policy only permitted the local Linseed pods, which select nothing on a managed cluster, so the request fell through to the trailing Pass and was denied by whatever the customer's tiers enforce. Branch the rule on ManagementClusterConnection, the same condition LINSEED_URL already uses. The calico-system policy table already had managed entries, but SelectPolicyByProvider only switches on the provider, so both managed cases were asserting the unmanaged fixture and could not have caught this. Switch to SelectPolicyByClusterTypeAndProvider and add the managed fixtures. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/render/apiserver.go | 10 ++ pkg/render/apiserver_test.go | 39 ++++- .../expected_policies/apiserver_managed.json | 123 ++++++++++++++++ .../apiserver_managed_ocp.json | 134 ++++++++++++++++++ 4 files changed, 305 insertions(+), 1 deletion(-) create mode 100644 pkg/render/testutils/expected_policies/apiserver_managed.json create mode 100644 pkg/render/testutils/expected_policies/apiserver_managed_ocp.json diff --git a/pkg/render/apiserver.go b/pkg/render/apiserver.go index 1a87b3345c..3bf3dc4e30 100644 --- a/pkg/render/apiserver.go +++ b/pkg/render/apiserver.go @@ -565,6 +565,16 @@ func calicoSystemAPIServerPolicy(cfg *APIServerConfiguration) *v3.NetworkPolicy }, }...) + // A managed cluster has no local Linseed; the query server reaches it through Guardian, + // matching the LINSEED_URL that LinseedEndpoint returns. + if cfg.ManagementClusterConnection != nil { + egressRules = append(egressRules, v3.Rule{ + Action: v3.Allow, + Protocol: &networkpolicy.TCPProtocol, + Destination: GuardianEntityRule, + }) + } + if cfg.KeyValidatorConfig != nil { if parsedURL, err := url.Parse(cfg.KeyValidatorConfig.Issuer()); err == nil { oidcEgressRule := networkpolicy.GetOIDCEgressRule(parsedURL) diff --git a/pkg/render/apiserver_test.go b/pkg/render/apiserver_test.go index 2d17eb0d2a..f9dbbf5730 100644 --- a/pkg/render/apiserver_test.go +++ b/pkg/render/apiserver_test.go @@ -63,6 +63,8 @@ import ( var _ = Describe("API server rendering tests (Calico Enterprise)", func() { apiServerPolicy := testutils.GetExpectedPolicyFromFile("./testutils/expected_policies/apiserver.json") apiServerPolicyForOCP := testutils.GetExpectedPolicyFromFile("./testutils/expected_policies/apiserver_ocp.json") + apiServerPolicyForManaged := testutils.GetExpectedPolicyFromFile("./testutils/expected_policies/apiserver_managed.json") + apiServerPolicyForManagedOCP := testutils.GetExpectedPolicyFromFile("./testutils/expected_policies/apiserver_managed_ocp.json") var ( instance *operatorv1.InstallationSpec apiserver *operatorv1.APIServerSpec @@ -830,6 +832,33 @@ var _ = Describe("API server rendering tests (Calico Enterprise)", func() { })) }) + It("should allow egress to Guardian on a managed cluster", func() { + cfg.ManagementClusterConnection = &operatorv1.ManagementClusterConnection{} + + component := render.APIServerPolicy(cfg) + resources, _ := component.Objects() + policyName := types.NamespacedName{Name: "calico-system.apiserver-access", Namespace: "calico-system"} + policy := testutils.GetCalicoSystemPolicyFromResources(policyName, resources) + Expect(policy).ToNot(BeNil()) + Expect(policy.Spec.Egress).To(ContainElement(calicov3.Rule{ + Action: calicov3.Allow, + Protocol: &networkpolicy.TCPProtocol, + Destination: render.GuardianEntityRule, + })) + // The rule is only reached if it precedes the trailing Pass. + n := len(policy.Spec.Egress) + Expect(policy.Spec.Egress[n-1].Action).To(Equal(calicov3.Pass)) + }) + + It("should omit the Guardian egress rule when the cluster is not managed", func() { + component := render.APIServerPolicy(cfg) + resources, _ := component.Objects() + policyName := types.NamespacedName{Name: "calico-system.apiserver-access", Namespace: "calico-system"} + policy := testutils.GetCalicoSystemPolicyFromResources(policyName, resources) + Expect(policy).ToNot(BeNil()) + Expect(policy.Spec.Egress).NotTo(ContainElement(HaveField("Destination", render.GuardianEntityRule))) + }) + It("should add egress policy with Enterprise variant and K8SServiceEndpoint as IP defined", func() { cfg.K8SServiceEndpoint.Host = "169.169.169.169" cfg.K8SServiceEndpoint.Port = "4321" @@ -1182,7 +1211,15 @@ var _ = Describe("API server rendering tests (Calico Enterprise)", func() { resources, _ := component.Objects() policy := testutils.GetCalicoSystemPolicyFromResources(policyName, resources) - expectedPolicy := testutils.SelectPolicyByProvider(scenario, apiServerPolicy, apiServerPolicyForOCP) + expectedPolicy := testutils.SelectPolicyByClusterTypeAndProvider( + scenario, + map[string]*calicov3.NetworkPolicy{ + "unmanaged": apiServerPolicy, + "unmanaged-openshift": apiServerPolicyForOCP, + "managed": apiServerPolicyForManaged, + "managed-openshift": apiServerPolicyForManagedOCP, + }, + ) Expect(policy).To(Equal(expectedPolicy)) }, Entry("for management/standalone, kube-dns", testutils.CalicoSystemScenario{ManagedCluster: false, OpenShift: false}), diff --git a/pkg/render/testutils/expected_policies/apiserver_managed.json b/pkg/render/testutils/expected_policies/apiserver_managed.json new file mode 100644 index 0000000000..e3ebdcc3f2 --- /dev/null +++ b/pkg/render/testutils/expected_policies/apiserver_managed.json @@ -0,0 +1,123 @@ +{ + "apiVersion": "projectcalico.org/v3", + "kind": "NetworkPolicy", + "metadata": { + "name": "calico-system.apiserver-access", + "namespace": "calico-system" + }, + "spec": { + "order": 1, + "tier": "calico-system", + "selector": "k8s-app == 'calico-apiserver'", + "types": [ + "Ingress", + "Egress" + ], + "ingress": [ + { + "action": "Allow", + "protocol": "TCP", + "source": { + "nets": [ + "0.0.0.0/0" + ] + }, + "destination": { + "ports": [ + 443, + 5443, + 8080, + 10443 + ] + } + }, + { + "action": "Allow", + "protocol": "TCP", + "source": { + "nets": [ + "::/0" + ] + }, + "destination": { + "ports": [ + 443, + 5443, + 8080, + 10443 + ] + } + } + ], + "egress": [ + { + "action": "Allow", + "protocol": "UDP", + "destination": { + "namespaceSelector": "projectcalico.org/name == 'kube-system'", + "selector": "k8s-app in { 'kube-dns', 'coredns' }", + "ports": [ + 53 + ] + } + }, + { + "action": "Allow", + "protocol": "TCP", + "destination": { + "services": { + "name": "kubernetes", + "namespace": "default" + } + } + }, + { + "action": "Allow", + "protocol": "TCP", + "destination": { + "namespaceSelector": "projectcalico.org/name == 'tigera-prometheus'", + "selector": "k8s-app == 'tigera-prometheus'", + "ports": [ + 9095 + ] + } + }, + { + "action": "Allow", + "protocol": "TCP", + "destination": { + "selector": "k8s-app == 'tigera-dex'", + "namespaceSelector": "projectcalico.org/name == 'tigera-dex'", + "ports": [ + 5556 + ] + } + }, + { + "action": "Allow", + "protocol": "TCP", + "destination": { + "namespaceSelector": "projectcalico.org/name == 'tigera-elasticsearch'", + "selector": "k8s-app == 'tigera-linseed'", + "ports": [ + 8444 + ] + } + }, + { + "action": "Allow", + "protocol": "TCP", + "destination": { + "namespaceSelector": "projectcalico.org/name == 'calico-system'", + "selector": "k8s-app == 'guardian'", + "ports": [ + 8080 + ] + } + }, + { + "action": "Pass" + } + ] + } +} diff --git a/pkg/render/testutils/expected_policies/apiserver_managed_ocp.json b/pkg/render/testutils/expected_policies/apiserver_managed_ocp.json new file mode 100644 index 0000000000..97ed1d1267 --- /dev/null +++ b/pkg/render/testutils/expected_policies/apiserver_managed_ocp.json @@ -0,0 +1,134 @@ +{ + "apiVersion": "projectcalico.org/v3", + "kind": "NetworkPolicy", + "metadata": { + "name": "calico-system.apiserver-access", + "namespace": "calico-system" + }, + "spec": { + "order": 1, + "tier": "calico-system", + "selector": "k8s-app == 'calico-apiserver'", + "types": [ + "Ingress", + "Egress" + ], + "ingress": [ + { + "action": "Allow", + "protocol": "TCP", + "source": { + "nets": [ + "0.0.0.0/0" + ] + }, + "destination": { + "ports": [ + 443, + 5443, + 8080, + 10443 + ] + } + }, + { + "action": "Allow", + "protocol": "TCP", + "source": { + "nets": [ + "::/0" + ] + }, + "destination": { + "ports": [ + 443, + 5443, + 8080, + 10443 + ] + } + } + ], + "egress": [ + { + "action": "Allow", + "protocol": "UDP", + "destination": { + "namespaceSelector": "projectcalico.org/name == 'openshift-dns'", + "selector": "dns.operator.openshift.io/daemonset-dns == 'default'", + "ports": [ + 5353 + ] + } + }, + { + "action": "Allow", + "protocol": "TCP", + "destination": { + "namespaceSelector": "projectcalico.org/name == 'openshift-dns'", + "selector": "dns.operator.openshift.io/daemonset-dns == 'default'", + "ports": [ + 5353 + ] + } + }, + { + "action": "Allow", + "protocol": "TCP", + "destination": { + "services": { + "name": "kubernetes", + "namespace": "default" + } + } + }, + { + "action": "Allow", + "protocol": "TCP", + "destination": { + "namespaceSelector": "projectcalico.org/name == 'tigera-prometheus'", + "selector": "k8s-app == 'tigera-prometheus'", + "ports": [ + 9095 + ] + } + }, + { + "action": "Allow", + "protocol": "TCP", + "destination": { + "selector": "k8s-app == 'tigera-dex'", + "namespaceSelector": "projectcalico.org/name == 'tigera-dex'", + "ports": [ + 5556 + ] + } + }, + { + "action": "Allow", + "protocol": "TCP", + "destination": { + "namespaceSelector": "projectcalico.org/name == 'tigera-elasticsearch'", + "selector": "k8s-app == 'tigera-linseed'", + "ports": [ + 8444 + ] + } + }, + { + "action": "Allow", + "protocol": "TCP", + "destination": { + "namespaceSelector": "projectcalico.org/name == 'calico-system'", + "selector": "k8s-app == 'guardian'", + "ports": [ + 8080 + ] + } + }, + { + "action": "Pass" + } + ] + } +}