Skip to content

Commit 216d062

Browse files
authored
Prevent deletion of local cluster (#551) (#592)
* Prevent deletion of `local` cluster It prevents deletion of both clusters.provisioning.cattle.io and cluster.management.cattle.io of the name `local`. * Prevent deletion of `local` and `fleet-local` namespaces * Parameter type grouping --------- Signed-off-by: Dharmit Shah <[email protected]>
1 parent 5c76113 commit 216d062

File tree

7 files changed

+75
-14
lines changed

7 files changed

+75
-14
lines changed

Diff for: pkg/resources/core/v1/namespace/projectannotations.go

+11-2
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import (
1515
)
1616

1717
const (
18+
fleetLocalNs = "fleet-local"
19+
localNs = "local"
1820
manageNSVerb = "manage-namespaces"
1921
projectNSAnnotation = "field.cattle.io/projectId"
2022
)
@@ -23,8 +25,10 @@ type projectNamespaceAdmitter struct {
2325
sar authorizationv1.SubjectAccessReviewInterface
2426
}
2527

26-
// Admit ensures that the user has permission to change the namespace annotation for
27-
// project membership, effectively moving a project from one namespace to another.
28+
// Admit ensures that the:
29+
// - user has permission to change the namespace annotation for project membership, effectively moving a project from
30+
// one namespace to another.
31+
// - deletion of `local` and `fleet-local` namespace is not allowed
2832
func (p *projectNamespaceAdmitter) Admit(request *admission.Request) (*admissionv1.AdmissionResponse, error) {
2933
listTrace := trace.New("Namespace Admit", trace.Field{Key: "user", Value: request.UserInfo.Username})
3034
defer listTrace.LogIfLong(admission.SlowTraceDuration)
@@ -36,6 +40,11 @@ func (p *projectNamespaceAdmitter) Admit(request *admission.Request) (*admission
3640
return nil, fmt.Errorf("failed to decode namespace from request: %w", err)
3741
}
3842

43+
if request.Operation == admissionv1.Delete {
44+
if oldNs.Name == localNs || oldNs.Name == fleetLocalNs {
45+
return admission.ResponseBadRequest(fmt.Sprintf("deletion of namespace %q is not allowed\n", request.Name)), nil
46+
}
47+
}
3948
projectAnnoValue, ok := newNs.Annotations[projectNSAnnotation]
4049
if !ok {
4150
// this namespace doesn't belong to a project, let standard RBAC handle it

Diff for: pkg/resources/core/v1/namespace/projectannotations_test.go

+29-4
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ func TestValidateProjectNamespaceAnnotations(t *testing.T) {
3030
sarError bool
3131
wantError bool
3232
wantAllowed bool
33+
namespaceName string
3334
}{
3435
{
3536
name: "user can access, create",
@@ -189,6 +190,26 @@ func TestValidateProjectNamespaceAnnotations(t *testing.T) {
189190
wantError: true,
190191
wantAllowed: false,
191192
},
193+
{
194+
name: "Prevent deletion of 'local' namespace",
195+
operationType: v1.Delete,
196+
wantError: false,
197+
wantAllowed: false,
198+
namespaceName: "local",
199+
},
200+
{
201+
name: "Prevent deletion of 'fleet-local' namespace",
202+
operationType: v1.Delete,
203+
wantError: false,
204+
wantAllowed: false,
205+
namespaceName: "fleet-local",
206+
},
207+
{
208+
name: "Allow deletion of namespace",
209+
operationType: v1.Delete,
210+
wantAllowed: true,
211+
wantError: false,
212+
},
192213
}
193214
for _, test := range tests {
194215
test := test
@@ -212,7 +233,7 @@ func TestValidateProjectNamespaceAnnotations(t *testing.T) {
212233
// if this wasn't for our project, don't handle the response
213234
return false, nil, nil
214235
})
215-
request, err := createAnnotationNamespaceRequest(test.projectAnnotationValue, test.oldProjectAnnotationValue, test.includeProjectAnnotation, test.operationType)
236+
request, err := createAnnotationNamespaceRequest(test.projectAnnotationValue, test.oldProjectAnnotationValue, test.includeProjectAnnotation, test.operationType, test.namespaceName)
216237
assert.NoError(t, err)
217238
response, err := admitter.Admit(request)
218239
if test.wantError {
@@ -231,12 +252,16 @@ func sarIsForProjectGVR(sarSpec authorizationv1.SubjectAccessReviewSpec) bool {
231252
sarSpec.ResourceAttributes.Resource == projectsGVR.Resource
232253
}
233254

234-
func createAnnotationNamespaceRequest(newProjectAnnotation, oldProjectAnnotation string, includeProjectAnnotation bool, operation v1.Operation) (*admission.Request, error) {
255+
func createAnnotationNamespaceRequest(newProjectAnnotation, oldProjectAnnotation string, includeProjectAnnotation bool, operation v1.Operation, namespaceName string) (*admission.Request, error) {
235256
gvk := metav1.GroupVersionKind{Version: "v1", Kind: "Namespace"}
236257
gvr := metav1.GroupVersionResource{Version: "v1", Resource: "namespace"}
258+
nsName := "test-ns"
259+
if namespaceName != "" {
260+
nsName = namespaceName
261+
}
237262
ns := corev1.Namespace{
238263
ObjectMeta: metav1.ObjectMeta{
239-
Name: "test-ns",
264+
Name: nsName,
240265
},
241266
}
242267
if includeProjectAnnotation {
@@ -266,7 +291,7 @@ func createAnnotationNamespaceRequest(newProjectAnnotation, oldProjectAnnotation
266291
if err != nil {
267292
return nil, err
268293
}
269-
if operation == v1.Update {
294+
if operation != v1.Create {
270295
if includeProjectAnnotation {
271296
ns.Annotations[projectNSAnnotation] = oldProjectAnnotation
272297
}

Diff for: pkg/resources/core/v1/namespace/validator.go

+5-1
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ func (v *Validator) Operations() []admissionv1.OperationType {
4747
return []admissionv1.OperationType{
4848
admissionv1.Update,
4949
admissionv1.Create,
50+
admissionv1.Delete,
5051
}
5152
}
5253

@@ -85,7 +86,10 @@ func (v *Validator) ValidatingWebhook(clientConfig admissionv1.WebhookClientConf
8586
}
8687
kubeSystemCreateWebhook.FailurePolicy = admission.Ptr(admissionv1.Ignore)
8788

88-
return []admissionv1.ValidatingWebhook{*standardWebhook, *createWebhook, *kubeSystemCreateWebhook}
89+
deleteWebhook := admission.NewDefaultValidatingWebhook(v, clientConfig, admissionv1.ClusterScope, []admissionv1.OperationType{admissionv1.Delete})
90+
deleteWebhook.Name = admission.CreateWebhookName(v, "delete-only")
91+
92+
return []admissionv1.ValidatingWebhook{*standardWebhook, *createWebhook, *kubeSystemCreateWebhook, *deleteWebhook}
8993
}
9094

9195
// Admitters returns the psaAdmitter and the projectNamespaceAdmitter for namespaces.

Diff for: pkg/resources/core/v1/namespace/validator_test.go

+4-4
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ 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
}
@@ -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)

Diff for: pkg/resources/management.cattle.io/v3/cluster/validator.go

+8-1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import (
2626

2727
var parsedRangeLessThan123 = semver.MustParseRange("< 1.23.0-rancher0")
2828

29+
const localCluster = "local"
30+
2931
// NewValidator returns a new validator for management clusters.
3032
func NewValidator(
3133
sar authorizationv1.SubjectAccessReviewInterface,
@@ -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("cannot delete the local cluster"), 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)
@@ -112,7 +119,7 @@ func (a *admitter) Admit(request *admission.Request) (*admissionv1.AdmissionResp
112119
if request.Operation == admissionv1.Create || request.Operation == admissionv1.Update {
113120
// no need to validate the PodSecurityAdmissionConfigurationTemplate on a local cluster,
114121
// or imported cluster which represents a KEv2 cluster (GKE/EKS/AKS) or v1 Provisioning Cluster
115-
if newCluster.Name == "local" || newCluster.Spec.RancherKubernetesEngineConfig == nil {
122+
if newCluster.Name == localCluster || newCluster.Spec.RancherKubernetesEngineConfig == nil {
116123
return admission.ResponseAllowed(), nil
117124
}
118125

Diff for: pkg/resources/management.cattle.io/v3/cluster/validator_test.go

+10
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 {

Diff for: pkg/resources/provisioning.cattle.io/v1/cluster/validator.go

+8-2
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import (
3535

3636
const (
3737
globalNamespace = "cattle-global-data"
38+
localCluster = "local"
3839
systemAgentVarDirEnvVar = "CATTLE_AGENT_VAR_DIR"
3940
failureStatus = "Failure"
4041
)
@@ -92,6 +93,11 @@ func (p *provisioningAdmitter) Admit(request *admission.Request) (*admissionv1.A
9293
listTrace := trace.New("provisioningClusterValidator Admit", trace.Field{Key: "user", Value: request.UserInfo.Username})
9394
defer listTrace.LogIfLong(admission.SlowTraceDuration)
9495

96+
if request.Operation == admissionv1.Delete && request.Name == localCluster {
97+
// deleting "local" cluster could corrupt the cluster Rancher is deployed in
98+
return admission.ResponseBadRequest("can't delete local cluster"), nil
99+
}
100+
95101
oldCluster, cluster, err := objectsv1.ClusterOldAndNewFromRequest(&request.AdmissionRequest)
96102
if err != nil {
97103
return nil, err
@@ -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)