diff --git a/controllers/backupcronjob/backupcronjob_controller.go b/controllers/backupcronjob/backupcronjob_controller.go index 6a4b5722a..7a64ff346 100644 --- a/controllers/backupcronjob/backupcronjob_controller.go +++ b/controllers/backupcronjob/backupcronjob_controller.go @@ -1,5 +1,5 @@ // -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 Red Hat, Inc. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/controllers/backupcronjob/backupcronjob_controller_test.go b/controllers/backupcronjob/backupcronjob_controller_test.go index efaa20ff5..2807aca0f 100644 --- a/controllers/backupcronjob/backupcronjob_controller_test.go +++ b/controllers/backupcronjob/backupcronjob_controller_test.go @@ -1,5 +1,5 @@ // -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 Red Hat, Inc. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/controllers/controller/devworkspacerouting/devworkspacerouting_controller.go b/controllers/controller/devworkspacerouting/devworkspacerouting_controller.go index c9231c366..c95a4842a 100644 --- a/controllers/controller/devworkspacerouting/devworkspacerouting_controller.go +++ b/controllers/controller/devworkspacerouting/devworkspacerouting_controller.go @@ -1,5 +1,5 @@ // -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 Red Hat, Inc. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/controllers/workspace/devworkspace_controller.go b/controllers/workspace/devworkspace_controller.go index 4c92cd297..37cc6e20d 100644 --- a/controllers/workspace/devworkspace_controller.go +++ b/controllers/workspace/devworkspace_controller.go @@ -22,10 +22,6 @@ import ( "strings" "time" - "github.com/devfile/devworkspace-operator/pkg/library/initcontainers" - "github.com/devfile/devworkspace-operator/pkg/library/overrides" - "github.com/devfile/devworkspace-operator/pkg/library/ssh" - dw "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" devfilevalidation "github.com/devfile/api/v2/pkg/validation" controllerv1alpha1 "github.com/devfile/devworkspace-operator/apis/controller/v1alpha1" @@ -35,15 +31,19 @@ import ( wkspConfig "github.com/devfile/devworkspace-operator/pkg/config" "github.com/devfile/devworkspace-operator/pkg/constants" "github.com/devfile/devworkspace-operator/pkg/dwerrors" + "github.com/devfile/devworkspace-operator/pkg/httpfactory" "github.com/devfile/devworkspace-operator/pkg/library/annotate" containerlib "github.com/devfile/devworkspace-operator/pkg/library/container" wsDefaults "github.com/devfile/devworkspace-operator/pkg/library/defaults" "github.com/devfile/devworkspace-operator/pkg/library/env" "github.com/devfile/devworkspace-operator/pkg/library/flatten" "github.com/devfile/devworkspace-operator/pkg/library/home" + "github.com/devfile/devworkspace-operator/pkg/library/initcontainers" kubesync "github.com/devfile/devworkspace-operator/pkg/library/kubernetes" + "github.com/devfile/devworkspace-operator/pkg/library/overrides/restrictions" "github.com/devfile/devworkspace-operator/pkg/library/projects" "github.com/devfile/devworkspace-operator/pkg/library/restore" + "github.com/devfile/devworkspace-operator/pkg/library/ssh" "github.com/devfile/devworkspace-operator/pkg/library/status" "github.com/devfile/devworkspace-operator/pkg/provision/automount" "github.com/devfile/devworkspace-operator/pkg/provision/metadata" @@ -258,7 +258,7 @@ func (r *DevWorkspaceReconciler) Reconcile(ctx context.Context, req ctrl.Request return reconcile.Result{Requeue: true}, err } - httpClient := httpClientsFactory.GetHttpClient(ctx, config.Routing) + httpClient := httpfactory.HttpFactory.GetHttpClient(ctx, config.Routing) flattenHelpers := flatten.ResolverTools{ WorkspaceNamespace: workspace.Namespace, @@ -339,7 +339,7 @@ func (r *DevWorkspaceReconciler) Reconcile(ctx context.Context, req ctrl.Request workspace.Config.Workspace.DefaultContainerResources, workspace.Config.Workspace.ContainerResourceCaps, workspace.Config.Workspace.PostStartTimeout, - overrides.GetRestrictedContainerOverrideFields(workspace), + restrictions.GetRestrictedContainerFields(workspace), postStartDebugTrapSleepDuration, ) if err != nil { @@ -789,11 +789,6 @@ func (r *DevWorkspaceReconciler) getWorkspaceId(ctx context.Context, workspace * } func (r *DevWorkspaceReconciler) SetupWithManager(mgr ctrl.Manager) error { - err := SetupHttpClientsFactory(mgr.GetClient(), mgr.GetLogger()) - if err != nil { - return err - } - maxConcurrentReconciles, err := wkspConfig.GetMaxConcurrentReconciles() if err != nil { return err diff --git a/controllers/workspace/devworkspace_controller_test.go b/controllers/workspace/devworkspace_controller_test.go index 26bee1609..e197d9066 100644 --- a/controllers/workspace/devworkspace_controller_test.go +++ b/controllers/workspace/devworkspace_controller_test.go @@ -1,4 +1,4 @@ -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 Red Hat, Inc. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -22,12 +22,12 @@ import ( dw "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" controllerv1alpha1 "github.com/devfile/devworkspace-operator/apis/controller/v1alpha1" - workspacecontroller "github.com/devfile/devworkspace-operator/controllers/workspace" "github.com/devfile/devworkspace-operator/controllers/workspace/internal/testutil" "github.com/devfile/devworkspace-operator/pkg/common" "github.com/devfile/devworkspace-operator/pkg/conditions" "github.com/devfile/devworkspace-operator/pkg/config" "github.com/devfile/devworkspace-operator/pkg/constants" + "github.com/devfile/devworkspace-operator/pkg/httpfactory" "github.com/devfile/devworkspace-operator/pkg/library/projects" "github.com/devfile/devworkspace-operator/pkg/library/restore" . "github.com/onsi/ginkgo/v2" @@ -184,7 +184,7 @@ var _ = Describe("DevWorkspace Controller", func() { AfterEach(func() { deleteDevWorkspace(devWorkspaceName) - workspacecontroller.SetupHttpClientsForTesting(getBasicTestHttpClient()) + httpfactory.SetupHttpClientsForTesting(getBasicTestHttpClient()) }) It("Creates roles and rolebindings", func() { @@ -317,7 +317,7 @@ var _ = Describe("DevWorkspace Controller", func() { devworkspace := getExistingDevWorkspace(devWorkspaceName) workspaceID := devworkspace.Status.DevWorkspaceId - workspacecontroller.SetupHttpClientsForTesting(&http.Client{ + httpfactory.SetupHttpClientsForTesting(&http.Client{ Transport: &testutil.TestRoundTripper{ Data: map[string]testutil.TestResponse{ "test-url/healthz": { @@ -355,7 +355,7 @@ var _ = Describe("DevWorkspace Controller", func() { const testURL = "test-url" BeforeEach(func() { - workspacecontroller.SetupHttpClientsForTesting(&http.Client{ + httpfactory.SetupHttpClientsForTesting(&http.Client{ Transport: &testutil.TestRoundTripper{ Data: map[string]testutil.TestResponse{ fmt.Sprintf("%s/healthz", testURL): { @@ -368,7 +368,7 @@ var _ = Describe("DevWorkspace Controller", func() { AfterEach(func() { deleteDevWorkspace(devWorkspaceName) - workspacecontroller.SetupHttpClientsForTesting(getBasicTestHttpClient()) + httpfactory.SetupHttpClientsForTesting(getBasicTestHttpClient()) }) It("Mounts image pull secrets to the DevWorkspace Deployment", func() { @@ -954,7 +954,7 @@ var _ = Describe("DevWorkspace Controller", func() { const testURL = "test-url" BeforeEach(func() { - workspacecontroller.SetupHttpClientsForTesting(&http.Client{ + httpfactory.SetupHttpClientsForTesting(&http.Client{ Transport: &testutil.TestRoundTripper{ Data: map[string]testutil.TestResponse{ fmt.Sprintf("%s/healthz", testURL): { @@ -967,7 +967,7 @@ var _ = Describe("DevWorkspace Controller", func() { AfterEach(func() { deleteDevWorkspace(devWorkspaceName) - workspacecontroller.SetupHttpClientsForTesting(getBasicTestHttpClient()) + httpfactory.SetupHttpClientsForTesting(getBasicTestHttpClient()) }) It("Sets the runtimeClassName from the DWOC", func() { @@ -1033,7 +1033,7 @@ var _ = Describe("DevWorkspace Controller", func() { const testURL = "test-url" BeforeEach(func() { - workspacecontroller.SetupHttpClientsForTesting(&http.Client{ + httpfactory.SetupHttpClientsForTesting(&http.Client{ Transport: &testutil.TestRoundTripper{ Data: map[string]testutil.TestResponse{ fmt.Sprintf("%s/healthz", testURL): { @@ -1047,7 +1047,7 @@ var _ = Describe("DevWorkspace Controller", func() { AfterEach(func() { deleteDevWorkspace(devWorkspaceName) - workspacecontroller.SetupHttpClientsForTesting(getBasicTestHttpClient()) + httpfactory.SetupHttpClientsForTesting(getBasicTestHttpClient()) }) It("Stops workspaces and scales deployment to zero", func() { @@ -1224,7 +1224,7 @@ var _ = Describe("DevWorkspace Controller", func() { BeforeEach(func() { By("Setting up HTTP client") - workspacecontroller.SetupHttpClientsForTesting(&http.Client{ + httpfactory.SetupHttpClientsForTesting(&http.Client{ Transport: &testutil.TestRoundTripper{ Data: map[string]testutil.TestResponse{ fmt.Sprintf("%s/healthz", testURL): { @@ -1241,7 +1241,7 @@ var _ = Describe("DevWorkspace Controller", func() { deleteDevWorkspace(altDevWorkspaceName) cleanupPVC("claim-devworkspace") By("Resetting HTTP client") - workspacecontroller.SetupHttpClientsForTesting(getBasicTestHttpClient()) + httpfactory.SetupHttpClientsForTesting(getBasicTestHttpClient()) }) It("Cleans up workspace PVC storage when other workspaces exist", func() { @@ -1361,7 +1361,7 @@ var _ = Describe("DevWorkspace Controller", func() { const testURL = "test-url" BeforeEach(func() { - workspacecontroller.SetupHttpClientsForTesting(&http.Client{ + httpfactory.SetupHttpClientsForTesting(&http.Client{ Transport: &testutil.TestRoundTripper{ Data: map[string]testutil.TestResponse{ fmt.Sprintf("%s/healthz", testURL): { @@ -1374,7 +1374,7 @@ var _ = Describe("DevWorkspace Controller", func() { AfterEach(func() { deleteDevWorkspace(devWorkspaceName) - workspacecontroller.SetupHttpClientsForTesting(getBasicTestHttpClient()) + httpfactory.SetupHttpClientsForTesting(getBasicTestHttpClient()) }) It("Restores workspace from backup with common PVC", func() { @@ -1563,7 +1563,7 @@ var _ = Describe("DevWorkspace Controller", func() { devworkspace := getExistingDevWorkspace(devWorkspaceName) workspaceID := devworkspace.Status.DevWorkspaceId - workspacecontroller.SetupHttpClientsForTesting(&http.Client{ + httpfactory.SetupHttpClientsForTesting(&http.Client{ Transport: &testutil.TestRoundTripper{ Data: map[string]testutil.TestResponse{ "test-url/healthz": { @@ -1595,7 +1595,7 @@ var _ = Describe("DevWorkspace Controller", func() { Expect(runningCondition.Status).Should(Equal(corev1.ConditionTrue)) // Clean up - workspacecontroller.SetupHttpClientsForTesting(getBasicTestHttpClient()) + httpfactory.SetupHttpClientsForTesting(getBasicTestHttpClient()) }) It("Ensures preStart initContainers are run after project-clone", func() { diff --git a/controllers/workspace/status.go b/controllers/workspace/status.go index c7b07a3fe..03e049461 100644 --- a/controllers/workspace/status.go +++ b/controllers/workspace/status.go @@ -27,6 +27,7 @@ import ( dw "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" "github.com/devfile/devworkspace-operator/pkg/common" "github.com/devfile/devworkspace-operator/pkg/dwerrors" + "github.com/devfile/devworkspace-operator/pkg/httpfactory" "github.com/devfile/devworkspace-operator/pkg/provision/sync" "github.com/go-logr/logr" @@ -210,7 +211,7 @@ func checkServerStatus(workspace *common.DevWorkspaceWithConfig) (ok bool, respo } healthz.Path = path.Join(healthz.Path, "healthz") - healthCheckHttpClient := httpClientsFactory.GetHealthCheckHttpClient() + healthCheckHttpClient := httpfactory.HttpFactory.GetHealthCheckHttpClient() resp, err := healthCheckHttpClient.Get(healthz.String()) if err != nil { return false, nil, &dwerrors.RetryError{Err: err, Message: "Failed to check server status", RequeueAfter: 1 * time.Second} diff --git a/controllers/workspace/suite_test.go b/controllers/workspace/suite_test.go index 92a1612a5..0ac135ea9 100644 --- a/controllers/workspace/suite_test.go +++ b/controllers/workspace/suite_test.go @@ -1,4 +1,4 @@ -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 Red Hat, Inc. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -22,6 +22,7 @@ import ( "runtime" "testing" + "github.com/devfile/devworkspace-operator/pkg/httpfactory" "sigs.k8s.io/controller-runtime/pkg/webhook" dwv1 "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha1" @@ -147,7 +148,7 @@ var _ = BeforeSuite(func() { Expect(err).NotTo(HaveOccurred()) // Set HTTP client to fail all requests by default; tests that require HTTP must set this up directly - workspacecontroller.SetupHttpClientsForTesting(getBasicTestHttpClient()) + httpfactory.SetupHttpClientsForTesting(getBasicTestHttpClient()) // Skip trying to set up / test webhooks for now diff --git a/main.go b/main.go index cb2a9f757..1cc1c1cb5 100644 --- a/main.go +++ b/main.go @@ -1,5 +1,5 @@ // -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 Red Hat, Inc. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -22,6 +22,7 @@ import ( "os" "runtime" + "github.com/devfile/devworkspace-operator/pkg/httpfactory" "sigs.k8s.io/controller-runtime/pkg/metrics/filters" "github.com/devfile/devworkspace-operator/controllers/controller/devworkspacerouting" @@ -146,6 +147,12 @@ func main() { os.Exit(1) } + err = httpfactory.SetupHttpClientsFactory(mgr.GetClient(), mgr.GetLogger()) + if err != nil { + setupLog.Error(err, "Failed to setup Http clients factory") + os.Exit(1) + } + nonCachingClient, err := client.New(mgr.GetConfig(), client.Options{Scheme: scheme}) if err != nil { setupLog.Error(err, "unable to initialize non-caching client") diff --git a/pkg/constants/metadata.go b/pkg/constants/metadata.go index 33f1c062f..7fcb71e8b 100644 --- a/pkg/constants/metadata.go +++ b/pkg/constants/metadata.go @@ -93,6 +93,14 @@ const ( // can either be specified as a decimal (e.g. '416') or as an octal by prefixing the number with zero (e.g. '0640') DevWorkspaceMountAccessModeAnnotation = "controller.devfile.io/mount-access-mode" + // DevWorkspaceValidatedK8sResourcesAnnotation is the annotation key used to store the list of + // Kubernetes resources that have been validated by the mutating webhook via SubjectAccessReview. + // The controller uses this to determine which objects the workspace creator is authorized to create. + DevWorkspaceValidatedK8sResourcesAnnotation = "controller.devfile.io/validated-kubernetes-resources" + // DevWorkspaceValidatedSCCAnnotation is the annotation key used to store the SecurityContextConstraints + // that have been validated by the mutating webhook via SubjectAccessReview. + DevWorkspaceValidatedSCCAnnotation = "controller.devfile.io/validated-scc" + // DevWorkspaceGitCredentialLabel is the label key to specify if the secret is a git credential. All secrets who // specify this label in a namespace will consolidate into one secret before mounting into a devworkspace. // Only secret data with the credentials key will be used and credentials must be the base64 encoded version @@ -161,10 +169,10 @@ const ( // fails to start (i.e. enters the "Failed" phase), its deployment will not be scaled down in order to allow viewing logs, etc. DevWorkspaceDebugStartAnnotation = "controller.devfile.io/debug-start" - // WebhookRestartedAtAnnotation holds the the time (unixnano) of when the webhook server was forced to restart by controller + // WebhookRestartedAtAnnotation holds the time (unixnano) of when the webhook server was forced to restart by controller WebhookRestartedAtAnnotation = "controller.devfile.io/restarted-at" - // DevWorkspaceStartedAtAnnotation holds the the time (unixnano) of when the devworkspace was started + // DevWorkspaceStartedAtAnnotation holds the time (unixnano) of when the devworkspace was started DevWorkspaceStartedAtAnnotation = "controller.devfile.io/started-at" // RoutingAnnotationInfix is the infix of the annotations of DevWorkspace that are passed down as annotation to the DevWorkspaceRouting objects. diff --git a/controllers/workspace/http.go b/pkg/httpfactory/http.go similarity index 98% rename from controllers/workspace/http.go rename to pkg/httpfactory/http.go index 65a4db090..928067030 100644 --- a/controllers/workspace/http.go +++ b/pkg/httpfactory/http.go @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package controllers +package httpfactory import ( "context" @@ -35,7 +35,7 @@ import ( "golang.org/x/net/http/httpproxy" ) -var httpClientsFactory HttpClientsFactory +var HttpFactory HttpClientsFactory type HttpClientsFactory interface { // GetHttpClient returns an HTTP client configured with proxy, TLS, and custom CA certificates @@ -84,7 +84,7 @@ func SetupHttpClientsFactory(k8s client.Client, logger logr.Logger) error { InsecureSkipVerify: true, } - httpClientsFactory = &DefaultHttpClientsFactory{ + HttpFactory = &DefaultHttpClientsFactory{ k8s: k8s, logger: logger, systemCertPool: systemCertPool, diff --git a/controllers/workspace/http_test.go b/pkg/httpfactory/http_test.go similarity index 92% rename from controllers/workspace/http_test.go rename to pkg/httpfactory/http_test.go index 59eda013e..4f4f05fc0 100644 --- a/controllers/workspace/http_test.go +++ b/pkg/httpfactory/http_test.go @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package controllers +package httpfactory import ( "context" @@ -40,26 +40,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log/zap" ) -type TestHttpClientsFactory struct { - client *http.Client - healthCheckHttpClient *http.Client -} - -func (t *TestHttpClientsFactory) GetHttpClient(_ context.Context, _ *controller.RoutingConfig) *http.Client { - return t.client -} - -func (t *TestHttpClientsFactory) GetHealthCheckHttpClient() *http.Client { - return t.healthCheckHttpClient -} - -func SetupHttpClientsForTesting(client *http.Client) { - httpClientsFactory = &TestHttpClientsFactory{ - client: client, - healthCheckHttpClient: client, - } -} - func TestHealthCheckHttpClient(t *testing.T) { t.Run("returns non-nil client", func(t *testing.T) { factory := newTestFactory(t) diff --git a/pkg/httpfactory/http_testing.go b/pkg/httpfactory/http_testing.go new file mode 100644 index 000000000..72ed534e5 --- /dev/null +++ b/pkg/httpfactory/http_testing.go @@ -0,0 +1,41 @@ +// Copyright (c) 2019-2026 Red Hat, Inc. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package httpfactory + +import ( + "context" + "net/http" + + controller "github.com/devfile/devworkspace-operator/apis/controller/v1alpha1" +) + +type TestHttpClientsFactory struct { + client *http.Client + healthCheckHttpClient *http.Client +} + +func (t *TestHttpClientsFactory) GetHttpClient(_ context.Context, _ *controller.RoutingConfig) *http.Client { + return t.client +} + +func (t *TestHttpClientsFactory) GetHealthCheckHttpClient() *http.Client { + return t.healthCheckHttpClient +} + +func SetupHttpClientsForTesting(client *http.Client) { + HttpFactory = &TestHttpClientsFactory{ + client: client, + healthCheckHttpClient: client, + } +} diff --git a/pkg/library/kubernetes/deserialize.go b/pkg/library/kubernetes/deserialize.go index 90bd38edd..b1283c19d 100644 --- a/pkg/library/kubernetes/deserialize.go +++ b/pkg/library/kubernetes/deserialize.go @@ -1,4 +1,4 @@ -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 Red Hat, Inc. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -17,7 +17,6 @@ import ( "fmt" gosync "sync" - "github.com/devfile/devworkspace-operator/pkg/provision/sync" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/serializer" "sigs.k8s.io/controller-runtime/pkg/client" @@ -38,11 +37,11 @@ func InitializeDeserializer(scheme *runtime.Scheme) error { return nil } -func deserializeToObject(jsonObj []byte, api sync.ClusterAPI) (client.Object, error) { +func DeserializeToObject(data []byte) (client.Object, error) { if decoder == nil { return nil, fmt.Errorf("kubernetes object deserializer is not initialized") } - obj, _, err := decoder.Decode(jsonObj, nil, nil) + obj, _, err := decoder.Decode(data, nil, nil) if err != nil { return nil, err } @@ -51,7 +50,6 @@ func deserializeToObject(jsonObj []byte, api sync.ClusterAPI) (client.Object, er } clientObj, ok := obj.(client.Object) if !ok { - // Should never occur but to avoid a panic return nil, fmt.Errorf("object does not have standard metadata and cannot be processed") } return clientObj, nil diff --git a/pkg/library/kubernetes/deserialize_test.go b/pkg/library/kubernetes/deserialize_test.go index 436e4a8f9..12a93821b 100644 --- a/pkg/library/kubernetes/deserialize_test.go +++ b/pkg/library/kubernetes/deserialize_test.go @@ -1,4 +1,4 @@ -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 Red Hat, Inc. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -72,7 +72,7 @@ func TestDeserializeObject(t *testing.T) { for _, tt := range tests { t.Run(fmt.Sprintf("%s (%s)", tt.name, tt.filePath), func(t *testing.T) { jsonBytes := readBytesFromFile(t, tt.filePath) - actualObj, err := deserializeToObject(jsonBytes, testAPI) + actualObj, err := DeserializeToObject(jsonBytes) if tt.expectedErrRegexp != "" { if !assert.Error(t, err, "Expect error to be returned") { return @@ -91,7 +91,7 @@ func TestDeserializeObject(t *testing.T) { } func TestErrorIfDeserializerNotInitialized(t *testing.T) { - _, err := deserializeToObject([]byte(""), testAPI) + _, err := DeserializeToObject([]byte("")) assert.Error(t, err) assert.Equal(t, "kubernetes object deserializer is not initialized", err.Error()) } diff --git a/pkg/library/kubernetes/provision.go b/pkg/library/kubernetes/provision.go index 88b7a1108..6a3ca6dbc 100644 --- a/pkg/library/kubernetes/provision.go +++ b/pkg/library/kubernetes/provision.go @@ -1,4 +1,4 @@ -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 Red Hat, Inc. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -14,17 +14,24 @@ package kubernetes import ( + "encoding/json" "fmt" "reflect" + "slices" "strings" + dw "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" "github.com/devfile/devworkspace-operator/pkg/common" "github.com/devfile/devworkspace-operator/pkg/constants" "github.com/devfile/devworkspace-operator/pkg/dwerrors" + "github.com/devfile/devworkspace-operator/pkg/library/overrides/restrictions" "github.com/devfile/devworkspace-operator/pkg/provision/sync" + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" k8sErrors "k8s.io/apimachinery/pkg/api/errors" "sigs.k8s.io/controller-runtime/pkg/client" - crclient "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" ) @@ -47,13 +54,27 @@ func HandleKubernetesComponents(workspace *common.DevWorkspaceWithConfig, api sy } return nil } + + var validatedK8sResources []string + if data, ok := workspace.Annotations[constants.DevWorkspaceValidatedK8sResourcesAnnotation]; ok { + if err := json.Unmarshal([]byte(data), &validatedK8sResources); err != nil { + return &dwerrors.FailError{Message: fmt.Sprintf("failed to parse %s annotation", constants.DevWorkspaceValidatedK8sResourcesAnnotation), Err: err} + } + } + for _, component := range kubeComponents { // Ignore error as we filtered list above k8sLikeComponent, _ := getK8sLikeComponent(component) - obj, err := deserializeToObject([]byte(k8sLikeComponent.Inlined), api) + obj, err := DeserializeToObject([]byte(k8sLikeComponent.Inlined)) + if err != nil { + return &dwerrors.FailError{Message: fmt.Sprintf("could not process component %s", component.Name), Err: err} + } + + err = restrictK8sComponent(workspace, obj, validatedK8sResources) if err != nil { return &dwerrors.FailError{Message: fmt.Sprintf("could not process component %s", component.Name), Err: err} } + if err := addMetadata(obj, workspace, api); err != nil { return &dwerrors.RetryError{Message: fmt.Sprintf("failed to add ownerref for component %s", component.Name), Err: err} } @@ -80,7 +101,7 @@ func HandleKubernetesComponents(workspace *common.DevWorkspaceWithConfig, api sy func checkForExistingObject(obj client.Object, api sync.ClusterAPI) error { objType := reflect.TypeOf(obj).Elem() - clusterObj := reflect.New(objType).Interface().(crclient.Object) + clusterObj := reflect.New(objType).Interface().(client.Object) err := api.Client.Get(api.Ctx, client.ObjectKey{Name: obj.GetName(), Namespace: obj.GetNamespace()}, clusterObj) switch { case err == nil: @@ -122,3 +143,61 @@ func addMetadata(obj client.Object, workspace *common.DevWorkspaceWithConfig, ap obj.SetLabels(newLabels) return nil } + +func restrictK8sComponent(workspace *common.DevWorkspaceWithConfig, obj client.Object, validatedK8sResources []string) error { + gvk := obj.GetObjectKind().GroupVersionKind() + switch gvk { + case + rbacv1.SchemeGroupVersion.WithKind("Role"), + rbacv1.SchemeGroupVersion.WithKind("RoleBinding"), + rbacv1.SchemeGroupVersion.WithKind("ClusterRole"), + rbacv1.SchemeGroupVersion.WithKind("ClusterRoleBinding"): + return fmt.Errorf("kubernetes RBAC objects are not permitted within DevWorkspace components") + case + dw.SchemeGroupVersion.WithKind("DevWorkspace"), + dw.SchemeGroupVersion.WithKind("DevWorkspaceTemplate"): + return fmt.Errorf("DevWorkspace objects are not permitted within DevWorkspace components") + default: + // For backward compatibility, skip the validation when the annotation is absent, as it + // may not be present on workspaces created before this check was introduced. + if validatedK8sResources != nil { + if !slices.Contains(validatedK8sResources, gvk.String()) { + return fmt.Errorf("user is not authorized to create %s resources", gvk.Kind) + } + } + + // Always enforce pod/container field restrictions regardless of workspace phase: + // a compromised or malicious K8s component must never escalate privileges (e.g. + // runAsUser=0, privileged=true) even if it was previously allowed to run. + return restrictPodSpec(workspace, obj) + } +} + +func restrictPodSpec(workspace *common.DevWorkspaceWithConfig, obj client.Object) error { + var podSpec *corev1.PodSpec + + gvk := obj.GetObjectKind().GroupVersionKind() + switch gvk { + case corev1.SchemeGroupVersion.WithKind("Pod"): + podSpec = &obj.(*corev1.Pod).Spec + case batchv1.SchemeGroupVersion.WithKind("Job"): + podSpec = &obj.(*batchv1.Job).Spec.Template.Spec + case batchv1.SchemeGroupVersion.WithKind("CronJob"): + podSpec = &obj.(*batchv1.CronJob).Spec.JobTemplate.Spec.Template.Spec + case appsv1.SchemeGroupVersion.WithKind("Deployment"): + podSpec = &obj.(*appsv1.Deployment).Spec.Template.Spec + case appsv1.SchemeGroupVersion.WithKind("DaemonSet"): + podSpec = &obj.(*appsv1.DaemonSet).Spec.Template.Spec + case appsv1.SchemeGroupVersion.WithKind("StatefulSet"): + podSpec = &obj.(*appsv1.StatefulSet).Spec.Template.Spec + case appsv1.SchemeGroupVersion.WithKind("ReplicaSet"): + podSpec = &obj.(*appsv1.ReplicaSet).Spec.Template.Spec + default: + return nil + } + + restrictedPodFields := restrictions.GetRestrictedPodFields(workspace) + restrictedContainerFields := restrictions.GetRestrictedContainerFields(workspace) + + return restrictions.RestrictPodAndContainers(podSpec, restrictedPodFields, restrictedContainerFields) +} diff --git a/pkg/library/kubernetes/provision_test.go b/pkg/library/kubernetes/provision_test.go index 0615d6344..9967ae37f 100644 --- a/pkg/library/kubernetes/provision_test.go +++ b/pkg/library/kubernetes/provision_test.go @@ -1,4 +1,4 @@ -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 Red Hat, Inc. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -22,6 +22,7 @@ import ( "testing" dw "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" + controllerv1alpha1 "github.com/devfile/devworkspace-operator/apis/controller/v1alpha1" "github.com/devfile/devworkspace-operator/pkg/common" "github.com/devfile/devworkspace-operator/pkg/constants" "github.com/devfile/devworkspace-operator/pkg/dwerrors" @@ -33,7 +34,7 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - "k8s.io/utils/pointer" + "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/yaml" @@ -171,7 +172,7 @@ func TestSecretAndConfigMapProvisioning(t *testing.T) { ComponentUnion: dw.ComponentUnion{ Kubernetes: &dw.KubernetesComponent{ K8sLikeComponent: dw.K8sLikeComponent{ - DeployByDefault: pointer.BoolPtr(true), + DeployByDefault: ptr.To(true), K8sLikeComponentLocation: dw.K8sLikeComponentLocation{ Inlined: cmInline, }, @@ -184,7 +185,7 @@ func TestSecretAndConfigMapProvisioning(t *testing.T) { ComponentUnion: dw.ComponentUnion{ Kubernetes: &dw.KubernetesComponent{ K8sLikeComponent: dw.K8sLikeComponent{ - DeployByDefault: pointer.BoolPtr(true), + DeployByDefault: ptr.To(true), K8sLikeComponentLocation: dw.K8sLikeComponentLocation{ Inlined: secretInline, }, @@ -237,6 +238,452 @@ func TestHasKubelikeComponent(t *testing.T) { assert.True(t, HasKubelikeComponent(workspaceWithK8sComponents)) } +func TestHandleKubernetesComponentsRestrictsPodFields(t *testing.T) { + if err := InitializeDeserializer(testScheme); err != nil { + t.Fatalf("Unexpected error: %s", err) + } + defer func() { decoder = nil }() + + podInline := `{"apiVersion":"v1","kind":"Pod","metadata":{"name":"test-pod"},"spec":{"securityContext":{"runAsUser":0},"containers":[{"name":"c","image":"img"}]}}` + testClient := fake.NewClientBuilder().WithScheme(testScheme).Build() + api := sync.ClusterAPI{ + Client: testClient, + Scheme: testScheme, + Logger: testr.New(t), + } + wksp := &common.DevWorkspaceWithConfig{ + DevWorkspace: testDevWorkspace.DeepCopy(), + Config: &controllerv1alpha1.OperatorConfiguration{ + Workspace: &controllerv1alpha1.WorkspaceConfig{ + Overrides: &controllerv1alpha1.OverrideConfig{ + RestrictedPodOverrideFields: []string{"securityContext.runAsUser"}, + }, + }, + }, + } + wksp.Annotations = map[string]string{ + constants.DevWorkspaceValidatedK8sResourcesAnnotation: `["/v1, Kind=Pod"]`, + } + wksp.Spec.Template.Components = append(wksp.Spec.Template.Components, dw.Component{ + Name: "test-pod", + ComponentUnion: dw.ComponentUnion{ + Kubernetes: &dw.KubernetesComponent{ + K8sLikeComponent: dw.K8sLikeComponent{ + DeployByDefault: ptr.To(true), + K8sLikeComponentLocation: dw.K8sLikeComponentLocation{ + Inlined: podInline, + }, + }, + }, + }, + }) + + err := HandleKubernetesComponents(wksp, api) + assert.Error(t, err) + + failErr := &dwerrors.FailError{} + assert.True(t, errors.As(err, &failErr), "Expected FailError, got %T", err) + assert.Regexp(t, "could not process component test-pod", err.Error()) +} + +func TestHandleKubernetesComponentsRestrictsContainerFields(t *testing.T) { + if err := InitializeDeserializer(testScheme); err != nil { + t.Fatalf("Unexpected error: %s", err) + } + defer func() { decoder = nil }() + + podInline := `{"apiVersion":"v1","kind":"Pod","metadata":{"name":"test-pod"},"spec":{"containers":[{"name":"c","image":"img","securityContext":{"privileged":true}}]}}` + testClient := fake.NewClientBuilder().WithScheme(testScheme).Build() + api := sync.ClusterAPI{ + Client: testClient, + Scheme: testScheme, + Logger: testr.New(t), + } + wksp := &common.DevWorkspaceWithConfig{ + DevWorkspace: testDevWorkspace.DeepCopy(), + Config: &controllerv1alpha1.OperatorConfiguration{ + Workspace: &controllerv1alpha1.WorkspaceConfig{ + Overrides: &controllerv1alpha1.OverrideConfig{ + RestrictedContainerOverrideFields: []string{"securityContext.privileged"}, + }, + }, + }, + } + wksp.Annotations = map[string]string{ + constants.DevWorkspaceValidatedK8sResourcesAnnotation: `["/v1, Kind=Pod"]`, + } + wksp.Spec.Template.Components = append(wksp.Spec.Template.Components, dw.Component{ + Name: "test-pod", + ComponentUnion: dw.ComponentUnion{ + Kubernetes: &dw.KubernetesComponent{ + K8sLikeComponent: dw.K8sLikeComponent{ + DeployByDefault: ptr.To(true), + K8sLikeComponentLocation: dw.K8sLikeComponentLocation{ + Inlined: podInline, + }, + }, + }, + }, + }) + + err := HandleKubernetesComponents(wksp, api) + assert.Error(t, err) + + failErr := &dwerrors.FailError{} + assert.True(t, errors.As(err, &failErr), "Expected FailError, got %T", err) + assert.Regexp(t, "could not process component test-pod", err.Error()) +} + +func TestHandleKubernetesComponentsAllowsNonRestrictedFields(t *testing.T) { + if err := InitializeDeserializer(testScheme); err != nil { + t.Fatalf("Unexpected error: %s", err) + } + defer func() { decoder = nil }() + + podInline := `{"apiVersion":"v1","kind":"Pod","metadata":{"name":"test-pod"},"spec":{"containers":[{"name":"c","image":"img"}]}}` + testClient := fake.NewClientBuilder().WithScheme(testScheme).Build() + api := sync.ClusterAPI{ + Client: testClient, + Scheme: testScheme, + Logger: testr.New(t), + } + wksp := &common.DevWorkspaceWithConfig{ + DevWorkspace: testDevWorkspace.DeepCopy(), + Config: &controllerv1alpha1.OperatorConfiguration{ + Workspace: &controllerv1alpha1.WorkspaceConfig{ + Overrides: &controllerv1alpha1.OverrideConfig{ + RestrictedPodOverrideFields: []string{"securityContext.runAsUser"}, + RestrictedContainerOverrideFields: []string{"securityContext.privileged"}, + }, + }, + }, + } + wksp.Annotations = map[string]string{ + constants.DevWorkspaceValidatedK8sResourcesAnnotation: `["/v1, Kind=Pod"]`, + } + wksp.Spec.Template.Components = append(wksp.Spec.Template.Components, dw.Component{ + Name: "test-pod", + ComponentUnion: dw.ComponentUnion{ + Kubernetes: &dw.KubernetesComponent{ + K8sLikeComponent: dw.K8sLikeComponent{ + DeployByDefault: ptr.To(true), + K8sLikeComponentLocation: dw.K8sLikeComponentLocation{ + Inlined: podInline, + }, + }, + }, + }, + }) + + var err error + retryErr := &dwerrors.RetryError{} + for err = HandleKubernetesComponents(wksp, api); errors.As(err, &retryErr); err = HandleKubernetesComponents(wksp, api) { + } + assert.NoError(t, err) + + clusterPod := &corev1.Pod{} + err = testClient.Get(api.Ctx, types.NamespacedName{Name: "test-pod", Namespace: wksp.Namespace}, clusterPod) + assert.NoError(t, err, "Expected pod to be created on cluster") +} + +func TestHandleKubernetesComponentsNoRestrictionsConfigured(t *testing.T) { + if err := InitializeDeserializer(testScheme); err != nil { + t.Fatalf("Unexpected error: %s", err) + } + defer func() { decoder = nil }() + + podInline := `{"apiVersion":"v1","kind":"Pod","metadata":{"name":"test-pod"},"spec":{"securityContext":{"runAsUser":0},"containers":[{"name":"c","image":"img","securityContext":{"privileged":true}}]}}` + testClient := fake.NewClientBuilder().WithScheme(testScheme).Build() + api := sync.ClusterAPI{ + Client: testClient, + Scheme: testScheme, + Logger: testr.New(t), + } + wksp := &common.DevWorkspaceWithConfig{ + DevWorkspace: testDevWorkspace.DeepCopy(), + } + wksp.Annotations = map[string]string{ + constants.DevWorkspaceValidatedK8sResourcesAnnotation: `["/v1, Kind=Pod"]`, + } + wksp.Spec.Template.Components = append(wksp.Spec.Template.Components, dw.Component{ + Name: "test-pod", + ComponentUnion: dw.ComponentUnion{ + Kubernetes: &dw.KubernetesComponent{ + K8sLikeComponent: dw.K8sLikeComponent{ + DeployByDefault: ptr.To(true), + K8sLikeComponentLocation: dw.K8sLikeComponentLocation{ + Inlined: podInline, + }, + }, + }, + }, + }) + + var err error + retryErr := &dwerrors.RetryError{} + for err = HandleKubernetesComponents(wksp, api); errors.As(err, &retryErr); err = HandleKubernetesComponents(wksp, api) { + } + assert.NoError(t, err) + + clusterPod := &corev1.Pod{} + err = testClient.Get(api.Ctx, types.NamespacedName{Name: "test-pod", Namespace: wksp.Namespace}, clusterPod) + assert.NoError(t, err, "Expected pod to be created on cluster") +} + +func TestHandleKubernetesComponentsRejectsRBACObjects(t *testing.T) { + if err := InitializeDeserializer(testScheme); err != nil { + t.Fatalf("Unexpected error: %s", err) + } + defer func() { decoder = nil }() + + rbacTests := []struct { + name string + inline string + }{ + { + name: "Role", + inline: `{"apiVersion":"rbac.authorization.k8s.io/v1","kind":"Role","metadata":{"name":"test-role"},"rules":[{"apiGroups":[""],"resources":["pods"],"verbs":["get"]}]}`, + }, + { + name: "RoleBinding", + inline: `{"apiVersion":"rbac.authorization.k8s.io/v1","kind":"RoleBinding","metadata":{"name":"test-rolebinding"},"roleRef":{"apiGroup":"rbac.authorization.k8s.io","kind":"Role","name":"test-role"},"subjects":[{"kind":"ServiceAccount","name":"default"}]}`, + }, + { + name: "ClusterRole", + inline: `{"apiVersion":"rbac.authorization.k8s.io/v1","kind":"ClusterRole","metadata":{"name":"test-clusterrole"},"rules":[{"apiGroups":[""],"resources":["pods"],"verbs":["get"]}]}`, + }, + { + name: "ClusterRoleBinding", + inline: `{"apiVersion":"rbac.authorization.k8s.io/v1","kind":"ClusterRoleBinding","metadata":{"name":"test-clusterrolebinding"},"roleRef":{"apiGroup":"rbac.authorization.k8s.io","kind":"ClusterRole","name":"test-clusterrole"},"subjects":[{"kind":"ServiceAccount","name":"default"}]}`, + }, + } + + for _, tt := range rbacTests { + t.Run(tt.name, func(t *testing.T) { + testClient := fake.NewClientBuilder().WithScheme(testScheme).Build() + api := sync.ClusterAPI{ + Client: testClient, + Scheme: testScheme, + Logger: testr.New(t), + } + + wksp := &common.DevWorkspaceWithConfig{ + DevWorkspace: testDevWorkspace.DeepCopy(), + } + + wksp.Spec.Template.Components = append(wksp.Spec.Template.Components, dw.Component{ + Name: "test-rbac", + ComponentUnion: dw.ComponentUnion{ + Kubernetes: &dw.KubernetesComponent{ + K8sLikeComponent: dw.K8sLikeComponent{ + DeployByDefault: ptr.To(true), + K8sLikeComponentLocation: dw.K8sLikeComponentLocation{ + Inlined: tt.inline, + }, + }, + }, + }, + }) + + err := HandleKubernetesComponents(wksp, api) + assert.Error(t, err) + + failErr := &dwerrors.FailError{} + assert.True(t, errors.As(err, &failErr), "Expected FailError, got %T", err) + assert.Regexp(t, "could not process component test-rbac", err.Error()) + assert.Contains(t, err.Error(), "RBAC objects are not permitted") + }) + } +} + +func TestHandleKubernetesComponentsWithoutAnnotation(t *testing.T) { + if err := InitializeDeserializer(testScheme); err != nil { + t.Fatalf("Unexpected error: %s", err) + } + defer func() { decoder = nil }() + + podInline := `{"apiVersion":"v1","kind":"Pod","metadata":{"name":"test-pod"},"spec":{"containers":[{"name":"c","image":"img"}]}}` + testClient := fake.NewClientBuilder().WithScheme(testScheme).Build() + api := sync.ClusterAPI{ + Client: testClient, + Scheme: testScheme, + Logger: testr.New(t), + } + wksp := &common.DevWorkspaceWithConfig{ + DevWorkspace: testDevWorkspace.DeepCopy(), + } + // No validated-kubernetes-resources annotation set — simulates a pre-upgrade workspace + wksp.Spec.Template.Components = append(wksp.Spec.Template.Components, dw.Component{ + Name: "test-pod", + ComponentUnion: dw.ComponentUnion{ + Kubernetes: &dw.KubernetesComponent{ + K8sLikeComponent: dw.K8sLikeComponent{ + DeployByDefault: ptr.To(true), + K8sLikeComponentLocation: dw.K8sLikeComponentLocation{ + Inlined: podInline, + }, + }, + }, + }, + }) + + var err error + retryErr := &dwerrors.RetryError{} + for err = HandleKubernetesComponents(wksp, api); errors.As(err, &retryErr); err = HandleKubernetesComponents(wksp, api) { + } + assert.NoError(t, err, "Should allow K8s components when annotation is absent (backward compat)") + + clusterPod := &corev1.Pod{} + err = testClient.Get(api.Ctx, types.NamespacedName{Name: "test-pod", Namespace: wksp.Namespace}, clusterPod) + assert.NoError(t, err, "Expected pod to be created on cluster") +} + +func TestHandleKubernetesComponentsRejectsMalformedAnnotation(t *testing.T) { + if err := InitializeDeserializer(testScheme); err != nil { + t.Fatalf("Unexpected error: %s", err) + } + defer func() { decoder = nil }() + + podInline := `{"apiVersion":"v1","kind":"Pod","metadata":{"name":"test-pod"},"spec":{"containers":[{"name":"c","image":"img"}]}}` + testClient := fake.NewClientBuilder().WithScheme(testScheme).Build() + api := sync.ClusterAPI{ + Client: testClient, + Scheme: testScheme, + Logger: testr.New(t), + } + wksp := &common.DevWorkspaceWithConfig{ + DevWorkspace: testDevWorkspace.DeepCopy(), + } + wksp.Annotations = map[string]string{ + constants.DevWorkspaceValidatedK8sResourcesAnnotation: `not-valid-json`, + } + wksp.Spec.Template.Components = append(wksp.Spec.Template.Components, dw.Component{ + Name: "test-pod", + ComponentUnion: dw.ComponentUnion{ + Kubernetes: &dw.KubernetesComponent{ + K8sLikeComponent: dw.K8sLikeComponent{ + DeployByDefault: ptr.To(true), + K8sLikeComponentLocation: dw.K8sLikeComponentLocation{ + Inlined: podInline, + }, + }, + }, + }, + }) + + err := HandleKubernetesComponents(wksp, api) + assert.Error(t, err) +} + +func TestRestrictK8sComponentAllowsValidatedResource(t *testing.T) { + if err := InitializeDeserializer(testScheme); err != nil { + t.Fatalf("Unexpected error: %s", err) + } + defer func() { decoder = nil }() + + obj, err := DeserializeToObject([]byte(`{"apiVersion":"v1","kind":"Pod","metadata":{"name":"test-pod"},"spec":{"containers":[{"name":"c","image":"img"}]}}`)) + assert.NoError(t, err) + + wksp := &common.DevWorkspaceWithConfig{ + DevWorkspace: testDevWorkspace.DeepCopy(), + } + + err = restrictK8sComponent(wksp, obj, []string{"/v1, Kind=Pod"}) + assert.NoError(t, err) +} + +func TestRestrictK8sComponentRejectsNonValidatedResource(t *testing.T) { + if err := InitializeDeserializer(testScheme); err != nil { + t.Fatalf("Unexpected error: %s", err) + } + defer func() { decoder = nil }() + + obj, err := DeserializeToObject([]byte(`{"apiVersion":"v1","kind":"Pod","metadata":{"name":"test-pod"},"spec":{"containers":[{"name":"c","image":"img"}]}}`)) + assert.NoError(t, err) + + wksp := &common.DevWorkspaceWithConfig{ + DevWorkspace: testDevWorkspace.DeepCopy(), + } + + err = restrictK8sComponent(wksp, obj, []string{}) + assert.Error(t, err) +} + +func TestRestrictK8sComponentRejectsRBACEvenWhenValidated(t *testing.T) { + if err := InitializeDeserializer(testScheme); err != nil { + t.Fatalf("Unexpected error: %s", err) + } + defer func() { decoder = nil }() + + obj, err := DeserializeToObject([]byte(`{"apiVersion":"rbac.authorization.k8s.io/v1","kind":"Role","metadata":{"name":"test-role"},"rules":[{"apiGroups":[""],"resources":["pods"],"verbs":["get"]}]}`)) + assert.NoError(t, err) + + wksp := &common.DevWorkspaceWithConfig{ + DevWorkspace: testDevWorkspace.DeepCopy(), + } + + err = restrictK8sComponent(wksp, obj, []string{"rbac.authorization.k8s.io/v1, Kind=Role"}) + assert.Error(t, err) +} + +func TestRestrictK8sComponentSkipsValidationWhenAnnotationAbsent(t *testing.T) { + if err := InitializeDeserializer(testScheme); err != nil { + t.Fatalf("Unexpected error: %s", err) + } + defer func() { decoder = nil }() + + obj, err := DeserializeToObject([]byte(`{"apiVersion":"v1","kind":"Pod","metadata":{"name":"test-pod"},"spec":{"containers":[{"name":"c","image":"img"}]}}`)) + assert.NoError(t, err) + + wksp := &common.DevWorkspaceWithConfig{ + DevWorkspace: testDevWorkspace.DeepCopy(), + } + + // nil validatedK8sComponents simulates absent annotation (backward compat for pre-upgrade workspaces) + err = restrictK8sComponent(wksp, obj, nil) + assert.NoError(t, err) +} + +func TestRestrictK8sComponentRejectsRBACEvenWhenAnnotationAbsent(t *testing.T) { + if err := InitializeDeserializer(testScheme); err != nil { + t.Fatalf("Unexpected error: %s", err) + } + defer func() { decoder = nil }() + + obj, err := DeserializeToObject([]byte(`{"apiVersion":"rbac.authorization.k8s.io/v1","kind":"Role","metadata":{"name":"test-role"},"rules":[{"apiGroups":[""],"resources":["pods"],"verbs":["get"]}]}`)) + assert.NoError(t, err) + + wksp := &common.DevWorkspaceWithConfig{ + DevWorkspace: testDevWorkspace.DeepCopy(), + } + + // RBAC objects must be rejected even when annotation is absent + err = restrictK8sComponent(wksp, obj, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "RBAC objects are not permitted") +} + +func TestRestrictK8sComponentAllowsMultipleValidatedResources(t *testing.T) { + if err := InitializeDeserializer(testScheme); err != nil { + t.Fatalf("Unexpected error: %s", err) + } + defer func() { decoder = nil }() + + podObj, err := DeserializeToObject([]byte(`{"apiVersion":"v1","kind":"Pod","metadata":{"name":"test-pod"},"spec":{"containers":[{"name":"c","image":"img"}]}}`)) + assert.NoError(t, err) + + svcObj, err := DeserializeToObject([]byte(`{"apiVersion":"v1","kind":"Service","metadata":{"name":"test-svc"},"spec":{"selector":{"app":"test"},"ports":[{"port":8080}]}}`)) + assert.NoError(t, err) + + wksp := &common.DevWorkspaceWithConfig{ + DevWorkspace: testDevWorkspace.DeepCopy(), + } + + validatedResources := []string{"/v1, Kind=Pod", "/v1, Kind=Service"} + assert.NoError(t, restrictK8sComponent(wksp, podObj, validatedResources)) + assert.NoError(t, restrictK8sComponent(wksp, svcObj, validatedResources)) +} + func loadAllTestCasesOrPanic(t *testing.T, fromDir string) []testCase { files, err := os.ReadDir(fromDir) if err != nil { diff --git a/pkg/library/overrides/containers.go b/pkg/library/overrides/containers.go index 0e2fa3ebe..ced75f659 100644 --- a/pkg/library/overrides/containers.go +++ b/pkg/library/overrides/containers.go @@ -16,6 +16,7 @@ package overrides import ( "fmt" + "github.com/devfile/devworkspace-operator/pkg/library/overrides/restrictions" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/util/json" "k8s.io/apimachinery/pkg/util/strategicpatch" @@ -74,6 +75,29 @@ func ApplyContainerOverrides(component *dw.Component, container *corev1.Containe return patched, nil } +func restrictContainerOverride(override *corev1.Container, restrictedFields []string) error { + if override.Name != "" { + return fmt.Errorf("restricted container field set name") + } + if override.Image != "" { + return fmt.Errorf("restricted container field set image") + } + if override.Command != nil { + return fmt.Errorf("restricted container field set command") + } + if override.Args != nil { + return fmt.Errorf("restricted container field set args") + } + if override.Ports != nil { + return fmt.Errorf("restricted container field set ports") + } + if override.Env != nil { + return fmt.Errorf("restricted container field set env") + } + + return restrictions.RestrictContainer(override, restrictedFields) +} + // handleDefaultedContainerFields fills partially-filled structs with defaulted fields // in a container. This is required to avoid repeatedly reconciling a container where e.g. // diff --git a/pkg/library/overrides/containers_test.go b/pkg/library/overrides/containers_test.go index 6c8c49844..60a7e4b41 100644 --- a/pkg/library/overrides/containers_test.go +++ b/pkg/library/overrides/containers_test.go @@ -20,10 +20,15 @@ import ( "testing" dw "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" + "github.com/devfile/api/v2/pkg/attributes" "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/assert" corev1 "k8s.io/api/core/v1" + apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/util/json" "sigs.k8s.io/yaml" + + "github.com/devfile/devworkspace-operator/pkg/constants" ) func TestApplyContainerOverrides(t *testing.T) { @@ -45,6 +50,98 @@ func TestApplyContainerOverrides(t *testing.T) { } } +func TestRestrictContainerOverride(t *testing.T) { + tests := []struct { + Name string + Override corev1.Container + IsErrorExpected bool + ErrField string + }{ + { + Name: "no restricted fields allows everything", + Override: corev1.Container{}, + }, + { + Name: "name always restricted", + Override: corev1.Container{Name: "test"}, + IsErrorExpected: true, + ErrField: "name", + }, + { + Name: "image always restricted", + Override: corev1.Container{Image: "test"}, + IsErrorExpected: true, + ErrField: "image", + }, + { + Name: "command always restricted", + Override: corev1.Container{Command: []string{}}, + IsErrorExpected: true, + ErrField: "command", + }, + { + Name: "args always restricted", + Override: corev1.Container{Args: []string{}}, + IsErrorExpected: true, + ErrField: "args", + }, + { + Name: "ports always restricted", + Override: corev1.Container{Ports: []corev1.ContainerPort{{}}}, + IsErrorExpected: true, + ErrField: "ports", + }, + { + Name: "env always restricted", + Override: corev1.Container{Env: []corev1.EnvVar{{}}}, + IsErrorExpected: true, + ErrField: "env", + }, + } + + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + err := restrictContainerOverride(&tt.Override, nil) + + if tt.IsErrorExpected { + assert.Error(t, err) + assert.Equal(t, fmt.Sprintf("restricted container field set %s", tt.ErrField), err.Error()) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestApplyContainerOverridesStripsUnknownFields(t *testing.T) { + overrideJSON := `{"workingDir":"/workspace","futureSecurityField":"malicious-value","unknownNested":{"key":"val"}}` + + component := &dw.Component{ + Name: "test-component", + Attributes: attributes.Attributes{ + constants.ContainerOverridesAttribute: apiext.JSON{Raw: []byte(overrideJSON)}, + }, + ComponentUnion: dw.ComponentUnion{ + Container: &dw.ContainerComponent{ + Container: dw.Container{Image: "test-image"}, + }, + }, + } + container := &corev1.Container{ + Name: "test-component", + Image: "test-image", + } + + patched, err := ApplyContainerOverrides(component, container, nil) + assert.NoError(t, err) + assert.Equal(t, "/workspace", patched.WorkingDir) + + patchedBytes, err := json.Marshal(patched) + assert.NoError(t, err) + assert.NotContains(t, string(patchedBytes), "futureSecurityField") + assert.NotContains(t, string(patchedBytes), "unknownNested") +} + type containerTestCase struct { Name string `json:"name,omitempty"` Input *containerTestInput `json:"input,omitempty"` diff --git a/pkg/library/overrides/pods.go b/pkg/library/overrides/pods.go index a59406578..7a3d5b008 100644 --- a/pkg/library/overrides/pods.go +++ b/pkg/library/overrides/pods.go @@ -17,6 +17,7 @@ import ( "fmt" dw "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" + "github.com/devfile/devworkspace-operator/pkg/library/overrides/restrictions" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" @@ -42,7 +43,7 @@ func NeedsPodOverrides(workspace *common.DevWorkspaceWithConfig) bool { } func ApplyPodOverrides(workspace *common.DevWorkspaceWithConfig, deployment *appsv1.Deployment) (*appsv1.Deployment, error) { - overrides, err := getPodOverrides(&workspace.Spec.Template, GetRestrictedPodOverrideFields(workspace)) + overrides, err := getPodOverrides(&workspace.Spec.Template, restrictions.GetRestrictedPodFields(workspace)) if err != nil { return nil, err } @@ -133,3 +134,14 @@ func getPodOverrides(workspace *dw.DevWorkspaceTemplateSpec, restrictedFields [] } return allOverrides, nil } + +func restrictPodOverride(override *corev1.PodSpec, restrictedFields []string) error { + if override.Containers != nil { + return fmt.Errorf("restricted pod field set containers") + } + if override.InitContainers != nil { + return fmt.Errorf("restricted pod field set initContainers") + } + + return restrictions.RestrictPod(override, restrictedFields) +} diff --git a/pkg/library/overrides/pods_test.go b/pkg/library/overrides/pods_test.go index 6352fbc2e..34d806452 100644 --- a/pkg/library/overrides/pods_test.go +++ b/pkg/library/overrides/pods_test.go @@ -21,14 +21,16 @@ import ( dw "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" "github.com/devfile/api/v2/pkg/attributes" - "github.com/devfile/devworkspace-operator/pkg/common" - "github.com/devfile/devworkspace-operator/pkg/constants" "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/assert" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/util/json" "sigs.k8s.io/yaml" + + "github.com/devfile/devworkspace-operator/pkg/common" + "github.com/devfile/devworkspace-operator/pkg/constants" ) func TestApplyPodOverrides(t *testing.T) { @@ -173,6 +175,82 @@ func TestNeedsPodOverride(t *testing.T) { } } +func TestRestrictPodOverride(t *testing.T) { + tests := []struct { + Name string + Override corev1.PodSpec + IsErrorExpected bool + ErrField string + }{ + { + Name: "no denied fields allows everything", + Override: corev1.PodSpec{}, + }, + { + Name: "containers always denied", + Override: corev1.PodSpec{Containers: []corev1.Container{{}}}, + IsErrorExpected: true, + ErrField: "containers", + }, + { + Name: "initContainers always denied", + Override: corev1.PodSpec{InitContainers: []corev1.Container{{}}}, + IsErrorExpected: true, + ErrField: "initContainers", + }, + } + + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + err := restrictPodOverride(&tt.Override, nil) + + if tt.IsErrorExpected { + assert.Error(t, err) + assert.Equal(t, fmt.Sprintf("restricted pod field set %s", tt.ErrField), err.Error()) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestApplyPodOverridesStripsUnknownFields(t *testing.T) { + overrideJSON := `{"spec":{"schedulerName":"custom","futureSecurityField":"malicious-value","unknownNested":{"key":"val"}}}` + + workspace := &common.DevWorkspaceWithConfig{} + workspace.DevWorkspace = &dw.DevWorkspace{} + workspace.Spec.Template = dw.DevWorkspaceTemplateSpec{ + DevWorkspaceTemplateSpecContent: dw.DevWorkspaceTemplateSpecContent{ + Attributes: attributes.Attributes{ + constants.PodOverridesAttribute: apiext.JSON{Raw: []byte(overrideJSON)}, + }, + Components: []dw.Component{{ + Name: "test-component", + ComponentUnion: dw.ComponentUnion{ + Container: &dw.ContainerComponent{ + Container: dw.Container{Image: "test-image"}, + }, + }, + }}, + }, + } + + deployment := &appsv1.Deployment{} + deployment.Spec.Template.Spec.Containers = []corev1.Container{{ + Name: "test-component", + Image: "test-image", + }} + + patched, err := ApplyPodOverrides(workspace, deployment) + assert.NoError(t, err) + assert.Equal(t, "custom", patched.Spec.Template.Spec.SchedulerName) + + patchedBytes, err := json.Marshal(patched.Spec.Template.Spec) + assert.NoError(t, err) + assert.NotContains(t, string(patchedBytes), "futureSecurityField") + assert.NotContains(t, string(patchedBytes), "unknownNested") +} + type podTestCase struct { Name string `json:"name,omitempty"` Input *podTestInput `json:"input,omitempty"` diff --git a/pkg/library/overrides/container_restrictions.go b/pkg/library/overrides/restrictions/container_restrictions.go similarity index 92% rename from pkg/library/overrides/container_restrictions.go rename to pkg/library/overrides/restrictions/container_restrictions.go index 3fda37544..0c9db7d39 100644 --- a/pkg/library/overrides/container_restrictions.go +++ b/pkg/library/overrides/restrictions/container_restrictions.go @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package overrides +package restrictions import ( "fmt" @@ -21,29 +21,10 @@ import ( ) func getContainerRestrictionErr(msg string) error { - return fmt.Errorf("cannot use container-overrides to override container %s", msg) + return fmt.Errorf("restricted container field set %s", msg) } -func restrictContainerOverride(override *corev1.Container, restrictedFields []string) error { - if override.Name != "" { - return getContainerRestrictionErr("name") - } - if override.Image != "" { - return getContainerRestrictionErr("image") - } - if override.Command != nil { - return getContainerRestrictionErr("command") - } - if override.Args != nil { - return getContainerRestrictionErr("args") - } - if override.Ports != nil { - return getContainerRestrictionErr("ports") - } - if override.Env != nil { - return getContainerRestrictionErr("env") - } - +func RestrictContainer(container *corev1.Container, restrictedFields []string) error { for _, restrictedField := range restrictedFields { fieldName, fieldValue, _ := strings.Cut(restrictedField, "=") if fieldName == "" { @@ -58,7 +39,7 @@ func restrictContainerOverride(override *corev1.Container, restrictedFields []st getRestrictionErr: getContainerRestrictionErr, } - if err := checkContainer(override, root, remaining, restriction); err != nil { + if err := checkContainer(container, root, remaining, restriction); err != nil { return err } } diff --git a/pkg/library/overrides/container_restrictions_test.go b/pkg/library/overrides/restrictions/container_restrictions_test.go similarity index 95% rename from pkg/library/overrides/container_restrictions_test.go rename to pkg/library/overrides/restrictions/container_restrictions_test.go index 483978b39..388ba2ec7 100644 --- a/pkg/library/overrides/container_restrictions_test.go +++ b/pkg/library/overrides/restrictions/container_restrictions_test.go @@ -11,23 +11,19 @@ // See the License for the specific language governing permissions and // limitations under the License. -package overrides +package restrictions import ( + "fmt" "testing" - dw "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" - "github.com/devfile/api/v2/pkg/attributes" - "github.com/devfile/devworkspace-operator/pkg/constants" "github.com/stretchr/testify/assert" corev1 "k8s.io/api/core/v1" - apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/apimachinery/pkg/api/resource" - "k8s.io/apimachinery/pkg/util/json" "k8s.io/utils/ptr" ) -func TestRestrictContainerOverride(t *testing.T) { +func TestRestrictContainer(t *testing.T) { tests := []struct { Name string @@ -41,42 +37,6 @@ func TestRestrictContainerOverride(t *testing.T) { Name: "no restricted fields allows everything", Override: corev1.Container{}, }, - { - Name: "name always restricted", - Override: corev1.Container{Name: "test"}, - IsErrorExpected: true, - ErrField: "name", - }, - { - Name: "image always restricted", - Override: corev1.Container{Image: "test"}, - IsErrorExpected: true, - ErrField: "image", - }, - { - Name: "command always restricted", - Override: corev1.Container{Command: []string{}}, - IsErrorExpected: true, - ErrField: "command", - }, - { - Name: "args always restricted", - Override: corev1.Container{Args: []string{}}, - IsErrorExpected: true, - ErrField: "args", - }, - { - Name: "ports always restricted", - Override: corev1.Container{Ports: []corev1.ContainerPort{{}}}, - IsErrorExpected: true, - ErrField: "ports", - }, - { - Name: "env always restricted", - Override: corev1.Container{Env: []corev1.EnvVar{{}}}, - IsErrorExpected: true, - ErrField: "env", - }, // ----------- WorkingDir ----------- { Name: "workingDir empty value not restricted", @@ -1176,43 +1136,14 @@ func TestRestrictContainerOverride(t *testing.T) { for _, tt := range tests { t.Run(tt.Name, func(t *testing.T) { - err := restrictContainerOverride(&tt.Override, tt.RestrictedFields) + err := RestrictContainer(&tt.Override, tt.RestrictedFields) if tt.IsErrorExpected { assert.Error(t, err) - assert.Equal(t, getContainerRestrictionErr(tt.ErrField).Error(), err.Error()) + assert.Equal(t, fmt.Sprintf("restricted container field set %s", tt.ErrField), err.Error()) } else { assert.NoError(t, err) } }) } } - -func TestApplyContainerOverridesStripsUnknownFields(t *testing.T) { - overrideJSON := `{"workingDir":"/workspace","futureSecurityField":"malicious-value","unknownNested":{"key":"val"}}` - - component := &dw.Component{ - Name: "test-component", - Attributes: attributes.Attributes{ - constants.ContainerOverridesAttribute: apiext.JSON{Raw: []byte(overrideJSON)}, - }, - ComponentUnion: dw.ComponentUnion{ - Container: &dw.ContainerComponent{ - Container: dw.Container{Image: "test-image"}, - }, - }, - } - container := &corev1.Container{ - Name: "test-component", - Image: "test-image", - } - - patched, err := ApplyContainerOverrides(component, container, nil) - assert.NoError(t, err) - assert.Equal(t, "/workspace", patched.WorkingDir) - - patchedBytes, err := json.Marshal(patched) - assert.NoError(t, err) - assert.NotContains(t, string(patchedBytes), "futureSecurityField") - assert.NotContains(t, string(patchedBytes), "unknownNested") -} diff --git a/pkg/library/overrides/pod_restrictions.go b/pkg/library/overrides/restrictions/pod_restrictions.go similarity index 92% rename from pkg/library/overrides/pod_restrictions.go rename to pkg/library/overrides/restrictions/pod_restrictions.go index 18189a4e7..06f782d57 100644 --- a/pkg/library/overrides/pod_restrictions.go +++ b/pkg/library/overrides/restrictions/pod_restrictions.go @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package overrides +package restrictions import ( "fmt" @@ -21,17 +21,10 @@ import ( ) func getPodRestrictionErr(msg string) error { - return fmt.Errorf("cannot use pod-overrides to override pod %s", msg) + return fmt.Errorf("restricted pod field set %s", msg) } -func restrictPodOverride(override *corev1.PodSpec, restrictedFields []string) error { - if override.Containers != nil { - return getPodRestrictionErr("containers") - } - if override.InitContainers != nil { - return getPodRestrictionErr("initContainers") - } - +func RestrictPod(pod *corev1.PodSpec, restrictedFields []string) error { for _, field := range restrictedFields { fieldName, fieldValue, _ := strings.Cut(field, "=") if fieldName == "" { @@ -46,7 +39,7 @@ func restrictPodOverride(override *corev1.PodSpec, restrictedFields []string) er getRestrictionErr: getPodRestrictionErr, } - if err := checkPodField(override, root, remaining, restriction); err != nil { + if err := checkPodField(pod, root, remaining, restriction); err != nil { return err } } @@ -54,6 +47,30 @@ func restrictPodOverride(override *corev1.PodSpec, restrictedFields []string) er return nil } +func RestrictPodAndContainers(pod *corev1.PodSpec, restrictedPodFields []string, restrictedContainerFields []string) error { + if err := RestrictPod(pod, restrictedPodFields); err != nil { + return err + } + + if pod.InitContainers != nil { + for _, container := range pod.InitContainers { + if err := RestrictContainer(&container, restrictedContainerFields); err != nil { + return err + } + } + } + + if pod.Containers != nil { + for _, container := range pod.Containers { + if err := RestrictContainer(&container, restrictedContainerFields); err != nil { + return err + } + } + } + + return nil +} + func checkPodField(override *corev1.PodSpec, root string, remaining string, restriction *FieldRestriction) error { if remaining == "" { switch root { diff --git a/pkg/library/overrides/pod_restrictions_test.go b/pkg/library/overrides/restrictions/pod_restrictions_test.go similarity index 96% rename from pkg/library/overrides/pod_restrictions_test.go rename to pkg/library/overrides/restrictions/pod_restrictions_test.go index 3278a450e..5bb7b5ad6 100644 --- a/pkg/library/overrides/pod_restrictions_test.go +++ b/pkg/library/overrides/restrictions/pod_restrictions_test.go @@ -11,26 +11,19 @@ // See the License for the specific language governing permissions and // limitations under the License. -package overrides +package restrictions import ( + "fmt" "testing" - dw "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" - "github.com/devfile/api/v2/pkg/attributes" "github.com/stretchr/testify/assert" - appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" - apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/apimachinery/pkg/api/resource" - "k8s.io/apimachinery/pkg/util/json" "k8s.io/utils/ptr" - - "github.com/devfile/devworkspace-operator/pkg/common" - "github.com/devfile/devworkspace-operator/pkg/constants" ) -func TestRestrictPodOverride(t *testing.T) { +func TestRestrictPod(t *testing.T) { tests := []struct { Name string RestrictedFields []string @@ -42,18 +35,6 @@ func TestRestrictPodOverride(t *testing.T) { Name: "no denied fields allows everything", Override: corev1.PodSpec{}, }, - { - Name: "containers always denied", - Override: corev1.PodSpec{Containers: []corev1.Container{{}}}, - IsErrorExpected: true, - ErrField: "containers", - }, - { - Name: "initContainers always denied", - Override: corev1.PodSpec{InitContainers: []corev1.Container{{}}}, - IsErrorExpected: true, - ErrField: "initContainers", - }, // ----------- RestartPolicy ----------- { Name: "restartPolicy empty value not restricted", @@ -1444,11 +1425,11 @@ func TestRestrictPodOverride(t *testing.T) { for _, tt := range tests { t.Run(tt.Name, func(t *testing.T) { - err := restrictPodOverride(&tt.Override, tt.RestrictedFields) + err := RestrictPod(&tt.Override, tt.RestrictedFields) if tt.IsErrorExpected { assert.Error(t, err) - assert.Equal(t, getPodRestrictionErr(tt.ErrField).Error(), err.Error()) + assert.Equal(t, fmt.Sprintf("restricted pod field set %s", tt.ErrField), err.Error()) } else { assert.NoError(t, err) } @@ -1456,43 +1437,6 @@ func TestRestrictPodOverride(t *testing.T) { } } -func TestApplyPodOverridesStripsUnknownFields(t *testing.T) { - overrideJSON := `{"spec":{"schedulerName":"custom","futureSecurityField":"malicious-value","unknownNested":{"key":"val"}}}` - - workspace := &common.DevWorkspaceWithConfig{} - workspace.DevWorkspace = &dw.DevWorkspace{} - workspace.Spec.Template = dw.DevWorkspaceTemplateSpec{ - DevWorkspaceTemplateSpecContent: dw.DevWorkspaceTemplateSpecContent{ - Attributes: attributes.Attributes{ - constants.PodOverridesAttribute: apiext.JSON{Raw: []byte(overrideJSON)}, - }, - Components: []dw.Component{{ - Name: "test-component", - ComponentUnion: dw.ComponentUnion{ - Container: &dw.ContainerComponent{ - Container: dw.Container{Image: "test-image"}, - }, - }, - }}, - }, - } - - deployment := &appsv1.Deployment{} - deployment.Spec.Template.Spec.Containers = []corev1.Container{{ - Name: "test-component", - Image: "test-image", - }} - - patched, err := ApplyPodOverrides(workspace, deployment) - assert.NoError(t, err) - assert.Equal(t, "custom", patched.Spec.Template.Spec.SchedulerName) - - patchedBytes, err := json.Marshal(patched.Spec.Template.Spec) - assert.NoError(t, err) - assert.NotContains(t, string(patchedBytes), "futureSecurityField") - assert.NotContains(t, string(patchedBytes), "unknownNested") -} - func preemptionPolicyPtr(p corev1.PreemptionPolicy) *corev1.PreemptionPolicy { return &p } diff --git a/pkg/library/overrides/restrictions.go b/pkg/library/overrides/restrictions/restrictions.go similarity index 95% rename from pkg/library/overrides/restrictions.go rename to pkg/library/overrides/restrictions/restrictions.go index ba84ffb68..180f37267 100644 --- a/pkg/library/overrides/restrictions.go +++ b/pkg/library/overrides/restrictions/restrictions.go @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package overrides +package restrictions import ( "strconv" @@ -100,7 +100,7 @@ func (r FieldRestriction) checkRestrictedValue(value string) error { return nil } -func GetRestrictedContainerOverrideFields(workspace *common.DevWorkspaceWithConfig) []string { +func GetRestrictedContainerFields(workspace *common.DevWorkspaceWithConfig) []string { if workspace.Config != nil && workspace.Config.Workspace != nil && workspace.Config.Workspace.Overrides != nil { return workspace.Config.Workspace.Overrides.RestrictedContainerOverrideFields } @@ -108,7 +108,7 @@ func GetRestrictedContainerOverrideFields(workspace *common.DevWorkspaceWithConf return nil } -func GetRestrictedPodOverrideFields(workspace *common.DevWorkspaceWithConfig) []string { +func GetRestrictedPodFields(workspace *common.DevWorkspaceWithConfig) []string { if workspace.Config != nil && workspace.Config.Workspace != nil && workspace.Config.Workspace.Overrides != nil { return workspace.Config.Workspace.Overrides.RestrictedPodOverrideFields } diff --git a/pkg/library/overrides/testdata/container-overrides/container-cannot-set-restricted-fields.yaml b/pkg/library/overrides/testdata/container-overrides/container-cannot-set-restricted-fields.yaml index e310350d0..5b3f1f77b 100644 --- a/pkg/library/overrides/testdata/container-overrides/container-cannot-set-restricted-fields.yaml +++ b/pkg/library/overrides/testdata/container-overrides/container-cannot-set-restricted-fields.yaml @@ -36,4 +36,4 @@ input: output: - errRegexp: "cannot use container-overrides to override container image" + errRegexp: "restricted container field set image" diff --git a/pkg/provision/storage/cleanup.go b/pkg/provision/storage/cleanup.go index 111b48d7d..aaed82243 100644 --- a/pkg/provision/storage/cleanup.go +++ b/pkg/provision/storage/cleanup.go @@ -1,5 +1,5 @@ // -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 Red Hat, Inc. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/provision/storage/commonStorage.go b/pkg/provision/storage/commonStorage.go index 98bcfc021..8c1e98427 100644 --- a/pkg/provision/storage/commonStorage.go +++ b/pkg/provision/storage/commonStorage.go @@ -22,6 +22,7 @@ import ( dw "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" "github.com/devfile/devworkspace-operator/pkg/common" "github.com/devfile/devworkspace-operator/pkg/dwerrors" + "github.com/devfile/devworkspace-operator/pkg/library/overrides/restrictions" "github.com/devfile/devworkspace-operator/pkg/provision/sync" corev1 "k8s.io/api/core/v1" @@ -86,7 +87,7 @@ func (p *CommonStorageProvisioner) ProvisionStorage(podAdditions *v1alpha1.PodAd pvcName, podAdditions, &workspace.Spec.Template, - overrides.GetRestrictedPodOverrideFields(workspace), + restrictions.GetRestrictedPodFields(workspace), ); err != nil { return &dwerrors.FailError{ Err: err, diff --git a/pkg/provision/storage/perWorkspaceStorage.go b/pkg/provision/storage/perWorkspaceStorage.go index d58aea8aa..bde796b68 100644 --- a/pkg/provision/storage/perWorkspaceStorage.go +++ b/pkg/provision/storage/perWorkspaceStorage.go @@ -26,6 +26,7 @@ import ( "github.com/devfile/devworkspace-operator/pkg/dwerrors" devfileConstants "github.com/devfile/devworkspace-operator/pkg/library/constants" "github.com/devfile/devworkspace-operator/pkg/library/overrides" + "github.com/devfile/devworkspace-operator/pkg/library/overrides/restrictions" nsconfig "github.com/devfile/devworkspace-operator/pkg/provision/config" "github.com/devfile/devworkspace-operator/pkg/provision/sync" corev1 "k8s.io/api/core/v1" @@ -77,7 +78,7 @@ func (p *PerWorkspaceStorageProvisioner) ProvisionStorage(podAdditions *v1alpha1 pvcName, podAdditions, &workspace.Spec.Template, - overrides.GetRestrictedPodOverrideFields(workspace), + restrictions.GetRestrictedPodFields(workspace), ); err != nil { return &dwerrors.FailError{ Err: err, diff --git a/pkg/provision/storage/shared.go b/pkg/provision/storage/shared.go index f6000c684..0982fb94c 100644 --- a/pkg/provision/storage/shared.go +++ b/pkg/provision/storage/shared.go @@ -1,5 +1,5 @@ // -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 Red Hat, Inc. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/provision/storage/shared_test.go b/pkg/provision/storage/shared_test.go index 00a9b20cc..2d2b7ef50 100644 --- a/pkg/provision/storage/shared_test.go +++ b/pkg/provision/storage/shared_test.go @@ -1,5 +1,5 @@ // -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 Red Hat, Inc. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/provision/workspace/rbac/common_test.go b/pkg/provision/workspace/rbac/common_test.go index 874f963d2..1d6aec379 100644 --- a/pkg/provision/workspace/rbac/common_test.go +++ b/pkg/provision/workspace/rbac/common_test.go @@ -1,4 +1,4 @@ -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 Red Hat, Inc. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/provision/workspace/rbac/role.go b/pkg/provision/workspace/rbac/role.go index 66c49c4c5..0a50c77d1 100644 --- a/pkg/provision/workspace/rbac/role.go +++ b/pkg/provision/workspace/rbac/role.go @@ -36,6 +36,17 @@ func syncRoles(workspace *common.DevWorkspaceWithConfig, api sync.ClusterAPI) er return nil } sccName := workspace.Spec.Template.Attributes.GetString(constants.WorkspaceSCCAttribute, nil) + + // For backward compatibility, skip the validation when the annotation is absent, as it + // may not be present on workspaces created before this check was introduced. + if validatedSCCName, ok := workspace.Annotations[constants.DevWorkspaceValidatedSCCAnnotation]; ok { + if validatedSCCName != sccName { + return &dwerrors.FailError{ + Message: fmt.Sprintf("user is not authorized to use SecurityContextConstraints '%s'", sccName), + } + } + } + sccRole := generateUseRoleForSCC(workspace.Namespace, sccName) if _, err := sync.SyncObjectWithCluster(sccRole, api); err != nil { return dwerrors.WrapSyncError(err) diff --git a/pkg/provision/workspace/rbac/role_test.go b/pkg/provision/workspace/rbac/role_test.go index 1e805c0ea..002a36d83 100644 --- a/pkg/provision/workspace/rbac/role_test.go +++ b/pkg/provision/workspace/rbac/role_test.go @@ -1,4 +1,4 @@ -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 Red Hat, Inc. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -65,6 +65,44 @@ func TestDoesNothingIfRoleAlreadyInSync(t *testing.T) { assert.NoError(t, err, "Should not return error if role is in sync") } +func TestRejectsSCCRoleWhenAnnotationMismatches(t *testing.T) { + infrastructure.InitializeForTesting(infrastructure.OpenShiftv4) + testdw := getTestDevWorkspaceWithAttributes(t, "test-devworkspace", constants.WorkspaceSCCAttribute, testSCCName) + testdw.Annotations = map[string]string{ + constants.DevWorkspaceValidatedSCCAnnotation: "different-scc", + } + api := getTestClusterAPI(t, testdw.DevWorkspace) + retryErr := &dwerrors.RetryError{} + err := syncRoles(testdw, api) + if assert.Error(t, err, "Should return RetryError to indicate that default role was created") { + assert.ErrorAs(t, err, &retryErr, "Error should have RetryError type") + } + err = syncRoles(testdw, api) + assert.Error(t, err, "Should reject SCC role when annotation does not match attribute") + failErr := &dwerrors.FailError{} + assert.ErrorAs(t, err, &failErr, "Error should have FailError type") + assert.Contains(t, err.Error(), "not authorized") +} + +func TestRejectsSCCRoleWhenAnnotationEmpty(t *testing.T) { + infrastructure.InitializeForTesting(infrastructure.OpenShiftv4) + testdw := getTestDevWorkspaceWithAttributes(t, "test-devworkspace", constants.WorkspaceSCCAttribute, testSCCName) + testdw.Annotations = map[string]string{ + constants.DevWorkspaceValidatedSCCAnnotation: "", + } + api := getTestClusterAPI(t, testdw.DevWorkspace) + retryErr := &dwerrors.RetryError{} + err := syncRoles(testdw, api) + if assert.Error(t, err, "Should return RetryError to indicate that default role was created") { + assert.ErrorAs(t, err, &retryErr, "Error should have RetryError type") + } + err = syncRoles(testdw, api) + assert.Error(t, err, "Should reject SCC role when annotation empty") + failErr := &dwerrors.FailError{} + assert.ErrorAs(t, err, &failErr, "Error should have FailError type") + assert.Contains(t, err.Error(), "not authorized") +} + func TestCreatesSCCRoleIfNotExists(t *testing.T) { infrastructure.InitializeForTesting(infrastructure.OpenShiftv4) testdw := getTestDevWorkspaceWithAttributes(t, "test-devworkspace", constants.WorkspaceSCCAttribute, testSCCName) diff --git a/pkg/webhook/cluster_roles.go b/pkg/webhook/cluster_roles.go index cce26efae..5e07b5688 100755 --- a/pkg/webhook/cluster_roles.go +++ b/pkg/webhook/cluster_roles.go @@ -1,5 +1,5 @@ // -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 Red Hat, Inc. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -126,6 +126,58 @@ func getSpecClusterRole() (*v1.ClusterRole, error) { "create", }, }, + { + APIGroups: []string{ + "controller.devfile.io", + }, + Resources: []string{ + "devworkspaceoperatorconfigs", + }, + Verbs: []string{ + "get", + "list", + "watch", + }, + }, + { + APIGroups: []string{ + "workspace.devfile.io", + }, + Resources: []string{ + "devworkspacetemplates", + }, + Verbs: []string{ + "get", + "list", + "watch", + }, + }, + { + APIGroups: []string{ + "", + }, + Resources: []string{ + "configmaps", + }, + Verbs: []string{ + "get", + "list", + }, + }, + { + APIGroups: []string{ + "config.openshift.io", + }, + Resources: []string{ + "proxies", + }, + ResourceNames: []string{ + "cluster", + }, + Verbs: []string{ + "get", + }, + }, }, } diff --git a/pkg/webhook/create.go b/pkg/webhook/create.go index 02c4d0587..fc871dfd9 100644 --- a/pkg/webhook/create.go +++ b/pkg/webhook/create.go @@ -109,5 +109,20 @@ func setUpWebhookServerRBAC(ctx context.Context, err error, client crclient.Clie if err != nil { return err } + + // Set up the namespace-scoped role and role binding (e.g. routes on OpenShift) + log.Info("Setting up the webhook server role") + err = CreateWebhookRole(client, ctx, namespace) + if err != nil { + return err + } + + // Set up the namespace-scoped role binding + log.Info("Setting up the webhook server role binding") + err = CreateWebhookRoleBinding(client, ctx, namespace) + if err != nil { + return err + } + return nil } diff --git a/pkg/webhook/role_bindings.go b/pkg/webhook/role_bindings.go new file mode 100644 index 000000000..7cec292d9 --- /dev/null +++ b/pkg/webhook/role_bindings.go @@ -0,0 +1,76 @@ +// +// Copyright (c) 2019-2026 Red Hat, Inc. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package webhook + +import ( + "context" + + "github.com/devfile/devworkspace-operator/pkg/infrastructure" + "github.com/devfile/devworkspace-operator/webhook/server" + v1 "k8s.io/api/rbac/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + crclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +func CreateWebhookRoleBinding(client crclient.Client, ctx context.Context, namespace string) error { + if !infrastructure.IsOpenShift() { + return nil + } + + roleBinding := getSpecRoleBinding(namespace) + if err := client.Create(ctx, roleBinding); err != nil { + if !apierrors.IsAlreadyExists(err) { + return err + } + existingRoleBinding := &v1.RoleBinding{} + if err := client.Get(ctx, types.NamespacedName{Name: roleBinding.Name, Namespace: namespace}, existingRoleBinding); err != nil { + return err + } + roleBinding.ResourceVersion = existingRoleBinding.ResourceVersion + if err := client.Update(ctx, roleBinding); err != nil { + return err + } + log.Info("Updated webhook server role binding") + } else { + log.Info("Created webhook server role binding") + } + + return nil +} + +func getSpecRoleBinding(namespace string) *v1.RoleBinding { + return &v1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: server.WebhookServerSAName, + Namespace: namespace, + Labels: server.WebhookServerAppLabels(), + }, + Subjects: []v1.Subject{ + { + Kind: "ServiceAccount", + Name: server.WebhookServerSAName, + Namespace: namespace, + }, + }, + RoleRef: v1.RoleRef{ + Kind: "Role", + Name: server.WebhookServerSAName, + APIGroup: "rbac.authorization.k8s.io", + }, + } +} diff --git a/pkg/webhook/roles.go b/pkg/webhook/roles.go new file mode 100644 index 000000000..80b81feff --- /dev/null +++ b/pkg/webhook/roles.go @@ -0,0 +1,79 @@ +// +// Copyright (c) 2019-2026 Red Hat, Inc. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package webhook + +import ( + "context" + + "github.com/devfile/devworkspace-operator/pkg/infrastructure" + "github.com/devfile/devworkspace-operator/webhook/server" + v1 "k8s.io/api/rbac/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + crclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +func CreateWebhookRole(client crclient.Client, ctx context.Context, namespace string) error { + if !infrastructure.IsOpenShift() { + return nil + } + + role := getSpecRole(namespace) + if err := client.Create(ctx, role); err != nil { + if !apierrors.IsAlreadyExists(err) { + return err + } + existingRole := &v1.Role{} + if err := client.Get(ctx, types.NamespacedName{Name: role.Name, Namespace: namespace}, existingRole); err != nil { + return err + } + role.ResourceVersion = existingRole.ResourceVersion + if err := client.Update(ctx, role); err != nil { + return err + } + log.Info("Updated webhook server role") + } else { + log.Info("Created webhook server role") + } + + return nil +} + +func getSpecRole(namespace string) *v1.Role { + return &v1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: server.WebhookServerSAName, + Namespace: namespace, + Labels: server.WebhookServerAppLabels(), + }, + Rules: []v1.PolicyRule{ + { + APIGroups: []string{ + "route.openshift.io", + }, + Resources: []string{ + "routes", + }, + Verbs: []string{ + "create", + "get", + "delete", + }, + }, + }, + } +} diff --git a/version/version.go b/version/version.go index 88f5cab7a..940e90d51 100644 --- a/version/version.go +++ b/version/version.go @@ -1,5 +1,5 @@ // -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 Red Hat, Inc. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/webhook/main.go b/webhook/main.go index 6dd976f54..3b283383c 100644 --- a/webhook/main.go +++ b/webhook/main.go @@ -1,5 +1,5 @@ // -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 Red Hat, Inc. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -24,7 +24,18 @@ import ( "runtime" "syscall" + controllerv1alpha1 "github.com/devfile/devworkspace-operator/apis/controller/v1alpha1" + "github.com/devfile/devworkspace-operator/pkg/httpfactory" + kubesync "github.com/devfile/devworkspace-operator/pkg/library/kubernetes" + configv1 "github.com/openshift/api/config/v1" + routev1 "github.com/openshift/api/route/v1" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/metrics/filters" + "sigs.k8s.io/controller-runtime/pkg/reconcile" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" "sigs.k8s.io/controller-runtime/pkg/webhook" @@ -64,8 +75,14 @@ func init() { } utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(controllerv1alpha1.AddToScheme(scheme)) utilruntime.Must(dwv1.AddToScheme(scheme)) utilruntime.Must(dwv2.AddToScheme(scheme)) + + if infrastructure.IsOpenShift() { + utilruntime.Must(routev1.Install(scheme)) + utilruntime.Must(configv1.Install(scheme)) + } } func main() { @@ -82,6 +99,11 @@ func main() { log.Info(fmt.Sprintf("Commit: %s", version.Commit)) log.Info(fmt.Sprintf("BuildTime: %s", version.BuildTime)) + if err := kubesync.InitializeDeserializer(scheme); err != nil { + log.Error(err, "Failed to initialize kubernetes object deserializer") + os.Exit(1) + } + // Get a config to talk to the apiserver cfg, err := clientconfig.GetConfig() if err != nil { @@ -124,6 +146,30 @@ func main() { os.Exit(1) } + nonCachedClient, err := client.New(mgr.GetConfig(), client.Options{Scheme: mgr.GetScheme()}) + if err != nil { + log.Error(err, "Failed to setup nonCachedClient") + os.Exit(1) + } + + err = config.SetupControllerConfig(nonCachedClient) + if err != nil { + log.Error(err, "Failed to setup Controller Config") + os.Exit(1) + } + + err = httpfactory.SetupHttpClientsFactory(mgr.GetClient(), mgr.GetLogger()) + if err != nil { + log.Error(err, "Failed to setup Http clients factory") + os.Exit(1) + } + + err = setupConfigWatcher(mgr) + if err != nil { + log.Error(err, "Failed to setup config watcher") + os.Exit(1) + } + err = createWebhooks(mgr) if err != nil { log.Error(err, "Failed to create webhooks") @@ -165,3 +211,22 @@ func createWebhooks(mgr manager.Manager) error { } return nil } + +func setupConfigWatcher(mgr ctrl.Manager) error { + emptyMapper := func(ctx context.Context, obj client.Object) []reconcile.Request { + return []reconcile.Request{} + } + + // Do nothing, just for keeping DOWC up to date + return ctrl.NewControllerManagedBy(mgr). + Named("dwoc-config-watcher"). + WithOptions(controller.Options{ + UsePriorityQueue: ptr.To(false), + }). + Watches(&controllerv1alpha1.DevWorkspaceOperatorConfig{}, + handler.EnqueueRequestsFromMapFunc(emptyMapper), + builder.WithPredicates(config.Predicates())). + Complete(reconcile.Func(func(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { + return reconcile.Result{}, nil + })) +} diff --git a/webhook/workspace/handler/access_control.go b/webhook/workspace/handler/access_control.go index b8f7a7e1f..216367349 100644 --- a/webhook/workspace/handler/access_control.go +++ b/webhook/workspace/handler/access_control.go @@ -1,4 +1,4 @@ -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 Red Hat, Inc. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -29,39 +29,60 @@ import ( // In case we're validating a workspace on creation, parameter oldWksp should be set to nil. Returns an error if the user // cannot perform the requested changes, or if an unexpected error occurs. // Note: we only perform validation on v1alpha2 DevWorkspaces at the moment, as v1alpha1 DevWorkspaces do not support attributes. -func (h *WebhookHandler) validateUserPermissions(ctx context.Context, req admission.Request, newWksp, oldWksp *dwv2.DevWorkspace) error { - if !newWksp.Spec.Template.Attributes.Exists(constants.WorkspaceSCCAttribute) { +func (h *WebhookHandler) validateUserPermissions( + ctx context.Context, + req admission.Request, + newWksp *dwv2.DevWorkspaceTemplateSpec, + oldWksp *dwv2.DevWorkspace, +) (string, error) { + // Passing the unresolved oldWorkspace is sufficient here. The validation check prefers the + // `controller.devfile.io/validated-scc` annotation (which already reflects the resolved/flattened + // spec from the previous webhook call) and only falls back to the raw SCC attribute for backward + // compatibility with workspaces created before the annotation was introduced. + + if !newWksp.Attributes.Exists(constants.WorkspaceSCCAttribute) { // Workspace is not requesting anything we need to check RBAC for. - return nil + return "", nil } var attributeDecodeErr error - newSCCAttr := newWksp.Spec.Template.Attributes.GetString(constants.WorkspaceSCCAttribute, &attributeDecodeErr) + newSCCAttr := newWksp.Attributes.GetString(constants.WorkspaceSCCAttribute, &attributeDecodeErr) if attributeDecodeErr != nil { - return fmt.Errorf("failed to read %s attribute in DevWorkspace: %s", constants.WorkspaceSCCAttribute, attributeDecodeErr) + return "", fmt.Errorf("failed to read %s attribute in DevWorkspace: %s", constants.WorkspaceSCCAttribute, attributeDecodeErr) } - if oldWksp != nil && oldWksp.Spec.Template.Attributes.Exists(constants.WorkspaceSCCAttribute) { - // If we're updating a DevWorkspace, check RBAC only if the relevant attribute is modified to avoid performing too many SARs. - oldSCCAttr := oldWksp.Spec.Template.Attributes.GetString(constants.WorkspaceSCCAttribute, &attributeDecodeErr) - if attributeDecodeErr != nil { - return fmt.Errorf("failed to read %s attribute in DevWorkspace: %s", constants.WorkspaceSCCAttribute, attributeDecodeErr) - } - if oldSCCAttr == newSCCAttr { - // RBAC has already been checked for this setting, don't recheck - return nil + var oldSCCAttr string + if oldWksp != nil { + // Prefer the validated-scc annotation over the raw attribute because the annotation + // reflects the SCC from the fully resolved (flattened) DevWorkspace, which may differ + // from the unresolved spec (e.g. when the SCC is inherited from a parent). + oldSCCAttr = oldWksp.Annotations[constants.DevWorkspaceValidatedSCCAnnotation] + + if oldSCCAttr == "" && + oldWksp.Spec.Template.Attributes.Exists(constants.WorkspaceSCCAttribute) { + + oldSCCAttr = oldWksp.Spec.Template.Attributes.GetString(constants.WorkspaceSCCAttribute, &attributeDecodeErr) + if attributeDecodeErr != nil { + return "", fmt.Errorf("failed to read %s attribute in DevWorkspace: %s", constants.WorkspaceSCCAttribute, attributeDecodeErr) + } } - if oldSCCAttr != newSCCAttr { + + if oldSCCAttr != "" { + if oldSCCAttr == newSCCAttr { + // RBAC has already been checked for this setting, don't recheck + return newSCCAttr, nil + } + // Don't allow attribute to be changed once it is set, otherwise we can't clean up the SCC when the workspace is deleted. - return fmt.Errorf("%s attribute cannot be modified after being set -- workspace must be deleted", constants.WorkspaceSCCAttribute) + return "", fmt.Errorf("%s attribute cannot be modified after being set -- workspace must be deleted", constants.WorkspaceSCCAttribute) } } if err := h.validateOpenShiftSCC(ctx, req, newSCCAttr); err != nil { - return err + return "", err } - return nil + return newSCCAttr, nil } func (h *WebhookHandler) validateOpenShiftSCC(ctx context.Context, req admission.Request, scc string) error { diff --git a/webhook/workspace/handler/attributes.go b/webhook/workspace/handler/attributes.go index 9e4fa1fac..728a8b56e 100644 --- a/webhook/workspace/handler/attributes.go +++ b/webhook/workspace/handler/attributes.go @@ -1,5 +1,5 @@ // -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 Red Hat, Inc. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -29,7 +29,7 @@ import ( // If only a single container component has the controller.devfile.io/merge-contribution attribute set to true, nil is returned. // If multiple container component have the controller.devfile.io/merge-contribution attribute set to true, or an error occurs // while parsing the attribute, an error is returned. -func checkMultipleContainerContributionTargets(devWorkspaceSpec dwv2.DevWorkspaceTemplateSpec) error { +func checkMultipleContainerContributionTargets(devWorkspaceSpec *dwv2.DevWorkspaceTemplateSpec) error { var componentNames []string for _, component := range devWorkspaceSpec.Components { if component.Container == nil { diff --git a/webhook/workspace/handler/kubernetes.go b/webhook/workspace/handler/kubernetes.go index 784c6a52a..d239389c4 100644 --- a/webhook/workspace/handler/kubernetes.go +++ b/webhook/workspace/handler/kubernetes.go @@ -1,4 +1,4 @@ -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 Red Hat, Inc. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -16,6 +16,7 @@ package handler import ( "context" "fmt" + "slices" "strings" dwv1 "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha1" @@ -33,7 +34,13 @@ var ( userVerbs = []string{"get", "create", "update", "delete"} ) -func (h *WebhookHandler) validateKubernetesObjectPermissionsOnCreate(ctx context.Context, req admission.Request, wksp *dwv2.DevWorkspaceTemplateSpec) error { +func (h *WebhookHandler) validateKubernetesObjectPermissions( + ctx context.Context, + req admission.Request, + wksp *dwv2.DevWorkspaceTemplateSpec, +) ([]string, error) { + var validatedKubernetesResources []string + kubeComponents := getKubeComponentsFromWorkspace(wksp) for componentName, component := range kubeComponents { if !component.GetDeployByDefault() { @@ -46,56 +53,39 @@ func (h *WebhookHandler) validateKubernetesObjectPermissionsOnCreate(ctx context continue } if component.Inlined == "" { - return fmt.Errorf("kubernetes component does not define inlined content") + return nil, fmt.Errorf("kubernetes component does not define inlined content") } - if err := h.validatePermissionsOnObject(ctx, req, componentName, component.Inlined); err != nil { - return err - } - } - return nil -} -func (h *WebhookHandler) validateKubernetesObjectPermissionsOnUpdate(ctx context.Context, req admission.Request, newWksp, oldWksp *dwv2.DevWorkspaceTemplateSpec) error { - newKubeComponents := getKubeComponentsFromWorkspace(newWksp) - oldKubeComponents := getKubeComponentsFromWorkspace(oldWksp) - - for componentName, newComponent := range newKubeComponents { - if !newComponent.GetDeployByDefault() { - // Intended to be applied later, will not be handled by DWO. It's up to whoever applies it to make - // sure that's safe to do (e.g. by using the user's token to apply the yaml) - continue + typeMeta := &metav1.TypeMeta{} + if err := yaml.Unmarshal([]byte(component.Inlined), typeMeta); err != nil { + return nil, fmt.Errorf("failed to read content for component %s", componentName) } - if newComponent.Uri != "" { - // We're going to ignore URI components for now - continue - } - if newComponent.Inlined == "" { - return fmt.Errorf("kubernetes component does not define inlined content") + if err := h.validatePermissionsOnObject(ctx, req, componentName, typeMeta); err != nil { + return nil, err } - oldComponent, ok := oldKubeComponents[componentName] - if !ok || oldComponent.Inlined != newComponent.Inlined { - // Review new components - if err := h.validatePermissionsOnObject(ctx, req, componentName, newComponent.Inlined); err != nil { - return err - } + gvk := typeMeta.GroupVersionKind().String() + if !slices.Contains(validatedKubernetesResources, gvk) { + validatedKubernetesResources = append(validatedKubernetesResources, gvk) } } - return nil -} -func (h *WebhookHandler) validatePermissionsOnObject(ctx context.Context, req admission.Request, componentName, component string) error { + return validatedKubernetesResources, nil +} - typeMeta := &metav1.TypeMeta{} - if err := yaml.Unmarshal([]byte(component), typeMeta); err != nil { - return fmt.Errorf("failed to read content for component %s", componentName) - } +func (h *WebhookHandler) validatePermissionsOnObject( + ctx context.Context, + req admission.Request, + componentName string, + typeMeta *metav1.TypeMeta, +) error { kind := typeMeta.Kind + if kind == "List" { return fmt.Errorf("lists are not supported in Kubernetes or OpenShift components") } - if kind == "Role" || kind == "Rolebinding" || kind == "ClusterRole" || kind == "ClusterRoleBinding" { + if kind == "Role" || kind == "RoleBinding" || kind == "ClusterRole" || kind == "ClusterRoleBinding" { return fmt.Errorf("kubernetes RBAC objects are not permitted within DevWorkspace components") } if kind == "DevWorkspace" || kind == "DevWorkspaceTemplate" { @@ -170,7 +160,7 @@ func getKubeLikeComponent(component *dwv2.Component) (*dwv2.K8sLikeComponent, er return nil, fmt.Errorf("component does not specify kubernetes or openshift fields") } -func (h *WebhookHandler) validateKubernetesObjectPermissionsOnCreate_v1alpha1(ctx context.Context, req admission.Request, wksp *dwv1.DevWorkspaceTemplateSpec) error { +func (h *WebhookHandler) validateKubernetesObjectPermissions_v1alpha1(ctx context.Context, req admission.Request, wksp *dwv1.DevWorkspaceTemplateSpec) error { kubeComponents := getKubeComponentsFromWorkspace_v1alpha1(wksp) for componentName, component := range kubeComponents { if component.Uri != "" { @@ -182,34 +172,13 @@ func (h *WebhookHandler) validateKubernetesObjectPermissionsOnCreate_v1alpha1(ct // v1alpha1 DevWorkspace/DevWorkspaceTemplates do not have a deployByDefault field, and the default // value in v1alpha2 is false (i.e. do not deploy at start time); however, for safety we check permissions // even if the object will not be deployed (v1alpha1 should not be used, in general) - if err := h.validatePermissionsOnObject(ctx, req, componentName, component.Inlined); err != nil { - return err + typeMeta := &metav1.TypeMeta{} + if err := yaml.Unmarshal([]byte(component.Inlined), typeMeta); err != nil { + return fmt.Errorf("failed to read content for component %s", componentName) } - } - return nil -} -func (h *WebhookHandler) validateKubernetesObjectPermissionsOnUpdate_v1alpha1(ctx context.Context, req admission.Request, newWksp, oldWksp *dwv1.DevWorkspaceTemplateSpec) error { - newKubeComponents := getKubeComponentsFromWorkspace_v1alpha1(newWksp) - oldKubeComponents := getKubeComponentsFromWorkspace_v1alpha1(oldWksp) - - for componentName, newComponent := range newKubeComponents { - if newComponent.Uri != "" { - return fmt.Errorf("kubenetes components specified via URI are unsupported") - } - if newComponent.Inlined == "" { - return fmt.Errorf("kubernetes component does not define inlined content") - } - - // v1alpha1 DevWorkspace/DevWorkspaceTemplates do not have a deployByDefault field, and the default - // value in v1alpha2 is false (i.e. do not deploy at start time); however, for safety we check permissions - // even if the object will not be deployed (v1alpha1 should not be used, in general) - oldComponent, ok := oldKubeComponents[componentName] - if !ok || oldComponent.Inlined != newComponent.Inlined { - // Review new components - if err := h.validatePermissionsOnObject(ctx, req, componentName, newComponent.Inlined); err != nil { - return err - } + if err := h.validatePermissionsOnObject(ctx, req, componentName, typeMeta); err != nil { + return err } } return nil diff --git a/webhook/workspace/handler/template.go b/webhook/workspace/handler/template.go deleted file mode 100644 index e0793f8a6..000000000 --- a/webhook/workspace/handler/template.go +++ /dev/null @@ -1,101 +0,0 @@ -// Copyright (c) 2019-2025 Red Hat, Inc. -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package handler - -import ( - "context" - "fmt" - "net/http" - - dwv1 "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha1" - dwv2 "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" - "github.com/devfile/devworkspace-operator/pkg/constants" - "sigs.k8s.io/controller-runtime/pkg/webhook/admission" -) - -func (h *WebhookHandler) MutateWorkspaceTemplateV1alpha1OnCreate(ctx context.Context, req admission.Request) admission.Response { - wksp := &dwv1.DevWorkspaceTemplate{} - err := h.Decoder.Decode(req, wksp) - if err != nil { - return admission.Errored(http.StatusBadRequest, err) - } - - if err := h.validateKubernetesObjectPermissionsOnCreate_v1alpha1(ctx, req, &wksp.Spec); err != nil { - return admission.Denied(err.Error()) - } - - return h.returnPatched(req, wksp) -} - -func (h *WebhookHandler) MutateWorkspaceTemplateV1alpha2OnCreate(ctx context.Context, req admission.Request) admission.Response { - wksp := &dwv2.DevWorkspaceTemplate{} - err := h.Decoder.Decode(req, wksp) - if err != nil { - return admission.Errored(http.StatusBadRequest, err) - } - - if err := h.validateKubernetesObjectPermissionsOnCreate(ctx, req, &wksp.Spec); err != nil { - return admission.Denied(err.Error()) - } - - return h.returnPatched(req, wksp) -} - -func (h *WebhookHandler) MutateWorkspaceTemplateV1alpha1OnUpdate(ctx context.Context, req admission.Request) admission.Response { - newWksp := &dwv1.DevWorkspaceTemplate{} - oldWksp := &dwv1.DevWorkspaceTemplate{} - err := h.parse(req, oldWksp, newWksp) - if err != nil { - return admission.Errored(http.StatusBadRequest, err) - } - - if err := h.validateKubernetesObjectPermissionsOnUpdate_v1alpha1(ctx, req, &newWksp.Spec, &oldWksp.Spec); err != nil { - return admission.Denied(err.Error()) - } - - oldCreator, found := oldWksp.Labels[constants.DevWorkspaceCreatorLabel] - if !found { - return admission.Denied(fmt.Sprintf("label '%s' is missing. Please recreate devworkspace to get it initialized", constants.DevWorkspaceCreatorLabel)) - } - - newCreator, found := newWksp.Labels[constants.DevWorkspaceCreatorLabel] - if !found { - if newWksp.Labels == nil { - newWksp.Labels = map[string]string{} - } - newWksp.Labels[constants.DevWorkspaceCreatorLabel] = oldCreator - return h.returnPatched(req, newWksp) - } - - if newCreator != oldCreator { - return admission.Denied(fmt.Sprintf("label '%s' is assigned once devworkspace is created and is immutable", constants.DevWorkspaceCreatorLabel)) - } - - return admission.Allowed("new devworkspace has the same devworkspace creator as old one") -} - -func (h *WebhookHandler) MutateWorkspaceTemplateV1alpha2OnUpdate(ctx context.Context, req admission.Request) admission.Response { - newWksp := &dwv2.DevWorkspaceTemplate{} - oldWksp := &dwv2.DevWorkspaceTemplate{} - err := h.parse(req, oldWksp, newWksp) - if err != nil { - return admission.Errored(http.StatusBadRequest, err) - } - - if err := h.validateKubernetesObjectPermissionsOnUpdate(ctx, req, &newWksp.Spec, &oldWksp.Spec); err != nil { - return admission.Denied(err.Error()) - } - - return admission.Allowed("new workspace has the same devworkspace as old one") -} diff --git a/webhook/workspace/handler/workspace.go b/webhook/workspace/handler/workspace.go index 96a567da6..7e71b3493 100644 --- a/webhook/workspace/handler/workspace.go +++ b/webhook/workspace/handler/workspace.go @@ -1,5 +1,5 @@ // -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 Red Hat, Inc. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -17,14 +17,22 @@ package handler import ( "context" + "encoding/json" "fmt" "net/http" - - maputils "github.com/devfile/devworkspace-operator/internal/map" - "github.com/devfile/devworkspace-operator/pkg/constants" + "sort" dwv1 "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha1" dwv2 "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" + maputils "github.com/devfile/devworkspace-operator/internal/map" + "github.com/devfile/devworkspace-operator/pkg/common" + "github.com/devfile/devworkspace-operator/pkg/config" + "github.com/devfile/devworkspace-operator/pkg/constants" + "github.com/devfile/devworkspace-operator/pkg/httpfactory" + "github.com/devfile/devworkspace-operator/pkg/infrastructure" + wsDefaults "github.com/devfile/devworkspace-operator/pkg/library/defaults" + "github.com/devfile/devworkspace-operator/pkg/library/flatten" + "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" ) @@ -38,7 +46,7 @@ func (h *WebhookHandler) MutateWorkspaceV1alpha1OnCreate(ctx context.Context, re wksp.Labels = maputils.Append(wksp.Labels, constants.DevWorkspaceCreatorLabel, req.UserInfo.UID) - if err := h.validateKubernetesObjectPermissionsOnCreate_v1alpha1(ctx, req, &wksp.Spec.Template); err != nil { + if err := h.validateKubernetesObjectPermissions_v1alpha1(ctx, req, &wksp.Spec.Template); err != nil { return admission.Denied(err.Error()) } @@ -54,15 +62,11 @@ func (h *WebhookHandler) MutateWorkspaceV1alpha2OnCreate(ctx context.Context, re wksp.Labels = maputils.Append(wksp.Labels, constants.DevWorkspaceCreatorLabel, req.UserInfo.UID) - if err := h.validateUserPermissions(ctx, req, wksp, nil); err != nil { - return admission.Denied(err.Error()) - } - - if err := h.validateKubernetesObjectPermissionsOnCreate(ctx, req, &wksp.Spec.Template); err != nil { - return admission.Denied(err.Error()) - } - - if err := checkMultipleContainerContributionTargets(wksp.Spec.Template); err != nil { + _, code, err := h.ValidateWorkspaceV1alpha2Permissions(ctx, wksp, nil, req) + if err != nil { + if code != nil { + return admission.Errored(*code, err) + } return admission.Denied(err.Error()) } @@ -90,7 +94,7 @@ func (h *WebhookHandler) MutateWorkspaceV1alpha1OnUpdate(ctx context.Context, re return admission.Denied(msg) } - if err := h.validateKubernetesObjectPermissionsOnUpdate_v1alpha1(ctx, req, &newWksp.Spec.Template, &oldWksp.Spec.Template); err != nil { + if err := h.validateKubernetesObjectPermissions_v1alpha1(ctx, req, &newWksp.Spec.Template); err != nil { return admission.Denied(err.Error()) } @@ -166,15 +170,11 @@ func (h *WebhookHandler) MutateWorkspaceV1alpha2OnUpdate(ctx context.Context, re return admission.Denied(msg) } - if err := h.validateUserPermissions(ctx, req, newWksp, oldWksp); err != nil { - return admission.Denied(err.Error()) - } - - if err := h.validateKubernetesObjectPermissionsOnUpdate(ctx, req, &newWksp.Spec.Template, &oldWksp.Spec.Template); err != nil { - return admission.Denied(err.Error()) - } - - if err := checkMultipleContainerContributionTargets(newWksp.Spec.Template); err != nil { + changed, code, err := h.ValidateWorkspaceV1alpha2Permissions(ctx, newWksp, oldWksp, req) + if err != nil { + if code != nil { + return admission.Errored(*code, err) + } return admission.Denied(err.Error()) } @@ -200,12 +200,142 @@ func (h *WebhookHandler) MutateWorkspaceV1alpha2OnUpdate(ctx context.Context, re return admission.Denied(fmt.Sprintf("label '%s' is assigned once devworkspace is created and is immutable", constants.DevWorkspaceCreatorLabel)) } + if changed { + response := h.returnPatched(req, newWksp) + if warnings != "" { + response = response.WithWarnings(warnings) + } + return response + } + if warnings != "" { return admission.Allowed("").WithWarnings(warnings) } return admission.Allowed("new workspace has the same devworkspace as old one") } +func (h *WebhookHandler) ValidateWorkspaceV1alpha2Permissions( + ctx context.Context, + newWorkspace *dwv2.DevWorkspace, + oldWorkspace *dwv2.DevWorkspace, + req admission.Request, +) (bool, *int32, error) { + newWorkspaceConfig, err := config.ResolveConfigForWorkspace(newWorkspace, h.Client) + if err != nil { + // When started=true, resolution must succeed — the controller will attempt to start + // the workspace, so reject early if the spec can't be fully resolved. + if newWorkspace.Spec.Started { + return false, ptr.To(int32(http.StatusBadRequest)), err + } + + newWorkspaceConfig = config.GetGlobalConfig() + } + + newWorkspaceTemplate, err := h.resolveDevWorkspace( + ctx, + &common.DevWorkspaceWithConfig{ + DevWorkspace: newWorkspace, + Config: newWorkspaceConfig, + }, + ) + if err != nil { + // When started=true, resolution must succeed — the controller will attempt to start + // the workspace, so reject early if the spec can't be fully resolved. + if newWorkspace.Spec.Started { + return false, ptr.To(int32(http.StatusBadRequest)), err + } + + // Resolution can fail if parent/plugin templates don't exist yet; + newWorkspaceTemplate = &newWorkspace.Spec.Template + } + + validatedSCC, err := h.validateUserPermissions(ctx, req, newWorkspaceTemplate, oldWorkspace) + if err != nil { + return false, nil, err + } + + // Always re-validate against the new spec, even on updates: the user's RBAC + // permissions may have been revoked since the last webhook call, so previously + // validated resource types cannot be assumed to still be allowed. + validatedKubernetesResources, err := h.validateKubernetesObjectPermissions(ctx, req, newWorkspaceTemplate) + if err != nil { + return false, nil, err + } + + if err := checkMultipleContainerContributionTargets(newWorkspaceTemplate); err != nil { + return false, nil, err + } + + changed, err := setValidatedPermissionsAnnotations(newWorkspace, validatedSCC, validatedKubernetesResources) + if err != nil { + return false, ptr.To(int32(http.StatusInternalServerError)), err + } + + return changed, nil, nil +} + +func (h *WebhookHandler) resolveDevWorkspace( + ctx context.Context, + workspace *common.DevWorkspaceWithConfig, +) (*dwv2.DevWorkspaceTemplateSpec, error) { + // HttpFactory initialized in `webhook/main.go` + httpClient := httpfactory.HttpFactory.GetHttpClient(ctx, workspace.Config.Routing) + + flattenHelpers := flatten.ResolverTools{ + WorkspaceNamespace: workspace.Namespace, + Context: ctx, + K8sClient: h.Client, + HttpClient: httpClient, + DefaultResourceRequirements: workspace.Config.Workspace.DefaultContainerResources, + } + + if wsDefaults.NeedsDefaultTemplate(workspace) { + workspace = &common.DevWorkspaceWithConfig{ + // Make copy, don't change the original DevWorkspace object + DevWorkspace: workspace.DeepCopy(), + Config: workspace.Config, + } + wsDefaults.ApplyDefaultTemplate(workspace) + } + + flattenedWorkspace, _, err := flatten.ResolveDevWorkspace(&workspace.Spec.Template, workspace.Spec.Contributions, flattenHelpers) + if err != nil { + return nil, err + } + + return flattenedWorkspace, nil +} + +func setValidatedPermissionsAnnotations( + workspace *dwv2.DevWorkspace, + validatedSCC string, + validatedKubernetesResources []string, +) (bool, error) { + validatedKubernetesResourcesStr := "[]" + if len(validatedKubernetesResources) > 0 { + sort.Strings(validatedKubernetesResources) + + bytes, err := json.Marshal(validatedKubernetesResources) + if err != nil { + return false, fmt.Errorf("failed to marshal validated kubernetes resources: %w", err) + } + + validatedKubernetesResourcesStr = string(bytes) + } + + changed := false + if infrastructure.IsOpenShift() { + changed = workspace.Annotations[constants.DevWorkspaceValidatedSCCAnnotation] != validatedSCC + workspace.Annotations = maputils.Append(workspace.Annotations, constants.DevWorkspaceValidatedSCCAnnotation, validatedSCC) + } + + changed = changed || + workspace.Annotations[constants.DevWorkspaceValidatedK8sResourcesAnnotation] != validatedKubernetesResourcesStr + workspace.Annotations = maputils.Append(workspace.Annotations, constants.DevWorkspaceValidatedK8sResourcesAnnotation, validatedKubernetesResourcesStr) + + return changed, nil +} + func hasFinalizer(obj client.Object, finalizer string) bool { for _, f := range obj.GetFinalizers() { if f == finalizer {