Skip to content

Commit c4778ee

Browse files
authored
Prevent deletion of namespaces and cluster (#651) (#722)
* Ignore namespace delete operation * Prevent deletion of `local` and `fleet-local` namespaces * Prevent deletion of local cluster It prevents deletion of both clusters.provisioning.cattle.io and cluster.management.cattle.io resources of the named local. * Fixes to tests based on CI feedback --------- Signed-off-by: Dharmit Shah <[email protected]>
1 parent 0a9a9df commit c4778ee

File tree

9 files changed

+149
-9
lines changed

9 files changed

+149
-9
lines changed
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package namespace
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/rancher/webhook/pkg/admission"
7+
objectsv1 "github.com/rancher/webhook/pkg/generated/objects/core/v1"
8+
admissionv1 "k8s.io/api/admission/v1"
9+
"k8s.io/utils/trace"
10+
)
11+
12+
// deleteNamespaceAdmitter handles namespace deletion scenarios
13+
type deleteNamespaceAdmitter struct{}
14+
15+
func (d deleteNamespaceAdmitter) Admit(request *admission.Request) (*admissionv1.AdmissionResponse, error) {
16+
listTrace := trace.New("Namespace Admit", trace.Field{Key: "user", Value: request.UserInfo.Username})
17+
defer listTrace.LogIfLong(admission.SlowTraceDuration)
18+
19+
if request.Operation != admissionv1.Delete {
20+
return admission.ResponseAllowed(), nil
21+
}
22+
23+
oldNs, _, err := objectsv1.NamespaceOldAndNewFromRequest(&request.AdmissionRequest)
24+
if err != nil {
25+
return nil, fmt.Errorf("failed to decode namespace from request: %w", err)
26+
}
27+
28+
return admission.ResponseBadRequest(fmt.Sprintf("%q namespace my not be deleted\n", oldNs.Name)), nil
29+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package namespace
2+
3+
import (
4+
"testing"
5+
6+
"github.com/rancher/webhook/pkg/admission"
7+
"github.com/stretchr/testify/assert"
8+
admissionv1 "k8s.io/api/admission/v1"
9+
)
10+
11+
func Test_Admit(t *testing.T) {
12+
tests := []struct {
13+
name string
14+
namespaceName string
15+
operationType admissionv1.Operation
16+
wantAllowed bool
17+
wantErr bool
18+
}{
19+
{
20+
name: "Allow creating namespace",
21+
namespaceName: "local",
22+
operationType: admissionv1.Create,
23+
wantAllowed: true,
24+
wantErr: false,
25+
},
26+
{
27+
name: "Prevent deletion of 'local' namespace",
28+
namespaceName: "local",
29+
operationType: admissionv1.Delete,
30+
wantAllowed: false,
31+
wantErr: true,
32+
},
33+
{
34+
name: "Prevent deletion of 'fleet-local' namespace",
35+
namespaceName: "fleet-local",
36+
operationType: admissionv1.Delete,
37+
wantAllowed: false,
38+
wantErr: true,
39+
},
40+
}
41+
for _, test := range tests {
42+
test := test
43+
t.Run(test.name, func(t *testing.T) {
44+
d := deleteNamespaceAdmitter{}
45+
request := createRequest(test.name, test.namespaceName, test.operationType)
46+
response, err := d.Admit(request)
47+
if test.wantErr {
48+
assert.Error(t, err)
49+
} else {
50+
assert.NoError(t, err)
51+
assert.Equal(t, test.wantAllowed, response.Allowed)
52+
}
53+
})
54+
}
55+
}
56+
57+
func createRequest(name, namespaceName string, operation admissionv1.Operation) *admission.Request {
58+
return &admission.Request{
59+
AdmissionRequest: admissionv1.AdmissionRequest{
60+
Name: name,
61+
Namespace: namespaceName,
62+
Operation: operation,
63+
},
64+
}
65+
}

pkg/resources/core/v1/namespace/projectannotations.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ type projectNamespaceAdmitter struct {
2626
// Admit ensures that the user has permission to change the namespace annotation for
2727
// project membership, effectively moving a project from one namespace to another.
2828
func (p *projectNamespaceAdmitter) Admit(request *admission.Request) (*admissionv1.AdmissionResponse, error) {
29+
if request.Operation == admissionv1.Delete {
30+
return admission.ResponseAllowed(), nil
31+
}
32+
2933
listTrace := trace.New("Namespace Admit", trace.Field{Key: "user", Value: request.UserInfo.Username})
3034
defer listTrace.LogIfLong(admission.SlowTraceDuration)
3135

pkg/resources/core/v1/namespace/psalabels.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ type psaLabelAdmitter struct {
2222

2323
// Admit ensures that users have sufficient permissions to add/remove PSAs to a namespace.
2424
func (p *psaLabelAdmitter) Admit(request *admission.Request) (*admissionv1.AdmissionResponse, error) {
25+
if request.Operation == admissionv1.Delete {
26+
return admission.ResponseAllowed(), nil
27+
}
28+
2529
listTrace := trace.New("Namespace Admit", trace.Field{Key: "user", Value: request.UserInfo.Username})
2630
defer listTrace.LogIfLong(admission.SlowTraceDuration)
2731

pkg/resources/core/v1/namespace/validator.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,15 @@ var projectsGVR = schema.GroupVersionResource{
1818

1919
// Validator validates the namespace admission request.
2020
type Validator struct {
21+
deleteNamespaceAdmitter deleteNamespaceAdmitter
2122
psaAdmitter psaLabelAdmitter
2223
projectNamespaceAdmitter projectNamespaceAdmitter
2324
}
2425

2526
// NewValidator returns a new validator used for validation of namespace requests.
2627
func NewValidator(sar authorizationv1.SubjectAccessReviewInterface) *Validator {
2728
return &Validator{
29+
deleteNamespaceAdmitter: deleteNamespaceAdmitter{},
2830
psaAdmitter: psaLabelAdmitter{
2931
sar: sar,
3032
},
@@ -47,6 +49,7 @@ func (v *Validator) Operations() []admissionv1.OperationType {
4749
return []admissionv1.OperationType{
4850
admissionv1.Update,
4951
admissionv1.Create,
52+
admissionv1.Delete,
5053
}
5154
}
5255

@@ -85,10 +88,22 @@ func (v *Validator) ValidatingWebhook(clientConfig admissionv1.WebhookClientConf
8588
}
8689
kubeSystemCreateWebhook.FailurePolicy = admission.Ptr(admissionv1.Ignore)
8790

88-
return []admissionv1.ValidatingWebhook{*standardWebhook, *createWebhook, *kubeSystemCreateWebhook}
91+
deleteNamespaceWebhook := admission.NewDefaultValidatingWebhook(v, clientConfig, admissionv1.ClusterScope, []admissionv1.OperationType{admissionv1.Delete})
92+
deleteNamespaceWebhook.Name = admission.CreateWebhookName(v, "delete-namespace")
93+
deleteNamespaceWebhook.NamespaceSelector = &metav1.LabelSelector{
94+
MatchExpressions: []metav1.LabelSelectorRequirement{
95+
{
96+
Key: corev1.LabelMetadataName,
97+
Operator: metav1.LabelSelectorOpIn,
98+
Values: []string{"fleet-local", "local"},
99+
},
100+
},
101+
}
102+
103+
return []admissionv1.ValidatingWebhook{*deleteNamespaceWebhook, *standardWebhook, *createWebhook, *kubeSystemCreateWebhook}
89104
}
90105

91106
// Admitters returns the psaAdmitter and the projectNamespaceAdmitter for namespaces.
92107
func (v *Validator) Admitters() []admission.Admitter {
93-
return []admission.Admitter{&v.psaAdmitter, &v.projectNamespaceAdmitter}
108+
return []admission.Admitter{&v.psaAdmitter, &v.projectNamespaceAdmitter, &v.deleteNamespaceAdmitter}
94109
}

pkg/resources/core/v1/namespace/validator_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,15 @@ func TestGVR(t *testing.T) {
2020
func TestOperations(t *testing.T) {
2121
validator := NewValidator(nil)
2222
operations := validator.Operations()
23-
assert.Len(t, operations, 2)
23+
assert.Len(t, operations, 3)
2424
assert.Contains(t, operations, v1.Update)
2525
assert.Contains(t, operations, v1.Create)
2626
}
2727

2828
func TestAdmitters(t *testing.T) {
2929
validator := NewValidator(nil)
3030
admitters := validator.Admitters()
31-
assert.Len(t, admitters, 2)
31+
assert.Len(t, admitters, 3)
3232
hasPSAAdmitter := false
3333
hasProjectNamespaceAdmitter := false
3434
for i := range admitters {
@@ -56,7 +56,7 @@ func TestValidatingWebhook(t *testing.T) {
5656
wantURL := "test.cattle.io/namespaces"
5757
validator := NewValidator(nil)
5858
webhooks := validator.ValidatingWebhook(clientConfig)
59-
assert.Len(t, webhooks, 3)
59+
assert.Len(t, webhooks, 4)
6060
hasAllUpdateWebhook := false
6161
hasCreateNonKubeSystemWebhook := false
6262
hasCreateKubeSystemWebhook := false
@@ -71,7 +71,7 @@ func TestValidatingWebhook(t *testing.T) {
7171
operation := operations[0]
7272
assert.Equal(t, v1.ClusterScope, *rule.Scope)
7373

74-
assert.Contains(t, []v1.OperationType{v1.Create, v1.Update}, operation, "only expected webhooks for create and update")
74+
assert.Contains(t, []v1.OperationType{v1.Create, v1.Update, v1.Delete}, operation, "only expected webhooks for create, update and delete")
7575
if operation == v1.Update {
7676
assert.False(t, hasAllUpdateWebhook, "had more than one webhook validating update calls, exepcted only one")
7777
hasAllUpdateWebhook = true
@@ -81,7 +81,7 @@ func TestValidatingWebhook(t *testing.T) {
8181
// failure policy defaults to fail, but if we specify one it needs to be fail
8282
assert.Equal(t, v1.Fail, *webhook.FailurePolicy)
8383
}
84-
} else {
84+
} else if operation == v1.Create {
8585
assert.NotNil(t, webhook.NamespaceSelector)
8686
matchExpressions := webhook.NamespaceSelector.MatchExpressions
8787
assert.Len(t, matchExpressions, 1)

pkg/resources/management.cattle.io/v3/cluster/validator.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import (
2424
authorizationv1 "k8s.io/client-go/kubernetes/typed/authorization/v1"
2525
)
2626

27+
const localCluster = "local"
28+
2729
var parsedRangeLessThan123 = semver.MustParseRange("< 1.23.0-rancher0")
2830

2931
// NewValidator returns a new validator for management clusters.
@@ -81,6 +83,11 @@ func (a *admitter) Admit(request *admission.Request) (*admissionv1.AdmissionResp
8183
return nil, fmt.Errorf("failed get old and new clusters from request: %w", err)
8284
}
8385

86+
if request.Operation == admissionv1.Delete && oldCluster.Name == localCluster {
87+
// deleting "local" cluster could corrupt the cluster Rancher is deployed in
88+
return admission.ResponseBadRequest("local cluster may not be deleted"), nil
89+
}
90+
8491
response, err := a.validateFleetPermissions(request, oldCluster, newCluster)
8592
if err != nil {
8693
return nil, fmt.Errorf("failed to validate fleet permissions: %w", err)

pkg/resources/management.cattle.io/v3/cluster/validator_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,16 @@ func TestAdmit(t *testing.T) {
309309
expectAllowed: true,
310310
expectedReason: metav1.StatusReasonBadRequest,
311311
},
312+
{
313+
name: "Delete local cluster where Rancher is deployed",
314+
operation: admissionv1.Delete,
315+
oldCluster: v3.Cluster{
316+
ObjectMeta: metav1.ObjectMeta{
317+
Name: "local",
318+
},
319+
},
320+
expectAllowed: false,
321+
},
312322
}
313323

314324
for _, tt := range tests {

pkg/resources/provisioning.cattle.io/v1/cluster/validator.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const (
3737
globalNamespace = "cattle-global-data"
3838
systemAgentVarDirEnvVar = "CATTLE_AGENT_VAR_DIR"
3939
failureStatus = "Failure"
40+
localCluster = "local"
4041
)
4142

4243
var (
@@ -97,6 +98,11 @@ func (p *provisioningAdmitter) Admit(request *admission.Request) (*admissionv1.A
9798
return nil, err
9899
}
99100

101+
if request.Operation == admissionv1.Delete && oldCluster.Name == localCluster {
102+
// deleting "local" cluster could corrupt the cluster Rancher is deployed in
103+
return admission.ResponseBadRequest("local cluster may not be deleted"), nil
104+
}
105+
100106
response := &admissionv1.AdmissionResponse{}
101107
if request.Operation == admissionv1.Create || request.Operation == admissionv1.Update {
102108
if err := p.validateClusterName(request, response, cluster); err != nil || response.Result != nil {
@@ -416,7 +422,7 @@ func (p *provisioningAdmitter) validateMachinePoolNames(request *admission.Reque
416422

417423
// validatePSACT validate if the cluster and underlying secret are configured properly when PSACT is enabled or disabled
418424
func (p *provisioningAdmitter) validatePSACT(request *admission.Request, response *admissionv1.AdmissionResponse, cluster *v1.Cluster) error {
419-
if cluster.Name == "local" || cluster.Spec.RKEConfig == nil {
425+
if cluster.Name == localCluster || cluster.Spec.RKEConfig == nil {
420426
return nil
421427
}
422428

@@ -664,7 +670,7 @@ func validateACEConfig(cluster *v1.Cluster) *metav1.Status {
664670

665671
func isValidName(clusterName, clusterNamespace string, clusterExists bool) bool {
666672
// A provisioning cluster with name "local" is only expected to be created in the "fleet-local" namespace.
667-
if clusterName == "local" {
673+
if clusterName == localCluster {
668674
return clusterNamespace == "fleet-local"
669675
}
670676

0 commit comments

Comments
 (0)