diff --git a/docs/OpenShiftPipelinesAsCode.md b/docs/OpenShiftPipelinesAsCode.md index 78bc5f5b97..09e347286d 100644 --- a/docs/OpenShiftPipelinesAsCode.md +++ b/docs/OpenShiftPipelinesAsCode.md @@ -76,6 +76,33 @@ them as per their need. Details of the field can be found in [OpenShift Pipelines As Code Settings][pac-config] +#### Trusted Provider Hostnames + +`trusted-provider-hostnames` is an administrator-owned allowlist of git provider hostnames to which the controller may +send provider credentials. It can be set in `settings` of the `OpenShiftPipelinesAsCode` CR, or through `TektonConfig` +(`spec.platforms.openshift.pipelinesAsCode.settings`), and for every entry of `additionalPACControllers`. + +```yaml +spec: + platforms: + openshift: + pipelinesAsCode: + settings: + trusted-provider-hostnames: "ghe.example.com,gitlab.example.com" +``` + +When the list is empty, known public provider hosts remain trusted and each controller may learn a publicly routable +self-hosted hostname from an authenticated GitHub App webhook. Learned hosts are stored by the controller in the +`pipelinesascode.tekton.dev/auto-trusted-provider-hostnames` annotation of its own ConfigMap; the operator preserves this +annotation. When the list is not empty it is authoritative and no host is learned. + +Every enabled additional controller gets a Role and RoleBinding allowing the `pipelines-as-code-controller` ServiceAccount to +`get`, `update` and `patch` only that controller's ConfigMap (not created when the controller uses the `pipelines-as-code` ConfigMap). + +> **Upgrade note:** self-hosted providers using per-repository or incoming webhooks cannot teach the controller a hostname, +> so configure `trusted-provider-hostnames` before upgrading. GitHub.com installations need no additional configuration. +> This requires a Pipelines as Code release that includes `trusted-provider-hostnames`. + #### Remote Hub Catalogs Pipelines as Code supports configuring remote hub catalogs to fetch tasks and pipelines. You can configure custom catalogs using the `catalog-{INDEX}-*` settings pattern. diff --git a/pkg/reconciler/openshift/openshiftpipelinesascode/transform.go b/pkg/reconciler/openshift/openshiftpipelinesascode/transform.go index 88afe4e72c..500bd1faf1 100644 --- a/pkg/reconciler/openshift/openshiftpipelinesascode/transform.go +++ b/pkg/reconciler/openshift/openshiftpipelinesascode/transform.go @@ -32,12 +32,15 @@ import ( "go.uber.org/zap" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" ) const ( pipelinesAsCodeCM = "pipelines-as-code" + pacControllerServiceAccount = "pipelines-as-code-controller" additionalPACControllerNameSuffix = "-pac-controller" ) @@ -86,6 +89,16 @@ func additionalControllerTransform(extension common.Extension, name string) clie pac := comp.(*v1alpha1.OpenShiftPipelinesAsCode) additionalPACControllerConfig := pac.Spec.PACSettings.AdditionalPACControllers[name] + // give the additional controller access to its own configmap, so that it can + // record the learned trusted provider hostnames in the configmap annotation + if additionalPACControllerConfig.ConfigMapName != pipelinesAsCodeCM { + rbacManifest, err := additionalControllerConfigMapRBAC(name, additionalPACControllerConfig.ConfigMapName, pac.Spec.GetTargetNamespace()) + if err != nil { + return &mf.Manifest{}, err + } + *manifest = manifest.Append(rbacManifest) + } + imagesRaw := common.ToLowerCaseKeys(common.ImagesFromEnv(common.PacImagePrefix)) images := common.ImageRegistryDomainOverride(imagesRaw) // Run transformers @@ -120,6 +133,44 @@ func additionalControllerTransform(extension common.Extension, name string) clie } } +// additionalControllerConfigMapRBAC returns a Role and RoleBinding which allow the +// additional PACController to get, update and patch only its own configMap +func additionalControllerConfigMapRBAC(name, configMapName, namespace string) (mf.Manifest, error) { + rbacName := fmt.Sprintf("%s%s-configmap", name, additionalPACControllerNameSuffix) + labels := map[string]string{"app.kubernetes.io/part-of": "pipelines-as-code"} + + role := &rbacv1.Role{ + TypeMeta: metav1.TypeMeta{APIVersion: "rbac.authorization.k8s.io/v1", Kind: "Role"}, + ObjectMeta: metav1.ObjectMeta{Name: rbacName, Namespace: namespace, Labels: labels}, + Rules: []rbacv1.PolicyRule{{ + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + ResourceNames: []string{configMapName}, + Verbs: []string{"get", "update", "patch"}, + }}, + } + roleBinding := &rbacv1.RoleBinding{ + TypeMeta: metav1.TypeMeta{APIVersion: "rbac.authorization.k8s.io/v1", Kind: "RoleBinding"}, + ObjectMeta: metav1.ObjectMeta{Name: rbacName, Namespace: namespace, Labels: labels}, + RoleRef: rbacv1.RoleRef{APIGroup: "rbac.authorization.k8s.io", Kind: "Role", Name: rbacName}, + Subjects: []rbacv1.Subject{{ + Kind: rbacv1.ServiceAccountKind, + Name: pacControllerServiceAccount, + Namespace: namespace, + }}, + } + + resources := []unstructured.Unstructured{} + for _, obj := range []interface{}{role, roleBinding} { + content, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + return mf.Manifest{}, err + } + resources = append(resources, unstructured.Unstructured{Object: content}) + } + return mf.ManifestFrom(mf.Slice(resources)) +} + // This returns all resources to deploy for the additional PACController func filterAdditionalControllerManifest(manifest mf.Manifest) mf.Manifest { // filter deployment diff --git a/pkg/reconciler/openshift/openshiftpipelinesascode/transform_test.go b/pkg/reconciler/openshift/openshiftpipelinesascode/transform_test.go index a02a2fe04c..22616c53b3 100644 --- a/pkg/reconciler/openshift/openshiftpipelinesascode/transform_test.go +++ b/pkg/reconciler/openshift/openshiftpipelinesascode/transform_test.go @@ -28,6 +28,7 @@ import ( "gotest.tools/v3/assert" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" "k8s.io/apimachinery/pkg/runtime" ) @@ -188,3 +189,52 @@ func TestUpdateAdditionControllerConfigMap(t *testing.T) { t.Errorf("failed to update additional pac controller route %s", diff.PrintWantGot(d)) } } + +func TestAdditionalControllerConfigMapRBAC(t *testing.T) { + m, err := additionalControllerConfigMapRBAC("test", "test-config", "pac-ns") + assert.NilError(t, err) + assert.Equal(t, len(m.Resources()), 2) + + roles := m.Filter(mf.ByKind("Role")).Resources() + assert.Equal(t, len(roles), 1) + role := &rbacv1.Role{} + assert.NilError(t, runtime.DefaultUnstructuredConverter.FromUnstructured(roles[0].Object, role)) + assert.Equal(t, role.Name, "test-pac-controller-configmap") + assert.Equal(t, role.Namespace, "pac-ns") + assert.DeepEqual(t, role.Rules, []rbacv1.PolicyRule{{ + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + ResourceNames: []string{"test-config"}, + Verbs: []string{"get", "update", "patch"}, + }}) + + bindings := m.Filter(mf.ByKind("RoleBinding")).Resources() + assert.Equal(t, len(bindings), 1) + rb := &rbacv1.RoleBinding{} + assert.NilError(t, runtime.DefaultUnstructuredConverter.FromUnstructured(bindings[0].Object, rb)) + assert.Equal(t, rb.RoleRef.Name, role.Name) + assert.DeepEqual(t, rb.Subjects, []rbacv1.Subject{{ + Kind: "ServiceAccount", + Name: "pipelines-as-code-controller", + Namespace: "pac-ns", + }}) +} + +func TestUpdateAdditionControllerConfigMapPreservesAnnotations(t *testing.T) { + const learnedAnnotation = "pipelinesascode.tekton.dev/auto-trusted-provider-hostnames" + testData := path.Join("testdata", "test-additional-pac-cm.yaml") + manifest, err := mf.ManifestFrom(mf.Recursive(testData)) + assert.NilError(t, err) + + resources := manifest.Resources() + resources[0].SetAnnotations(map[string]string{learnedAnnotation: "ghe.example.com"}) + manifest, err = mf.ManifestFrom(mf.Slice(resources)) + assert.NilError(t, err) + + updated, err := manifest.Transform(updateAdditionControllerConfigMap(v1alpha1.AdditionalPACControllerConfig{ + ConfigMapName: "test-config", + Settings: map[string]string{"application-name": "test"}, + })) + assert.NilError(t, err) + assert.Equal(t, updated.Resources()[0].GetAnnotations()[learnedAnnotation], "ghe.example.com") +}