diff --git a/internal/operator-controller/rukpak/convert/installer_rbac.go b/internal/operator-controller/rukpak/convert/installer_rbac.go new file mode 100644 index 000000000..76612d3a2 --- /dev/null +++ b/internal/operator-controller/rukpak/convert/installer_rbac.go @@ -0,0 +1,166 @@ +package convert + +import ( + "fmt" + "slices" + "strings" + + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/operator-framework/operator-controller/internal/shared/util/filter" + slicesutil "github.com/operator-framework/operator-controller/internal/shared/util/slices" +) + +var ( + unnamedResourceVerbs = []string{"create", "list", "watch"} + namedResourceVerbs = []string{"get", "update", "patch", "delete"} + + // clusterScopedResources is a slice of registry+v1 bundle supported cluster scoped resource kinds + clusterScopedResources = []string{ + "ClusterRole", + "ClusterRoleBinding", + "PriorityClass", + "ConsoleYAMLSample", + "ConsoleQuickStart", + "ConsoleCLIDownload", + "ConsoleLink", + "CustomResourceDefinition", + } + + // clusterScopedResources is a slice of registry+v1 bundle supported namespace scoped resource kinds + namespaceScopedResources = []string{ + "Secret", + "ConfigMap", + "ServiceAccount", + "Service", + "Role", + "RoleBinding", + "PrometheusRule", + "ServiceMonitor", + "PodDisruptionBudget", + "VerticalPodAutoscaler", + "Deployment", + } +) + +// GenerateResourceManagerClusterRolePerms generates a ClusterRole permissions to manage objs resources. The +// permissions also aggregate any permissions from any ClusterRoles in objs allowing the holder to also assign +// the RBAC therein to another service account. Note: assumes objs have been created by convert.Convert. +func GenerateResourceManagerClusterRolePerms(objs []client.Object) []rbacv1.PolicyRule { + return slices.Concat( + // cluster scoped resource creation and management rules + generatePolicyRules(filter.Filter(objs, isClusterScopedResource)), + // controller rbac scope + collectRBACResourcePolicyRules(filter.Filter(objs, filter.And(isGeneratedResource, isOfKind("ClusterRole")))), + ) +} + +// GenerateClusterExtensionFinalizerPolicyRule generates a policy rule that allows the holder to update +// finalizer for a ClusterExtension with clusterExtensionName. +func GenerateClusterExtensionFinalizerPolicyRule(clusterExtensionName string) rbacv1.PolicyRule { + return rbacv1.PolicyRule{ + APIGroups: []string{"olm.operatorframework.io"}, + Resources: []string{"clusterextensions/finalizers"}, + Verbs: []string{"update"}, + ResourceNames: []string{clusterExtensionName}, + } +} + +// GenerateResourceManagerRolePerms generates role permissions to manage objs resources in their +// namespaces. The permissions also include any permissions defined in any Roles in objs within the namespace, allowing +// the holder to also assign the RBAC therein to another service account. +// Note: currently assumes objs have been created by convert.Convert. +// The returned Roles will not have set .metadata.name +func GenerateResourceManagerRolePerms(objs []client.Object) map[string][]rbacv1.PolicyRule { + out := map[string][]rbacv1.PolicyRule{} + namespaceScopedObjs := filter.Filter(objs, isNamespaceScopedResource) + for _, obj := range namespaceScopedObjs { + namespace := obj.GetNamespace() + if _, ok := out[namespace]; !ok { + objsInNamespace := filter.Filter(namespaceScopedObjs, isInNamespace(namespace)) + out[namespace] = slices.Concat( + // namespace scoped resource creation and management rules + generatePolicyRules(objsInNamespace), + // controller rbac scope + collectRBACResourcePolicyRules(filter.Filter(objsInNamespace, filter.And(isOfKind("Role"), isGeneratedResource))), + ) + } + } + return out +} + +func generatePolicyRules(objs []client.Object) []rbacv1.PolicyRule { + return slices.Concat( + mapToSlice(slicesutil.GroupBy(objs, groupKind), func(gk schema.GroupKind, resources []client.Object) []rbacv1.PolicyRule { + return []rbacv1.PolicyRule{ + newPolicyRule(gk, unnamedResourceVerbs), + newPolicyRule(gk, namedResourceVerbs, slicesutil.Map(resources, toResourceName)...), + } + })..., + ) +} + +func collectRBACResourcePolicyRules(objs []client.Object) []rbacv1.PolicyRule { + return slices.Concat(slicesutil.Map(objs, func(obj client.Object) []rbacv1.PolicyRule { + if cr, ok := obj.(*rbacv1.ClusterRole); ok { + return cr.Rules + } else if r, ok := obj.(*rbacv1.Role); ok { + return r.Rules + } else { + panic(fmt.Sprintf("unexpected type %T", obj)) + } + })...) +} + +func newPolicyRule(groupKind schema.GroupKind, verbs []string, resourceNames ...string) rbacv1.PolicyRule { + return rbacv1.PolicyRule{ + APIGroups: []string{groupKind.Group}, + Resources: []string{fmt.Sprintf("%ss", strings.ToLower(groupKind.Kind))}, + Verbs: verbs, + ResourceNames: resourceNames, + } +} + +func mapToSlice[K comparable, V any, R any](m map[K]V, fn func(k K, v V) R) []R { + out := make([]R, 0, len(m)) + for k, v := range m { + out = append(out, fn(k, v)) + } + return out +} + +func isClusterScopedResource(o client.Object) bool { + return slices.Contains(clusterScopedResources, o.GetObjectKind().GroupVersionKind().Kind) +} + +func isNamespaceScopedResource(o client.Object) bool { + return slices.Contains(namespaceScopedResources, o.GetObjectKind().GroupVersionKind().Kind) +} + +func isOfKind(kind string) filter.Predicate[client.Object] { + return func(o client.Object) bool { + return o.GetObjectKind().GroupVersionKind().Kind == kind + } +} + +func isGeneratedResource(o client.Object) bool { + annotations := o.GetAnnotations() + _, ok := annotations[AnnotationRegistryV1GeneratedManifest] + return ok +} + +func isInNamespace(namespace string) filter.Predicate[client.Object] { + return func(o client.Object) bool { + return o.GetNamespace() == namespace + } +} + +func groupKind(obj client.Object) schema.GroupKind { + return obj.GetObjectKind().GroupVersionKind().GroupKind() +} + +func toResourceName(o client.Object) string { + return o.GetName() +} diff --git a/internal/operator-controller/rukpak/convert/installer_rbac_test.go b/internal/operator-controller/rukpak/convert/installer_rbac_test.go new file mode 100644 index 000000000..deabdf0a4 --- /dev/null +++ b/internal/operator-controller/rukpak/convert/installer_rbac_test.go @@ -0,0 +1,283 @@ +package convert_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/convert" +) + +func Test_GenerateResourceManagerClusterRolePerms_GeneratesRBACSuccessfully(t *testing.T) { + objs := []client.Object{ + // ClusterRole created by convert.Convert + &rbacv1.ClusterRole{ + TypeMeta: metav1.TypeMeta{ + Kind: "ClusterRole", + APIVersion: rbacv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "operator-controller-cluster-perms", + Annotations: map[string]string{ + convert.AnnotationRegistryV1GeneratedManifest: "", + }, + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + Verbs: []string{"get", "list", "watch"}, + }, { + APIGroups: []string{"apps"}, + Resources: []string{"deployments"}, + Verbs: []string{"get", "list", "watch"}, + }, + }, + }, + // Some CRD + &apiextensions.CustomResourceDefinition{ + TypeMeta: metav1.TypeMeta{ + Kind: "CustomResourceDefinition", + APIVersion: apiextensions.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "operator-controller-crd", + }, + Spec: apiextensions.CustomResourceDefinitionSpec{ + Group: "some.operator.domain", + Names: apiextensions.CustomResourceDefinitionNames{ + Plural: "operatorresources", + Kind: "OperatorResource", + ListKind: "OperatorResourceList", + Singular: "OperatorResource", + }, + Versions: []apiextensions.CustomResourceDefinitionVersion{ + { + Name: "v1alpha1", + Served: true, + }, + }, + }, + }, + // Some Namespaced Resource + &corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{ + Kind: "ConfigMap", + APIVersion: corev1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "operator-controller-config", + }, + Data: map[string]string{ + "some": "data", + }, + }, + } + + clusterRolePerms := convert.GenerateResourceManagerClusterRolePerms(objs) + require.ElementsMatch(t, []rbacv1.PolicyRule{ + // Aggregates operator-controller ClusterRole rules + { + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + Verbs: []string{"get", "list", "watch"}, + }, { + APIGroups: []string{"apps"}, + Resources: []string{"deployments"}, + Verbs: []string{"get", "list", "watch"}, + }, + // Adds cluster-scoped resource management rules + { + APIGroups: []string{"rbac.authorization.k8s.io"}, + Resources: []string{"clusterroles"}, + Verbs: []string{"create", "list", "watch"}, + }, { + APIGroups: []string{"rbac.authorization.k8s.io"}, + Resources: []string{"clusterroles"}, + Verbs: []string{"get", "update", "patch", "delete"}, + ResourceNames: []string{"operator-controller-cluster-perms"}, + }, { + APIGroups: []string{"apiextensions.k8s.io"}, + Resources: []string{"customresourcedefinitions"}, + Verbs: []string{"create", "list", "watch"}, + }, { + APIGroups: []string{"apiextensions.k8s.io"}, + Resources: []string{"customresourcedefinitions"}, + Verbs: []string{"get", "update", "patch", "delete"}, + ResourceNames: []string{"operator-controller-crd"}, + }, + // Nothing to be said about namespaced resources + }, clusterRolePerms) +} + +func Test_GenerateResourceManagerRolePerms_GeneratesRBACSuccessfully(t *testing.T) { + objs := []client.Object{ + // ClusterRole generated by convert.Convert - should be ignored + &rbacv1.ClusterRole{ + TypeMeta: metav1.TypeMeta{ + Kind: "ClusterRole", + APIVersion: rbacv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "operator-controller-namespace-perms", + Annotations: map[string]string{ + convert.AnnotationRegistryV1GeneratedManifest: "", + }, + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + Verbs: []string{"get", "list", "watch"}, + }, { + APIGroups: []string{"apps"}, + Resources: []string{"deployments"}, + Verbs: []string{"get", "list", "watch"}, + }, + }, + }, + // Some cluster-scoped resources - should be ignored + &apiextensions.CustomResourceDefinition{ + TypeMeta: metav1.TypeMeta{ + Kind: "CustomResourceDefinition", + APIVersion: apiextensions.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "operator-controller-crd", + }, + Spec: apiextensions.CustomResourceDefinitionSpec{ + Group: "some.operator.domain", + Names: apiextensions.CustomResourceDefinitionNames{ + Plural: "operatorresources", + Kind: "OperatorResource", + ListKind: "OperatorResourceList", + Singular: "OperatorResource", + }, + Versions: []apiextensions.CustomResourceDefinitionVersion{ + { + Name: "v1alpha1", + Served: true, + }, + }, + }, + }, + // Some Namespaced Resource + &corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{ + Kind: "ConfigMap", + APIVersion: corev1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "operator-controller-config", + Namespace: "install-namespace", + }, + Data: map[string]string{ + "some": "data", + }, + }, + // Some namespaces resource in a different namespace + &corev1.Service{ + TypeMeta: metav1.TypeMeta{ + Kind: "Service", + APIVersion: corev1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "some-service", + Namespace: "another-namespace", + }, + }, + // Some convert.Convert generated Role - perms should be aggregated + &rbacv1.Role{ + TypeMeta: metav1.TypeMeta{ + Kind: "Role", + APIVersion: rbacv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "operator-controller-perms", + Namespace: "install-namespace", + Annotations: map[string]string{ + convert.AnnotationRegistryV1GeneratedManifest: "", + }, + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + Verbs: []string{"get", "list", "watch"}, + }, { + APIGroups: []string{"apps"}, + Resources: []string{"deployments"}, + Verbs: []string{"get", "list", "watch"}, + }, + }, + }, + } + + namespaceRolePerms := convert.GenerateResourceManagerRolePerms(objs) + expected := map[string][]rbacv1.PolicyRule{ + "install-namespace": { + // Aggregates operator-controller ClusterRole rules + { + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + Verbs: []string{"get", "list", "watch"}, + }, { + APIGroups: []string{"apps"}, + Resources: []string{"deployments"}, + Verbs: []string{"get", "list", "watch"}, + }, + // Adds cluster-scoped resource management rules + { + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + Verbs: []string{"create", "list", "watch"}, + }, { + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + Verbs: []string{"get", "update", "patch", "delete"}, + ResourceNames: []string{"operator-controller-config"}, + }, + { + APIGroups: []string{"rbac.authorization.k8s.io"}, + Resources: []string{"roles"}, + Verbs: []string{"create", "list", "watch"}, + }, { + APIGroups: []string{"rbac.authorization.k8s.io"}, + Resources: []string{"roles"}, + Verbs: []string{"get", "update", "patch", "delete"}, + ResourceNames: []string{"operator-controller-perms"}, + }, + }, + "another-namespace": { + { + APIGroups: []string{""}, + Resources: []string{"services"}, + Verbs: []string{"create", "list", "watch"}, + }, { + APIGroups: []string{""}, + Resources: []string{"services"}, + Verbs: []string{"get", "update", "patch", "delete"}, + ResourceNames: []string{"some-service"}, + }, + }, + } + + for namespace, perms := range namespaceRolePerms { + require.ElementsMatch(t, perms, expected[namespace]) + } +} + +func Test_GenerateClusterExtensionFinalizerPolicyRule(t *testing.T) { + rule := convert.GenerateClusterExtensionFinalizerPolicyRule("someext") + require.Equal(t, rbacv1.PolicyRule{ + APIGroups: []string{"olm.operatorframework.io"}, + Resources: []string{"clusterextensions/finalizers"}, + Verbs: []string{"update"}, + ResourceNames: []string{"someext"}, + }, rule) +} diff --git a/internal/operator-controller/rukpak/convert/registryv1.go b/internal/operator-controller/rukpak/convert/registryv1.go index 155b86be8..8f44eb375 100644 --- a/internal/operator-controller/rukpak/convert/registryv1.go +++ b/internal/operator-controller/rukpak/convert/registryv1.go @@ -29,6 +29,10 @@ import ( "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/util" ) +const ( + AnnotationRegistryV1GeneratedManifest = "io.operatorframework.olm.generated-manifest" +) + type RegistryV1 struct { PackageName string CSV v1alpha1.ClusterServiceVersion @@ -255,7 +259,7 @@ func Convert(in RegistryV1, installNamespace string, targetNamespaces []string) return nil, fmt.Errorf("webhookDefinitions are not supported") } - deployments := []appsv1.Deployment{} + deployments := make([]appsv1.Deployment, 0, len(in.CSV.Spec.InstallStrategy.StrategySpec.DeploymentSpecs)) serviceAccounts := map[string]corev1.ServiceAccount{} for _, depSpec := range in.CSV.Spec.InstallStrategy.StrategySpec.DeploymentSpecs { annotations := util.MergeMaps(in.CSV.Annotations, depSpec.Spec.Template.Annotations) @@ -340,23 +344,24 @@ func Convert(in RegistryV1, installNamespace string, targetNamespaces []string) } objs := []client.Object{} + for _, obj := range serviceAccounts { obj := obj if obj.GetName() != "default" { - objs = append(objs, &obj) + objs = append(objs, annotateGenerated(&obj)) } } for _, obj := range roles { obj := obj - objs = append(objs, &obj) + objs = append(objs, annotateGenerated(&obj)) } for _, obj := range roleBindings { obj := obj - objs = append(objs, &obj) + objs = append(objs, annotateGenerated(&obj)) } for _, obj := range clusterRoles { obj := obj - objs = append(objs, &obj) + objs = append(objs, annotateGenerated(&obj)) } for _, obj := range clusterRoleBindings { obj := obj @@ -375,13 +380,24 @@ func Convert(in RegistryV1, installNamespace string, targetNamespaces []string) } for _, obj := range deployments { obj := obj - objs = append(objs, &obj) + objs = append(objs, annotateGenerated(&obj)) } + return &Plain{Objects: objs}, nil } const maxNameLength = 63 +func annotateGenerated(o client.Object) client.Object { + annotations := o.GetAnnotations() + if annotations == nil { + annotations = make(map[string]string) + } + annotations[AnnotationRegistryV1GeneratedManifest] = "" + o.SetAnnotations(annotations) + return o +} + func generateName(base string, o interface{}) (string, error) { hashStr, err := util.DeepHashObject(o) if err != nil { diff --git a/internal/operator-controller/rukpak/convert/registryv1_test.go b/internal/operator-controller/rukpak/convert/registryv1_test.go index 20ea7fc88..286a035da 100644 --- a/internal/operator-controller/rukpak/convert/registryv1_test.go +++ b/internal/operator-controller/rukpak/convert/registryv1_test.go @@ -4,6 +4,7 @@ import ( "fmt" "io/fs" "os" + "reflect" "strings" "testing" "testing/fstest" @@ -346,6 +347,46 @@ func TestRegistryV1SuiteGenerateSingleNamespace(t *testing.T) { require.Equal(t, strings.Join(watchNamespaces, ","), dep.(*appsv1.Deployment).Spec.Template.Annotations[olmNamespaces]) } +func TestRegistryV1SuiteConvertAnnotatesGeneratedManifests(t *testing.T) { + t.Log("RegistryV1 Suite Convert") + t.Log("It should generate objects successfully based on target namespaces") + + t.Log("It should convert into plain manifests successfully with SingleNamespace") + baseCSV, svc := getBaseCsvAndService() + csv := baseCSV.DeepCopy() + csv.Spec.InstallModes = []v1alpha1.InstallMode{{Type: v1alpha1.InstallModeTypeSingleNamespace, Supported: true}} + + t.Log("By creating a registry v1 bundle") + watchNamespaces := []string{"testWatchNs1"} + unstructuredSvc := convertToUnstructured(t, svc) + registryv1Bundle := convert.RegistryV1{ + PackageName: "testPkg", + CSV: *csv, + Others: []unstructured.Unstructured{unstructuredSvc}, + } + + t.Log("By converting to plain") + plainBundle, err := convert.Convert(registryv1Bundle, installNamespace, watchNamespaces) + require.NoError(t, err) + + t.Log("By verifying if plain bundle has required objects") + require.NotNil(t, plainBundle) + require.Len(t, plainBundle.Objects, 5) + + t.Log("By verifying all manifests not in 'Others' contain 'io.operatorframework.olm.generated-manifest' annotation") + for _, obj := range plainBundle.Objects { + if reflect.DeepEqual(obj, &unstructuredSvc) { + require.NotContains(t, obj.GetAnnotations(), convert.AnnotationRegistryV1GeneratedManifest) + } else { + require.Contains(t, obj.GetAnnotations(), convert.AnnotationRegistryV1GeneratedManifest) + } + } + dep := findObjectByName("testDeployment", plainBundle.Objects) + require.NotNil(t, dep) + require.Contains(t, dep.(*appsv1.Deployment).Spec.Template.Annotations, olmNamespaces) + require.Equal(t, strings.Join(watchNamespaces, ","), dep.(*appsv1.Deployment).Spec.Template.Annotations[olmNamespaces]) +} + func TestRegistryV1SuiteGenerateOwnNamespace(t *testing.T) { t.Log("RegistryV1 Suite Convert") t.Log("It should generate objects successfully based on target namespaces") @@ -565,7 +606,7 @@ func TestRegistryV1SuiteGenerateNoWebhooks(t *testing.T) { require.Nil(t, plainBundle) } -func TestRegistryV1SuiteGenerateNoAPISerciceDefinitions(t *testing.T) { +func TestRegistryV1SuiteGenerateNoAPIServiceDefinitions(t *testing.T) { t.Log("RegistryV1 Suite Convert") t.Log("It should generate objects successfully based on target namespaces") diff --git a/internal/shared/util/slices/slices.go b/internal/shared/util/slices/slices.go new file mode 100644 index 000000000..90c33edae --- /dev/null +++ b/internal/shared/util/slices/slices.go @@ -0,0 +1,22 @@ +package slices + +type Key[T any, K comparable] func(entity T) K + +type MapFn[S any, V any] func(S) V + +func GroupBy[T any, K comparable](s []T, key Key[T, K]) map[K][]T { + out := map[K][]T{} + for _, value := range s { + k := key(value) + out[k] = append(out[k], value) + } + return out +} + +func Map[S, V any](s []S, mapper MapFn[S, V]) []V { + out := make([]V, len(s)) + for i := 0; i < len(s); i++ { + out[i] = mapper(s[i]) + } + return out +} diff --git a/internal/shared/util/slices/slices_test.go b/internal/shared/util/slices/slices_test.go new file mode 100644 index 000000000..5edf906ab --- /dev/null +++ b/internal/shared/util/slices/slices_test.go @@ -0,0 +1,31 @@ +package slices_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + slicesutil "github.com/operator-framework/operator-controller/internal/shared/util/slices" +) + +func Test_Map(t *testing.T) { + in := []int{1, 2, 3, 4, 5} + doubleIt := func(val int) int { + return 2 * val + } + require.Equal(t, []int{2, 4, 6, 8, 10}, slicesutil.Map(in, doubleIt)) +} + +func Test_GroupBy(t *testing.T) { + in := []int{1, 2, 3, 4, 5} + oddOrEven := func(val int) string { + if val%2 == 0 { + return "even" + } + return "odd" + } + require.Equal(t, map[string][]int{ + "even": {2, 4}, + "odd": {1, 3, 5}, + }, slicesutil.GroupBy(in, oddOrEven)) +}