Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

test: add test coverage for missing permissions #1123

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
387 changes: 387 additions & 0 deletions e2e/testcases/permission_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,387 @@
// Copyright 2024 Google LLC
//
// 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 e2e

import (
"fmt"
"testing"

"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"kpt.dev/configsync/e2e"
"kpt.dev/configsync/e2e/nomostest"
"kpt.dev/configsync/e2e/nomostest/ntopts"
nomostesting "kpt.dev/configsync/e2e/nomostest/testing"
"kpt.dev/configsync/e2e/nomostest/testpredicates"
"kpt.dev/configsync/e2e/nomostest/testutils"
"kpt.dev/configsync/e2e/nomostest/workloadidentity"
"kpt.dev/configsync/pkg/api/configsync"
"kpt.dev/configsync/pkg/api/configsync/v1beta1"
"kpt.dev/configsync/pkg/core"
"kpt.dev/configsync/pkg/importer/filesystem"
"kpt.dev/configsync/pkg/kinds"
"kpt.dev/configsync/pkg/reconcilermanager/controllers"
"kpt.dev/configsync/pkg/status"
"kpt.dev/configsync/pkg/testing/fake"
)

// gsaRestrictedEmail returns a GSA email that has no permissions and no IAM bindings as a WI user.
func gsaRestrictedEmail() string {
return fmt.Sprintf("e2e-test-restricted-user@%s.iam.gserviceaccount.com", *e2e.GCPProject)
}

// gsaRestrictedImpersonatedEmail returns a GSA email that has no permissions, but has an IAM binding as a WI user.
func gsaRestrictedImpersonatedEmail() string {
return fmt.Sprintf("e2e-test-restricted-wi-user@%s.iam.gserviceaccount.com", *e2e.GCPProject)
}

const (

// The exact error message returned for GKE WI is:
// credential refresh failed: auth URL returned status 500, body: \"error retrieveing TokenSource.Token: compute: Received 403 `Unable to generate access token; IAM returned 403 Forbidden: Permission 'iam.serviceAccounts.getAccessToken' denied on resource (or it may not exist).\\nThis error could be caused by a missing IAM policy binding on the target IAM service account.\\nFor more information, refer to the Workload Identity documentation:\\n\\thttps://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity#authenticating_to
// The exact error message returned for Fleet WI is:
// credential refresh failed: auth URL returned status 500, body: \"error retrieveing TokenSource.Token: oauth2/google: status code 403: {\\n \\\"error\\\": {\\n \\\"code\\\": 403,\\n \\\"message\\\": \\\"Permission 'iam.serviceAccounts.getAccessToken' denied on resource (or it may not exist).\\\",\\n \\\"status\\\": \\\"PERMISSION_DENIED\\\",\\n \\\"details\\\": [\\n {\\n \\\"@type\\\": \\\"type.googleapis.com/google.rpc.ErrorInfo\\\",\\n \\\"reason\\\": \\\"IAM_PERMISSION_DENIED\\\",\\n \\\"domain\\\": \\\"iam.googleapis.com\\\",\\n \\\"metadata\\\": {\\n \\\"permission\\\": \\\"iam.serviceAccounts.getAccessToken\\\"\\n }\\n }\\n ]\\n }\\n}\\n\\n\"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This error is really messy with all the multi-line json escaping. Is there any way we can clean up the error a bit for the user to make it more readable?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • error retrieving TokenSource.Token is from our askpass sidecar code.
  • oauth2/google: status code %d: %s is from one of 3 places in the oauth2/google internal library.

I wonder if we could add some code to the askpass sidecar to detect and parse a JSON payload in the error string and do some formatting to pretty-print it so it's more readable like the GKE WI error.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or even better, maybe we can modify the oauth2 library to parse it for us...

missingWIBindingError = `Permission 'iam.serviceAccounts.getAccessToken' denied on resource (or it may not exist).`

// The exact error message returned by CSR is:
// remote: PERMISSION_DENIED: The caller does not have permission\\nremote: [type.googleapis.com/google.rpc.RequestInfo]\\nremote: request_id: \\\"6a2759876362432c8d3bb63ad4e45a99\\\"\\nfatal: unable to access 'https://source.developers.google.com/p/stolos-dev/r/kustomize-components/
missingCSRPermissionError = "PERMISSION_DENIED: The caller does not have permission"

// The exact error message returned by AR for the OCI image is:
// failed to pull image us-docker.pkg.dev/stolos-dev/config-sync-test-private/kustomize-components:v1: GET https://us-docker.pkg.dev/v2/token?scope=repository%3Astolos-dev%2Fconfig-sync-test-private%2Fkustomize-components%3Apull\u0026service=: DENIED: Permission \"artifactregistry.repositories.downloadArtifacts\" denied on resource \"projects/stolos-dev/locations/us/repositories/config-sync-test-private\" (or it may not exist)
missingOCIARPermissionError = `DENIED: Permission \"artifactregistry.repositories.downloadArtifacts\" denied on resource`

// The exact error message returned by AR for the Helm chart is:
// unexpected error rendering chart, will retry","Err":"rendering helm chart: invoking helm: Error: failed to authorize: failed to fetch oauth token: unexpected status from GET request to https://us-docker.pkg.dev/v2/token?scope=repository%3Astolos-dev%2Fconfig-sync-e2e-test--nanyu-cluster-1%2Fcoredns-test-helm-argke-workload-identit%3Apull\u0026service=us-docker.pkg.dev: 403 Forbidden
missingHelmARPermissionError = "403 Forbidden"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to change the test validation to allow regex so we could test for more of the error message without needing to know/encode the url? Like maybe rendering helm chart: invoking helm: Error: failed to authorize: failed to fetch oauth token: unexpected status from GET request to (.*): 403 Forbidden


// The exact error message for missing GSA is:
// metadata: GCE metadata \"instance/service-accounts/default/token?scopes=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcloud-platform\" not defined
missingGSAError = "not defined"
)

func missingGSAEmail() string {
return fmt.Sprintf("e2e-test-non-existent-user@%s.iam.gserviceaccount.com", *e2e.GCPProject)
}

func TestGSAMissingPermissions(t *testing.T) {
testCases := []struct {
name string
fwiTest bool
sourceType v1beta1.SourceType
AuthType configsync.AuthType
sourceRepo string
sourceChart string
sourceVersion string
gsaEmail string
expectedErrMsg string
}{
{
name: "GSA - Git source missing GSA for GKE WI",
fwiTest: false,
sourceType: v1beta1.GitSource,
AuthType: configsync.AuthGCPServiceAccount,
sourceRepo: csrRepo(),
gsaEmail: missingGSAEmail(),
expectedErrMsg: missingGSAError,
},
{
name: "GSA - OCI source missing GSA for GKE WI",
fwiTest: false,
sourceType: v1beta1.OciSource,
AuthType: configsync.AuthGCPServiceAccount,
sourceRepo: privateARImage(),
gsaEmail: missingGSAEmail(),
expectedErrMsg: missingGSAError,
},
{
name: "GSA - Helm source missing GSA for GKE WI",
fwiTest: false,
sourceType: v1beta1.HelmSource,
AuthType: configsync.AuthGCPServiceAccount,
sourceVersion: privateCoreDNSHelmChartVersion,
sourceChart: privateCoreDNSHelmChart,
gsaEmail: missingGSAEmail(),
expectedErrMsg: missingGSAError,
},
{
name: "GSA - Git source missing GSA for Fleet WI",
fwiTest: false,
sourceType: v1beta1.GitSource,
AuthType: configsync.AuthGCPServiceAccount,
sourceRepo: csrRepo(),
gsaEmail: missingGSAEmail(),
expectedErrMsg: missingGSAError,
},
{
name: "GSA - OCI source missing GSA for Fleet WI",
fwiTest: false,
sourceType: v1beta1.OciSource,
AuthType: configsync.AuthGCPServiceAccount,
sourceRepo: privateARImage(),
gsaEmail: missingGSAEmail(),
expectedErrMsg: missingGSAError,
},
{
name: "GSA - Helm source missing GSA for Fleet WI",
fwiTest: false,
sourceType: v1beta1.HelmSource,
AuthType: configsync.AuthGCPServiceAccount,
sourceVersion: privateCoreDNSHelmChartVersion,
sourceChart: privateCoreDNSHelmChart,
gsaEmail: missingGSAEmail(),
expectedErrMsg: missingGSAError,
},
{
name: "GSA - Git source missing IAM binding for GKE WI",
fwiTest: false,
sourceType: v1beta1.GitSource,
AuthType: configsync.AuthGCPServiceAccount,
sourceRepo: csrRepo(),
gsaEmail: gsaRestrictedEmail(),
expectedErrMsg: missingWIBindingError,
},
{
name: "GSA - OCI source missing IAM binding for GKE WI",
fwiTest: false,
sourceType: v1beta1.OciSource,
AuthType: configsync.AuthGCPServiceAccount,
sourceRepo: privateARImage(),
gsaEmail: gsaRestrictedEmail(),
expectedErrMsg: missingWIBindingError,
},
{
name: "GSA - Helm source missing IAM binding for GKE WI",
fwiTest: false,
sourceType: v1beta1.HelmSource,
AuthType: configsync.AuthGCPServiceAccount,
sourceVersion: privateCoreDNSHelmChartVersion,
sourceChart: privateCoreDNSHelmChart,
gsaEmail: gsaRestrictedEmail(),
expectedErrMsg: missingWIBindingError,
},
{
name: "GSA - Git source missing IAM binding for Fleet WI",
fwiTest: true,
sourceType: v1beta1.GitSource,
AuthType: configsync.AuthGCPServiceAccount,
sourceRepo: csrRepo(),
gsaEmail: gsaRestrictedEmail(),
expectedErrMsg: missingWIBindingError,
},
{
name: "GSA - OCI source missing IAM binding for Fleet WI",
fwiTest: true,
sourceType: v1beta1.OciSource,
AuthType: configsync.AuthGCPServiceAccount,
sourceRepo: privateARImage(),
gsaEmail: gsaRestrictedEmail(),
expectedErrMsg: missingWIBindingError,
},
{
name: "GSA - Helm source missing IAM binding for Fleet WI",
fwiTest: true,
sourceType: v1beta1.HelmSource,
AuthType: configsync.AuthGCPServiceAccount,
sourceVersion: privateCoreDNSHelmChartVersion,
sourceChart: privateCoreDNSHelmChart,
gsaEmail: gsaRestrictedEmail(),
expectedErrMsg: missingWIBindingError,
},
{
name: "GSA - Git source with IAM binding for GKE WI, but missing reader permission",
fwiTest: false,
sourceType: v1beta1.GitSource,
AuthType: configsync.AuthGCPServiceAccount,
sourceRepo: csrRepo(),
gsaEmail: gsaRestrictedImpersonatedEmail(),
expectedErrMsg: missingCSRPermissionError,
},
{
name: "GSA - OCI source with IAM binding for GKE WI, but missing reader permission",
fwiTest: false,
sourceType: v1beta1.OciSource,
AuthType: configsync.AuthGCPServiceAccount,
sourceRepo: privateARImage(),
gsaEmail: gsaRestrictedImpersonatedEmail(),
expectedErrMsg: missingOCIARPermissionError,
},
{
name: "GSA - Helm source with IAM binding for GKE WI, but missing reader permission",
fwiTest: false,
sourceType: v1beta1.HelmSource,
AuthType: configsync.AuthGCPServiceAccount,
sourceVersion: privateCoreDNSHelmChartVersion,
sourceChart: privateCoreDNSHelmChart,
gsaEmail: gsaRestrictedImpersonatedEmail(),
expectedErrMsg: missingHelmARPermissionError,
},
}

for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
nt := nomostest.New(t, nomostesting.WorkloadIdentity, ntopts.Unstructured, ntopts.RequireGKE(t))
rs := fake.RootSyncObjectV1Beta1(configsync.RootSyncName)
tenant := "tenant-a"

if err := workloadidentity.ValidateEnabled(nt); err != nil {
nt.T.Fatal(err)
}

mustConfigureMembership(nt, tc.fwiTest, false)

spec := &sourceSpec{
sourceType: tc.sourceType,
sourceRepo: tc.sourceRepo,
sourceChart: tc.sourceChart,
sourceVersion: tc.sourceVersion,
gsaEmail: tc.gsaEmail,
}
mustConfigureRootSync(nt, rs, tenant, spec)
nt.WaitForRootSyncSourceError(rs.Name, status.SourceErrorCode, tc.expectedErrMsg)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All these auth errors makes me wonder if we shouldnt add a new error code. I would normally expect the problem with a source error to be in the source, but these are all auth problems fetching the source.

})
}

}

func TestKSAMissingReaderPermission(t *testing.T) {
testCases := []struct {
name string
fwiTest bool
sourceType v1beta1.SourceType
AuthType configsync.AuthType
sourceRepo string
sourceChart string
sourceVersion string
gsaEmail string
expectedErrMsg string
}{
{
name: "WI-KSA - OCI source missing permission for GKE WI",
fwiTest: false,
sourceType: v1beta1.OciSource,
AuthType: configsync.AuthGCPServiceAccount,
sourceRepo: privateARImage(),
gsaEmail: gsaRestrictedEmail(),
expectedErrMsg: missingOCIARPermissionError,
},
{
name: "WI-KSA - Helm source missing permission for GKE WI",
fwiTest: false,
sourceType: v1beta1.HelmSource,
AuthType: configsync.AuthGCPServiceAccount,
sourceVersion: privateCoreDNSHelmChartVersion,
sourceChart: privateCoreDNSHelmChart,
gsaEmail: gsaRestrictedEmail(),
expectedErrMsg: missingHelmARPermissionError,
},
{
name: "WI-KSA - OCI source missing permission for Fleet WI",
fwiTest: true,
sourceType: v1beta1.OciSource,
AuthType: configsync.AuthGCPServiceAccount,
sourceRepo: privateARImage(),
gsaEmail: gsaRestrictedEmail(),
expectedErrMsg: missingOCIARPermissionError,
},
{
name: "WI-KSA - Helm source missing permission for Fleet WI",
fwiTest: true,
sourceType: v1beta1.HelmSource,
AuthType: configsync.AuthGCPServiceAccount,
sourceVersion: privateCoreDNSHelmChartVersion,
sourceChart: privateCoreDNSHelmChart,
gsaEmail: gsaRestrictedEmail(),
expectedErrMsg: missingHelmARPermissionError,
},
}

for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
nt := nomostest.New(t, nomostesting.WorkloadIdentity,
ntopts.Unstructured,
ntopts.RequireGKE(t))

if err := workloadidentity.ValidateEnabled(nt); err != nil {
karlkfi marked this conversation as resolved.
Show resolved Hide resolved
nt.T.Fatal(err)
}
mustConfigureMembership(nt, tc.fwiTest, false)

// Add another RootSync (rs-byoid) to the `root-sync` Rootsync.
rs := fake.RootSyncObjectV1Beta1("rs-byoid")
nomostest.EnableDeletionPropagation(rs)
syncDir := "tenant"
rs.Spec.SourceFormat = string(filesystem.SourceFormatUnstructured)
rs.Spec.SourceType = string(tc.sourceType)
spec := &sourceSpec{
sourceType: tc.sourceType,
sourceRepo: tc.sourceRepo,
sourceChart: tc.sourceChart,
sourceVersion: tc.sourceVersion,
gsaEmail: tc.gsaEmail,
}
if err := pushSource(nt, spec); err != nil {
nt.T.Fatal(err)
}
switch tc.sourceType {
case v1beta1.OciSource:
rs.Spec.Oci = &v1beta1.Oci{
Image: spec.sourceRepo,
Dir: syncDir,
Auth: configsync.AuthK8sServiceAccount,
}
case v1beta1.HelmSource:
rs.Spec.Helm = &v1beta1.HelmRootSync{
HelmBase: v1beta1.HelmBase{
Repo: spec.sourceRepo,
Chart: spec.sourceChart,
Version: spec.sourceVersion,
ReleaseName: "my-coredns",
Period: metav1.Duration{},
Auth: configsync.AuthK8sServiceAccount,
},
Namespace: "coredns",
}
}
nt.Must(nt.RootRepos[configsync.RootSyncName].Add("acme/rs-byoid.yaml", rs))
nt.Must(nt.RootRepos[configsync.RootSyncName].CommitAndPush("Add a RootSync with restricted k8sserviceaccount"))

ksaRef := types.NamespacedName{
Namespace: configsync.ControllerNamespace,
Name: core.RootReconcilerName(rs.Name),
}
require.NoError(nt.T,
Copy link
Contributor

@karlkfi karlkfi Feb 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

most of the e2e tests use nt.Must(...) instead of require.NoError(nt.T, ...).

nt.WatchForSync(kinds.RootSyncV1Beta1(), configsync.RootSyncName, configsync.ControllerNamespace,
nomostest.DefaultRootSha1Fn, nomostest.RootSyncHasStatusSyncCommit, nil),
)
require.NoError(nt.T,
nt.Watcher.WatchObject(kinds.ServiceAccount(), ksaRef.Name, ksaRef.Namespace, []testpredicates.Predicate{
testpredicates.MissingAnnotation(controllers.GCPSAAnnotationKey),
}))
if tc.fwiTest {
nt.T.Log("Validate the serviceaccount_impersonation_url is absent from the injected FWI credentials")
nomostest.Wait(nt.T, "wait for FWI credentials to exist", nt.DefaultWaitTimeout, func() error {
return testutils.ReconcilerPodHasFWICredsAnnotation(nt, core.RootReconcilerName(rs.Name), "", configsync.AuthK8sServiceAccount)
})
}

nt.WaitForRootSyncSourceError(rs.Name, status.SourceErrorCode, tc.expectedErrMsg)
})
}

}
Loading