diff --git a/docs/additional-configuration.adoc b/docs/additional-configuration.adoc index 9df10c1ba..ad181ecdf 100644 --- a/docs/additional-configuration.adoc +++ b/docs/additional-configuration.adoc @@ -341,6 +341,7 @@ The `controller.devfile.io/devworkspace-config` attribute takes two string field * `name`: the `metadata.name` of the alternate DevWorkspaceOperatorConfig. * `namespace`: the `metadata.namespace` of the alternate DevWorkspaceOperatorConfig. + Must be the DevWorkspace's own namespace or the namespace where the DevWorkspace Operator is installed. [source,yaml] ---- @@ -360,6 +361,11 @@ spec: merged with the default DevWorkspaceOperatorConfig, overriding fields in the default configuration. Fields unset in the overridden configuration will use the global values. +If the referenced DevWorkspaceOperatorConfig is not in the operator +namespace, pod-level fields (`podSecurityContext`, +`containerSecurityContext`, `serviceAccount`, `runtimeClassName`, +`defaultTemplate`, `podAnnotations`, `schedulerName`, `initContainers`, +`hostUsers`, `overrides`, `projectClone`, and `restore`) are ignored. ## Configuring cross-namespace DevWorkspaceTemplate imports diff --git a/docs/dwo-configuration.md b/docs/dwo-configuration.md index c20588afc..9aee88a67 100644 --- a/docs/dwo-configuration.md +++ b/docs/dwo-configuration.md @@ -42,10 +42,12 @@ spec: attributes: controller.devfile.io/devworkspace-config: name: - namespace: + namespace: ``` Configuration specified as above will be merged into the default global configuration, overriding any values present. +The referenced `DevWorkspaceOperatorConfig` must live in the DevWorkspace's namespace or the operator namespace. If it is not in the operator namespace, pod-level fields (`podSecurityContext`, `containerSecurityContext`, `serviceAccount`, `runtimeClassName`, `defaultTemplate`, `podAnnotations`, `schedulerName`, `initContainers`, `hostUsers`, `overrides`, `projectClone`, and `restore`) are ignored. + ## Configuring the Webhook deployment The `devworkspace-webhook-server` deployment can be configured in the global `DevWorkspaceOperatorConfig`. The configuration options include: diff --git a/pkg/config/sync.go b/pkg/config/sync.go index 8ff002135..d524b3200 100644 --- a/pkg/config/sync.go +++ b/pkg/config/sync.go @@ -61,6 +61,9 @@ func GetGlobalConfig() *controller.OperatorConfiguration { // If the `controller.devfile.io/devworkspace-config` is not set, the global DevWorkspaceOperatorConfig is returned. // If the `controller.devfile.io/devworkspace-config` attribute is incorrectly set, or the specified DevWorkspaceOperatorConfig // does not exist on the cluster, an error is returned. +// +// Referenced DWOCs are restricted to the DevWorkspace's namespace and the operator namespace. +// Pod-level fields from DWOCs outside the operator namespace are omitted before merge. func ResolveConfigForWorkspace(workspace *dw.DevWorkspace, client crclient.Client) (*controller.OperatorConfiguration, error) { if !workspace.Spec.Template.Attributes.Exists(constants.ExternalDevWorkspaceConfiguration) { return GetGlobalConfig(), nil @@ -80,12 +83,68 @@ func ResolveConfigForWorkspace(workspace *dw.DevWorkspace, client crclient.Clien return nil, fmt.Errorf("'namespace' must be set for attribute %s in DevWorkspace attributes", constants.ExternalDevWorkspaceConfiguration) } + if !isAllowedExternalConfigNamespace(namespacedName.Namespace, workspace.Namespace) { + return nil, fmt.Errorf("DevWorkspaceOperatorConfig %s/%s referenced by attribute %s must be in the DevWorkspace namespace %q or the operator namespace %q", + namespacedName.Namespace, namespacedName.Name, constants.ExternalDevWorkspaceConfiguration, workspace.Namespace, configNamespace) + } + externalDWOC := &controller.DevWorkspaceOperatorConfig{} err = client.Get(context.TODO(), namespacedName, externalDWOC) if err != nil { return nil, fmt.Errorf("could not fetch external DWOC with name %s in namespace %s: %w", namespacedName.Name, namespacedName.Namespace, err) } - return getMergedConfig(externalDWOC.Config, internalConfig), nil + + externalConfig := externalDWOC.Config + if !isOperatorNamespace(namespacedName.Namespace) { + externalConfig = omitOperatorOnlyWorkspaceFields(externalConfig) + } + return getMergedConfig(externalConfig, internalConfig), nil +} + +// isAllowedExternalConfigNamespace reports whether a referenced DWOC may be used +// for a workspace. Only the workspace's own namespace and the operator namespace +// are permitted. +func isAllowedExternalConfigNamespace(dwocNamespace, workspaceNamespace string) bool { + if dwocNamespace == "" { + return false + } + if workspaceNamespace != "" && dwocNamespace == workspaceNamespace { + return true + } + return isOperatorNamespace(dwocNamespace) +} + +func isOperatorNamespace(namespace string) bool { + return configNamespace != "" && namespace == configNamespace +} + +// omitOperatorOnlyWorkspaceFields returns a copy of config with pod-level fields +// removed. Workspace-namespace DWOCs may still override operational settings +// (storage, timeouts, imagePullPolicy, etc.). Fields such as security context, +// service account, scheduler, and default template are applied only from the +// operator namespace. +func omitOperatorOnlyWorkspaceFields(config *controller.OperatorConfiguration) *controller.OperatorConfiguration { + if config == nil { + return nil + } + sanitized := config.DeepCopy() + if sanitized.Workspace == nil { + return sanitized + } + ws := sanitized.Workspace + ws.PodSecurityContext = nil + ws.ContainerSecurityContext = nil + ws.ServiceAccount = nil + ws.RuntimeClassName = nil + ws.DefaultTemplate = nil + ws.PodAnnotations = nil + ws.SchedulerName = "" + ws.InitContainers = nil + ws.HostUsers = nil + ws.Overrides = nil + ws.ProjectCloneConfig = nil + ws.RestoreConfig = nil + return sanitized } func GetConfigForTesting(customConfig *controller.OperatorConfiguration) *controller.OperatorConfiguration { diff --git a/pkg/config/sync_test.go b/pkg/config/sync_test.go index c01cada4a..aa36a2e34 100644 --- a/pkg/config/sync_test.go +++ b/pkg/config/sync_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 @@ -133,10 +133,11 @@ func TestCatchesNonExistentExternalDWOC(t *testing.T) { setupForTest(t) workspace := &dw.DevWorkspace{} + workspace.Namespace = externalConfigNamespace attributes := attributes.Attributes{} namespacedName := types.NamespacedName{ Name: "external-config-name", - Namespace: "external-config-namespace", + Namespace: externalConfigNamespace, } attributes.Put(constants.ExternalDevWorkspaceConfiguration, namespacedName, nil) workspace.Spec.Template.DevWorkspaceTemplateSpecContent = dw.DevWorkspaceTemplateSpecContent{ @@ -155,6 +156,7 @@ func TestMergeExternalConfig(t *testing.T) { setupForTest(t) workspace := &dw.DevWorkspace{} + workspace.Namespace = externalConfigNamespace attributes := attributes.Attributes{} namespacedName := types.NamespacedName{ Name: externalConfigName, @@ -219,6 +221,161 @@ func TestMergeExternalConfig(t *testing.T) { } } +func TestRejectsExternalDWOCOutsideAllowedNamespaces(t *testing.T) { + setupForTest(t) + + workspace := workspaceReferencingExternalConfig(t, "workspace-ns", "other-ns-dwoc", "other-ns") + clusterConfig := buildConfig(defaultConfig.DeepCopy()) + unrelatedDWOC := &v1alpha1.DevWorkspaceOperatorConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "other-ns-dwoc", + Namespace: "other-ns", + }, + Config: &v1alpha1.OperatorConfiguration{ + Workspace: &v1alpha1.WorkspaceConfig{ + ImagePullPolicy: "Never", + }, + }, + } + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(clusterConfig, unrelatedDWOC).Build() + err := SetupControllerConfig(client) + if !assert.NoError(t, err, "Should not return error") { + return + } + + resolvedConfig, err := ResolveConfigForWorkspace(workspace, client) + if !assert.Error(t, err, "Should reject DWOC references outside the workspace and operator namespaces") { + return + } + assert.Nil(t, resolvedConfig, "No config should be returned for a disallowed DWOC reference") + assert.Contains(t, err.Error(), "must be in the DevWorkspace namespace") +} + +func TestOmitsPodLevelFieldsFromWorkspaceNamespaceDWOC(t *testing.T) { + setupForTest(t) + + workspaceNS := "workspace-ns" + workspace := workspaceReferencingExternalConfig(t, workspaceNS, externalConfigName, workspaceNS) + + privileged := true + externalConfig := &v1alpha1.DevWorkspaceOperatorConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: externalConfigName, + Namespace: workspaceNS, + }, + Config: &v1alpha1.OperatorConfiguration{ + Workspace: &v1alpha1.WorkspaceConfig{ + ImagePullPolicy: "Never", + PVCName: "workspace-pvc", + PodSecurityContext: &corev1.PodSecurityContext{RunAsUser: pointer.Int64(0)}, + ContainerSecurityContext: &corev1.SecurityContext{Privileged: &privileged}, + ServiceAccount: &v1alpha1.ServiceAccountConfig{ServiceAccountName: "custom-sa"}, + RuntimeClassName: pointer.String("kata"), + SchedulerName: "custom-scheduler", + PodAnnotations: map[string]string{"test.devfile.io/from-workspace-dwoc": "true"}, + DefaultTemplate: &dw.DevWorkspaceTemplateSpecContent{}, + HostUsers: pointer.Bool(false), + InitContainers: []corev1.Container{{Name: "extra-init", Image: "example.com/init:latest"}}, + Overrides: &v1alpha1.OverrideConfig{RestrictedContainerOverrideFields: []string{}}, + ProjectCloneConfig: &v1alpha1.ProjectCloneConfig{Image: "example.com/clone:latest"}, + RestoreConfig: &v1alpha1.RestoreConfig{ + ImagePullPolicy: corev1.PullNever, + Env: []corev1.EnvVar{ + {Name: "RESTORE_FROM_WORKSPACE_DWOC", Value: "true"}, + }, + }, + }, + }, + } + clusterConfig := buildConfig(defaultConfig.DeepCopy()) + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(clusterConfig, externalConfig).Build() + err := SetupControllerConfig(client) + if !assert.NoError(t, err, "Should not return error") { + return + } + + resolvedConfig, err := ResolveConfigForWorkspace(workspace, client) + if !assert.NoError(t, err, "Workspace-namespace DWOC should be allowed") { + return + } + + assert.Equal(t, "Never", resolvedConfig.Workspace.ImagePullPolicy, "Operational fields should still merge") + assert.Equal(t, "workspace-pvc", resolvedConfig.Workspace.PVCName, "Operational fields should still merge") + assert.Equal(t, internalConfig.Workspace.PodSecurityContext, resolvedConfig.Workspace.PodSecurityContext) + assert.Equal(t, internalConfig.Workspace.ContainerSecurityContext, resolvedConfig.Workspace.ContainerSecurityContext) + assert.Equal(t, internalConfig.Workspace.ServiceAccount, resolvedConfig.Workspace.ServiceAccount) + assert.Equal(t, internalConfig.Workspace.RuntimeClassName, resolvedConfig.Workspace.RuntimeClassName) + assert.Equal(t, internalConfig.Workspace.SchedulerName, resolvedConfig.Workspace.SchedulerName) + assert.Equal(t, internalConfig.Workspace.PodAnnotations, resolvedConfig.Workspace.PodAnnotations) + assert.Nil(t, resolvedConfig.Workspace.DefaultTemplate) + assert.Equal(t, internalConfig.Workspace.HostUsers, resolvedConfig.Workspace.HostUsers) + assert.Empty(t, resolvedConfig.Workspace.InitContainers) + assert.Equal(t, internalConfig.Workspace.Overrides, resolvedConfig.Workspace.Overrides) + assert.Equal(t, internalConfig.Workspace.ProjectCloneConfig, resolvedConfig.Workspace.ProjectCloneConfig) + assert.Equal(t, internalConfig.Workspace.RestoreConfig, resolvedConfig.Workspace.RestoreConfig) +} + +func TestAppliesPodLevelFieldsFromOperatorNamespaceDWOC(t *testing.T) { + setupForTest(t) + + workspace := workspaceReferencingExternalConfig(t, "workspace-ns", externalConfigName, testNamespace) + + privileged := true + runtimeClass := "kata" + externalConfig := &v1alpha1.DevWorkspaceOperatorConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: externalConfigName, + Namespace: testNamespace, + }, + Config: &v1alpha1.OperatorConfiguration{ + Workspace: &v1alpha1.WorkspaceConfig{ + ContainerSecurityContext: &corev1.SecurityContext{Privileged: &privileged}, + SchedulerName: "custom-scheduler", + RuntimeClassName: &runtimeClass, + ServiceAccount: &v1alpha1.ServiceAccountConfig{ServiceAccountName: "operator-sa"}, + RestoreConfig: &v1alpha1.RestoreConfig{ + ImagePullPolicy: corev1.PullNever, + }, + }, + }, + } + clusterConfig := buildConfig(defaultConfig.DeepCopy()) + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(clusterConfig, externalConfig).Build() + err := SetupControllerConfig(client) + if !assert.NoError(t, err, "Should not return error") { + return + } + + resolvedConfig, err := ResolveConfigForWorkspace(workspace, client) + if !assert.NoError(t, err, "Operator-namespace DWOC should be allowed") { + return + } + + if !assert.NotNil(t, resolvedConfig.Workspace.ContainerSecurityContext) { + return + } + assert.Equal(t, &privileged, resolvedConfig.Workspace.ContainerSecurityContext.Privileged) + assert.Equal(t, "custom-scheduler", resolvedConfig.Workspace.SchedulerName) + assert.Equal(t, &runtimeClass, resolvedConfig.Workspace.RuntimeClassName) + assert.Equal(t, "operator-sa", resolvedConfig.Workspace.ServiceAccount.ServiceAccountName) + assert.Equal(t, corev1.PullNever, resolvedConfig.Workspace.RestoreConfig.ImagePullPolicy) +} + +func workspaceReferencingExternalConfig(t *testing.T, workspaceNS, dwocName, dwocNS string) *dw.DevWorkspace { + t.Helper() + workspace := &dw.DevWorkspace{} + workspace.Namespace = workspaceNS + attrs := attributes.Attributes{} + attrs.Put(constants.ExternalDevWorkspaceConfiguration, types.NamespacedName{ + Name: dwocName, + Namespace: dwocNS, + }, nil) + workspace.Spec.Template.DevWorkspaceTemplateSpecContent = dw.DevWorkspaceTemplateSpecContent{ + Attributes: attrs, + } + return workspace +} + func TestSetupControllerAlwaysSetsDefaultClusterRoutingSuffix(t *testing.T) { setupForTest(t) infrastructure.InitializeForTesting(infrastructure.OpenShiftv4) diff --git a/pkg/constants/attributes.go b/pkg/constants/attributes.go index fb03df339..659c79736 100644 --- a/pkg/constants/attributes.go +++ b/pkg/constants/attributes.go @@ -32,6 +32,9 @@ const ( // which will merged with the internal/global DevWorkspaceOperatorConfig. The DevWorkspaceOperatorConfig resulting from the merge will be used for the workspace. // The fields which are set in the external DevWorkspaceOperatorConfig will overwrite those existing in the // internal/global DevWorkspaceOperatorConfig during the merge. + // The referenced DevWorkspaceOperatorConfig must be in the DevWorkspace's namespace or the operator namespace. + // Pod-level fields (security contexts, service account, default template, etc.) from DWOCs outside + // the operator namespace are ignored. // The structure of the attribute value should contain two strings: name and namespace. // 'name' specifies the metadata.name of the external operator configuration. // 'namespace' specifies the metadata.namespace of the external operator configuration . @@ -40,7 +43,7 @@ const ( // attributes: // controller.devfile.io/devworkspace-config: // name: external-dwoc-name - // namespace: some-namespace + // namespace: workspace-or-operator-namespace ExternalDevWorkspaceConfiguration = "controller.devfile.io/devworkspace-config" // RuntimeClassNameAttribute is an attribute added to a DevWorkspace to specify a runtimeClassName for container