Skip to content

Commit 24ddfce

Browse files
fix: enforce ReferenceGrant on cross-namespace Consumer SecretRef (#432)
1 parent 0e070b0 commit 24ddfce

3 files changed

Lines changed: 235 additions & 4 deletions

File tree

internal/controller/consumer_controller.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import (
3535
"sigs.k8s.io/controller-runtime/pkg/predicate"
3636
"sigs.k8s.io/controller-runtime/pkg/reconcile"
3737
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
38+
"sigs.k8s.io/gateway-api/apis/v1beta1"
3839

3940
"github.com/apache/apisix-ingress-controller/api/v1alpha1"
4041
"github.com/apache/apisix-ingress-controller/internal/controller/config"
@@ -253,6 +254,27 @@ func (r *ConsumerReconciler) processSpec(ctx context.Context, tctx *provider.Tra
253254
if credential.SecretRef.Namespace != nil {
254255
ns = *credential.SecretRef.Namespace
255256
}
257+
// A cross-namespace SecretRef needs a ReferenceGrant, same as routes.
258+
secretNS := gatewayv1.Namespace(ns)
259+
if permitted := checkReferenceGrant(ctx,
260+
r.Client,
261+
v1beta1.ReferenceGrantFrom{
262+
Group: v1beta1.Group(v1alpha1.GroupVersion.Group),
263+
Kind: v1beta1.Kind(internaltypes.KindConsumer),
264+
Namespace: v1beta1.Namespace(consumer.GetNamespace()),
265+
},
266+
gatewayv1.ObjectReference{
267+
Group: corev1.GroupName,
268+
Kind: KindSecret,
269+
Name: gatewayv1.ObjectName(credential.SecretRef.Name),
270+
Namespace: &secretNS,
271+
},
272+
); !permitted {
273+
r.Log.Error(nil, "cross-namespace secret reference not permitted by any ReferenceGrant",
274+
"consumer", utils.NamespacedName(consumer), "secret", client.ObjectKey{Namespace: ns, Name: credential.SecretRef.Name})
275+
return fmt.Errorf("cross-namespace secret reference from Consumer %s/%s to Secret %s/%s is not permitted by any ReferenceGrant",
276+
consumer.GetNamespace(), consumer.GetName(), ns, credential.SecretRef.Name)
277+
}
256278
secret := corev1.Secret{}
257279
if err := r.Get(ctx, client.ObjectKey{
258280
Name: credential.SecretRef.Name,
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
package controller
19+
20+
import (
21+
"context"
22+
"testing"
23+
24+
"github.com/go-logr/logr"
25+
"github.com/stretchr/testify/require"
26+
corev1 "k8s.io/api/core/v1"
27+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
28+
"k8s.io/apimachinery/pkg/runtime"
29+
"k8s.io/apimachinery/pkg/types"
30+
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
31+
"sigs.k8s.io/controller-runtime/pkg/client/fake"
32+
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
33+
"sigs.k8s.io/gateway-api/apis/v1beta1"
34+
35+
"github.com/apache/apisix-ingress-controller/api/v1alpha1"
36+
"github.com/apache/apisix-ingress-controller/internal/provider"
37+
)
38+
39+
const (
40+
consumerNS = "team-a"
41+
secretNS = "team-b"
42+
)
43+
44+
func buildConsumerReconciler(t *testing.T, objs ...runtime.Object) *ConsumerReconciler {
45+
t.Helper()
46+
47+
scheme := runtime.NewScheme()
48+
require.NoError(t, clientgoscheme.AddToScheme(scheme))
49+
require.NoError(t, v1alpha1.AddToScheme(scheme))
50+
require.NoError(t, gatewayv1.Install(scheme))
51+
require.NoError(t, v1beta1.Install(scheme))
52+
53+
cli := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(objs...).Build()
54+
return &ConsumerReconciler{Client: cli, Log: logr.Discard()}
55+
}
56+
57+
func crossNamespaceConsumer() *v1alpha1.Consumer {
58+
target := secretNS
59+
return &v1alpha1.Consumer{
60+
ObjectMeta: metav1.ObjectMeta{Name: "attacker", Namespace: consumerNS},
61+
Spec: v1alpha1.ConsumerSpec{
62+
Credentials: []v1alpha1.Credential{{
63+
Name: "cred",
64+
Type: "key-auth",
65+
SecretRef: &v1alpha1.SecretReference{Name: "victim-secret", Namespace: &target},
66+
}},
67+
},
68+
}
69+
}
70+
71+
func victimSecret() *corev1.Secret {
72+
return &corev1.Secret{
73+
ObjectMeta: metav1.ObjectMeta{Name: "victim-secret", Namespace: secretNS},
74+
Data: map[string][]byte{"key": []byte("victim-key")},
75+
}
76+
}
77+
78+
func secretGrant() *v1beta1.ReferenceGrant {
79+
return &v1beta1.ReferenceGrant{
80+
ObjectMeta: metav1.ObjectMeta{Name: "allow-consumer", Namespace: secretNS},
81+
Spec: v1beta1.ReferenceGrantSpec{
82+
From: []v1beta1.ReferenceGrantFrom{{
83+
Group: v1beta1.Group(v1alpha1.GroupVersion.Group),
84+
Kind: "Consumer",
85+
Namespace: consumerNS,
86+
}},
87+
To: []v1beta1.ReferenceGrantTo{{
88+
Group: "",
89+
Kind: "Secret",
90+
}},
91+
},
92+
}
93+
}
94+
95+
// Without a ReferenceGrant the cross-namespace secret must not be bound.
96+
func TestProcessSpec_CrossNamespaceSecretRef_DeniedWithoutGrant(t *testing.T) {
97+
SetEnableReferenceGrant(true)
98+
defer SetEnableReferenceGrant(false)
99+
100+
r := buildConsumerReconciler(t, victimSecret())
101+
consumer := crossNamespaceConsumer()
102+
tctx := provider.NewDefaultTranslateContext(context.Background())
103+
104+
err := r.processSpec(context.Background(), tctx, consumer)
105+
require.Error(t, err)
106+
require.Empty(t, tctx.Secrets, "foreign secret must not be loaded without a ReferenceGrant")
107+
}
108+
109+
// A matching ReferenceGrant permits the cross-namespace secret.
110+
func TestProcessSpec_CrossNamespaceSecretRef_AllowedWithGrant(t *testing.T) {
111+
SetEnableReferenceGrant(true)
112+
defer SetEnableReferenceGrant(false)
113+
114+
r := buildConsumerReconciler(t, victimSecret(), secretGrant())
115+
consumer := crossNamespaceConsumer()
116+
tctx := provider.NewDefaultTranslateContext(context.Background())
117+
118+
err := r.processSpec(context.Background(), tctx, consumer)
119+
require.NoError(t, err)
120+
require.Contains(t, tctx.Secrets, types.NamespacedName{Namespace: secretNS, Name: "victim-secret"})
121+
}
122+
123+
// Same-namespace SecretRef needs no grant.
124+
func TestProcessSpec_SameNamespaceSecretRef_Allowed(t *testing.T) {
125+
SetEnableReferenceGrant(true)
126+
defer SetEnableReferenceGrant(false)
127+
128+
secret := &corev1.Secret{
129+
ObjectMeta: metav1.ObjectMeta{Name: "local-secret", Namespace: consumerNS},
130+
Data: map[string][]byte{"key": []byte("local-key")},
131+
}
132+
r := buildConsumerReconciler(t, secret)
133+
consumer := &v1alpha1.Consumer{
134+
ObjectMeta: metav1.ObjectMeta{Name: "local", Namespace: consumerNS},
135+
Spec: v1alpha1.ConsumerSpec{
136+
Credentials: []v1alpha1.Credential{{
137+
Name: "cred",
138+
Type: "key-auth",
139+
SecretRef: &v1alpha1.SecretReference{Name: "local-secret"},
140+
}},
141+
},
142+
}
143+
tctx := provider.NewDefaultTranslateContext(context.Background())
144+
145+
err := r.processSpec(context.Background(), tctx, consumer)
146+
require.NoError(t, err)
147+
require.Contains(t, tctx.Secrets, types.NamespacedName{Namespace: consumerNS, Name: "local-secret"})
148+
}

internal/webhook/v1/consumer_webhook_test.go

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,19 +27,48 @@ import (
2727
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
2828
"sigs.k8s.io/controller-runtime/pkg/client/fake"
2929
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
30+
"sigs.k8s.io/gateway-api/apis/v1beta1"
3031

3132
apisixv1alpha1 "github.com/apache/apisix-ingress-controller/api/v1alpha1"
33+
"github.com/apache/apisix-ingress-controller/internal/controller"
3234
"github.com/apache/apisix-ingress-controller/internal/controller/config"
3335
"github.com/apache/apisix-ingress-controller/internal/controller/indexer"
3436
)
3537

38+
// authNS is a foreign namespace used for cross-namespace secretRef tests.
39+
const authNS = "auth"
40+
41+
// enableReferenceGrant turns on the cross-namespace ReferenceGrant gate for the
42+
// duration of a test and resets it afterwards.
43+
func enableReferenceGrant(t *testing.T) {
44+
t.Helper()
45+
controller.SetEnableReferenceGrant(true)
46+
t.Cleanup(func() { controller.SetEnableReferenceGrant(false) })
47+
}
48+
49+
// consumerToSecretGrant permits Consumers in fromNS to reference Secrets in grantNS.
50+
func consumerToSecretGrant(grantNS, fromNS string) *v1beta1.ReferenceGrant {
51+
return &v1beta1.ReferenceGrant{
52+
ObjectMeta: metav1.ObjectMeta{Name: "allow-consumer", Namespace: grantNS},
53+
Spec: v1beta1.ReferenceGrantSpec{
54+
From: []v1beta1.ReferenceGrantFrom{{
55+
Group: v1beta1.Group(apisixv1alpha1.GroupVersion.Group),
56+
Kind: "Consumer",
57+
Namespace: v1beta1.Namespace(fromNS),
58+
}},
59+
To: []v1beta1.ReferenceGrantTo{{Group: "", Kind: "Secret"}},
60+
},
61+
}
62+
}
63+
3664
func buildConsumerValidator(t *testing.T, objects ...runtime.Object) *ConsumerCustomValidator {
3765
t.Helper()
3866

3967
scheme := runtime.NewScheme()
4068
require.NoError(t, clientgoscheme.AddToScheme(scheme))
4169
require.NoError(t, apisixv1alpha1.AddToScheme(scheme))
4270
require.NoError(t, gatewayv1.Install(scheme))
71+
require.NoError(t, v1beta1.Install(scheme))
4372

4473
managed := []runtime.Object{
4574
&gatewayv1.GatewayClass{
@@ -90,7 +119,7 @@ func TestConsumerValidator_MissingSecretDefaultNamespace(t *testing.T) {
90119
}
91120

92121
func TestConsumerValidator_MissingSecretCustomNamespace(t *testing.T) {
93-
ns := "auth"
122+
ns := authNS
94123
consumer := &apisixv1alpha1.Consumer{
95124
ObjectMeta: metav1.ObjectMeta{
96125
Name: "demo",
@@ -108,16 +137,46 @@ func TestConsumerValidator_MissingSecretCustomNamespace(t *testing.T) {
108137
},
109138
}
110139

111-
validator := buildConsumerValidator(t)
140+
enableReferenceGrant(t)
141+
validator := buildConsumerValidator(t, consumerToSecretGrant(authNS, "default"))
112142

113143
warnings, err := validator.ValidateCreate(context.Background(), consumer)
114144
require.NoError(t, err)
115145
require.Len(t, warnings, 1)
116146
require.Contains(t, warnings[0], "Referenced Secret 'auth/jwt-secret' not found")
117147
}
118148

149+
// A cross-namespace secretRef without a permitting ReferenceGrant is rejected at admission.
150+
func TestConsumerValidator_CrossNamespaceSecretRefDeniedWithoutGrant(t *testing.T) {
151+
enableReferenceGrant(t)
152+
ns := authNS
153+
consumer := &apisixv1alpha1.Consumer{
154+
ObjectMeta: metav1.ObjectMeta{
155+
Name: "demo",
156+
Namespace: "default",
157+
},
158+
Spec: apisixv1alpha1.ConsumerSpec{
159+
GatewayRef: apisixv1alpha1.GatewayRef{Name: "test-gateway"},
160+
Credentials: []apisixv1alpha1.Credential{{
161+
Type: "jwt-auth",
162+
SecretRef: &apisixv1alpha1.SecretReference{
163+
Name: "jwt-secret",
164+
Namespace: &ns,
165+
},
166+
}},
167+
},
168+
}
169+
170+
secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "jwt-secret", Namespace: authNS}}
171+
validator := buildConsumerValidator(t, secret)
172+
173+
_, err := validator.ValidateCreate(context.Background(), consumer)
174+
require.Error(t, err)
175+
require.Contains(t, err.Error(), "not permitted by any ReferenceGrant")
176+
}
177+
119178
func TestConsumerValidator_NoWarnings(t *testing.T) {
120-
ns := "auth"
179+
ns := authNS
121180
consumer := &apisixv1alpha1.Consumer{
122181
ObjectMeta: metav1.ObjectMeta{
123182
Name: "demo",
@@ -140,9 +199,11 @@ func TestConsumerValidator_NoWarnings(t *testing.T) {
140199
},
141200
}
142201

202+
enableReferenceGrant(t)
143203
objs := []runtime.Object{
144-
&corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "jwt-secret", Namespace: "auth"}},
204+
&corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "jwt-secret", Namespace: authNS}},
145205
&corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "key-secret", Namespace: "default"}},
206+
consumerToSecretGrant(authNS, "default"),
146207
}
147208

148209
validator := buildConsumerValidator(t, objs...)

0 commit comments

Comments
 (0)