/* Copyright 2017 The Kubernetes Authors. 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 auth import ( "encoding/json" "fmt" "strings" "time" apps "k8s.io/api/apps/v1" apiv1 "k8s.io/api/core/v1" apiextensionsv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" apiextensionclientset "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" "k8s.io/apiextensions-apiserver/test/integration/fixtures" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/wait" auditinternal "k8s.io/apiserver/pkg/apis/audit" "k8s.io/apiserver/pkg/apis/audit/v1beta1" clientset "k8s.io/client-go/kubernetes" restclient "k8s.io/client-go/rest" "k8s.io/kubernetes/test/e2e/framework" "k8s.io/kubernetes/test/utils" imageutils "k8s.io/kubernetes/test/utils/image" "github.com/evanphx/json-patch" . "github.com/onsi/ginkgo" ) var ( watchTestTimeout int64 = 1 auditTestUser = "kubecfg" crd = fixtures.NewRandomNameCustomResourceDefinition(apiextensionsv1beta1.ClusterScoped) crdName = strings.SplitN(crd.Name, ".", 2)[0] crdNamespace = strings.SplitN(crd.Name, ".", 2)[1] watchOptions = metav1.ListOptions{TimeoutSeconds: &watchTestTimeout} patch, _ = json.Marshal(jsonpatch.Patch{}) ) var _ = SIGDescribe("Advanced Audit", func() { f := framework.NewDefaultFramework("audit") BeforeEach(func() { framework.SkipUnlessProviderIs("gce") }) // TODO: Get rid of [DisabledForLargeClusters] when feature request #53455 is ready. It("should audit API calls [DisabledForLargeClusters]", func() { namespace := f.Namespace.Name config, err := framework.LoadConfig() framework.ExpectNoError(err, "failed to load config") apiExtensionClient, err := apiextensionclientset.NewForConfig(config) framework.ExpectNoError(err, "failed to initialize apiExtensionClient") By("Creating a kubernetes client that impersonates an unauthorized anonymous user") config, err = framework.LoadConfig() framework.ExpectNoError(err) config.Impersonate = restclient.ImpersonationConfig{ UserName: "system:anonymous", Groups: []string{"system:unauthenticated"}, } anonymousClient, err := clientset.NewForConfig(config) framework.ExpectNoError(err) testCases := []struct { action func() events []utils.AuditEvent }{ // Create, get, update, patch, delete, list, watch pods. { func() { pod := &apiv1.Pod{ ObjectMeta: metav1.ObjectMeta{ Name: "audit-pod", }, Spec: apiv1.PodSpec{ Containers: []apiv1.Container{{ Name: "pause", Image: imageutils.GetPauseImageName(), }}, }, } updatePod := func(pod *apiv1.Pod) {} f.PodClient().CreateSync(pod) _, err := f.PodClient().Get(pod.Name, metav1.GetOptions{}) framework.ExpectNoError(err, "failed to get audit-pod") podChan, err := f.PodClient().Watch(watchOptions) framework.ExpectNoError(err, "failed to create watch for pods") podChan.Stop() f.PodClient().Update(pod.Name, updatePod) _, err = f.PodClient().List(metav1.ListOptions{}) framework.ExpectNoError(err, "failed to list pods") _, err = f.PodClient().Patch(pod.Name, types.JSONPatchType, patch) framework.ExpectNoError(err, "failed to patch pod") f.PodClient().DeleteSync(pod.Name, &metav1.DeleteOptions{}, framework.DefaultPodDeletionTimeout) }, []utils.AuditEvent{ { Level: auditinternal.LevelRequestResponse, Stage: auditinternal.StageResponseComplete, RequestURI: fmt.Sprintf("/api/v1/namespaces/%s/pods", namespace), Verb: "create", Code: 201, User: auditTestUser, Resource: "pods", Namespace: namespace, RequestObject: true, ResponseObject: true, AuthorizeDecision: "allow", }, { Level: auditinternal.LevelRequest, Stage: auditinternal.StageResponseComplete, RequestURI: fmt.Sprintf("/api/v1/namespaces/%s/pods/audit-pod", namespace), Verb: "get", Code: 200, User: auditTestUser, Resource: "pods", Namespace: namespace, RequestObject: false, ResponseObject: false, AuthorizeDecision: "allow", }, { Level: auditinternal.LevelRequest, Stage: auditinternal.StageResponseComplete, RequestURI: fmt.Sprintf("/api/v1/namespaces/%s/pods", namespace), Verb: "list", Code: 200, User: auditTestUser, Resource: "pods", Namespace: namespace, RequestObject: false, ResponseObject: false, AuthorizeDecision: "allow", }, { Level: auditinternal.LevelRequest, Stage: auditinternal.StageResponseStarted, RequestURI: fmt.Sprintf("/api/v1/namespaces/%s/pods?timeout=%ds&timeoutSeconds=%d&watch=true", namespace, watchTestTimeout, watchTestTimeout), Verb: "watch", Code: 200, User: auditTestUser, Resource: "pods", Namespace: namespace, RequestObject: false, ResponseObject: false, AuthorizeDecision: "allow", }, { Level: auditinternal.LevelRequest, Stage: auditinternal.StageResponseComplete, RequestURI: fmt.Sprintf("/api/v1/namespaces/%s/pods?timeout=%ds&timeoutSeconds=%d&watch=true", namespace, watchTestTimeout, watchTestTimeout), Verb: "watch", Code: 200, User: auditTestUser, Resource: "pods", Namespace: namespace, RequestObject: false, ResponseObject: false, AuthorizeDecision: "allow", }, { Level: auditinternal.LevelRequestResponse, Stage: auditinternal.StageResponseComplete, RequestURI: fmt.Sprintf("/api/v1/namespaces/%s/pods/audit-pod", namespace), Verb: "update", Code: 200, User: auditTestUser, Resource: "pods", Namespace: namespace, RequestObject: true, ResponseObject: true, AuthorizeDecision: "allow", }, { Level: auditinternal.LevelRequestResponse, Stage: auditinternal.StageResponseComplete, RequestURI: fmt.Sprintf("/api/v1/namespaces/%s/pods/audit-pod", namespace), Verb: "patch", Code: 200, User: auditTestUser, Resource: "pods", Namespace: namespace, RequestObject: true, ResponseObject: true, AuthorizeDecision: "allow", }, { Level: auditinternal.LevelRequestResponse, Stage: auditinternal.StageResponseComplete, RequestURI: fmt.Sprintf("/api/v1/namespaces/%s/pods/audit-pod", namespace), Verb: "delete", Code: 200, User: auditTestUser, Resource: "pods", Namespace: namespace, RequestObject: true, ResponseObject: true, AuthorizeDecision: "allow", }, }, }, // Create, get, update, patch, delete, list, watch deployments. { func() { podLabels := map[string]string{"name": "audit-deployment-pod"} d := framework.NewDeployment("audit-deployment", int32(1), podLabels, "redis", imageutils.GetE2EImage(imageutils.Redis), apps.RecreateDeploymentStrategyType) _, err := f.ClientSet.AppsV1().Deployments(namespace).Create(d) framework.ExpectNoError(err, "failed to create audit-deployment") _, err = f.ClientSet.AppsV1().Deployments(namespace).Get(d.Name, metav1.GetOptions{}) framework.ExpectNoError(err, "failed to get audit-deployment") deploymentChan, err := f.ClientSet.AppsV1().Deployments(namespace).Watch(watchOptions) framework.ExpectNoError(err, "failed to create watch for deployments") deploymentChan.Stop() _, err = f.ClientSet.AppsV1().Deployments(namespace).Update(d) framework.ExpectNoError(err, "failed to update audit-deployment") _, err = f.ClientSet.AppsV1().Deployments(namespace).Patch(d.Name, types.JSONPatchType, patch) framework.ExpectNoError(err, "failed to patch deployment") _, err = f.ClientSet.AppsV1().Deployments(namespace).List(metav1.ListOptions{}) framework.ExpectNoError(err, "failed to create list deployments") err = f.ClientSet.AppsV1().Deployments(namespace).Delete("audit-deployment", &metav1.DeleteOptions{}) framework.ExpectNoError(err, "failed to delete deployments") }, []utils.AuditEvent{ { Level: auditinternal.LevelRequestResponse, Stage: auditinternal.StageResponseComplete, RequestURI: fmt.Sprintf("/apis/apps/v1/namespaces/%s/deployments", namespace), Verb: "create", Code: 201, User: auditTestUser, Resource: "deployments", Namespace: namespace, RequestObject: true, ResponseObject: true, AuthorizeDecision: "allow", }, { Level: auditinternal.LevelRequest, Stage: auditinternal.StageResponseComplete, RequestURI: fmt.Sprintf("/apis/apps/v1/namespaces/%s/deployments/audit-deployment", namespace), Verb: "get", Code: 200, User: auditTestUser, Resource: "deployments", Namespace: namespace, RequestObject: false, ResponseObject: false, AuthorizeDecision: "allow", }, { Level: auditinternal.LevelRequest, Stage: auditinternal.StageResponseComplete, RequestURI: fmt.Sprintf("/apis/apps/v1/namespaces/%s/deployments", namespace), Verb: "list", Code: 200, User: auditTestUser, Resource: "deployments", Namespace: namespace, RequestObject: false, ResponseObject: false, AuthorizeDecision: "allow", }, { Level: auditinternal.LevelRequest, Stage: auditinternal.StageResponseStarted, RequestURI: fmt.Sprintf("/apis/apps/v1/namespaces/%s/deployments?timeout=%ds&timeoutSeconds=%d&watch=true", namespace, watchTestTimeout, watchTestTimeout), Verb: "watch", Code: 200, User: auditTestUser, Resource: "deployments", Namespace: namespace, RequestObject: false, ResponseObject: false, AuthorizeDecision: "allow", }, { Level: auditinternal.LevelRequest, Stage: auditinternal.StageResponseComplete, RequestURI: fmt.Sprintf("/apis/apps/v1/namespaces/%s/deployments?timeout=%ds&timeoutSeconds=%d&watch=true", namespace, watchTestTimeout, watchTestTimeout), Verb: "watch", Code: 200, User: auditTestUser, Resource: "deployments", Namespace: namespace, RequestObject: false, ResponseObject: false, AuthorizeDecision: "allow", }, { Level: auditinternal.LevelRequestResponse, Stage: auditinternal.StageResponseComplete, RequestURI: fmt.Sprintf("/apis/apps/v1/namespaces/%s/deployments/audit-deployment", namespace), Verb: "update", Code: 200, User: auditTestUser, Resource: "deployments", Namespace: namespace, RequestObject: true, ResponseObject: true, AuthorizeDecision: "allow", }, { Level: auditinternal.LevelRequestResponse, Stage: auditinternal.StageResponseComplete, RequestURI: fmt.Sprintf("/apis/apps/v1/namespaces/%s/deployments/audit-deployment", namespace), Verb: "patch", Code: 200, User: auditTestUser, Resource: "deployments", Namespace: namespace, RequestObject: true, ResponseObject: true, AuthorizeDecision: "allow", }, { Level: auditinternal.LevelRequestResponse, Stage: auditinternal.StageResponseComplete, RequestURI: fmt.Sprintf("/apis/apps/v1/namespaces/%s/deployments/audit-deployment", namespace), Verb: "delete", Code: 200, User: auditTestUser, Resource: "deployments", Namespace: namespace, RequestObject: true, ResponseObject: true, AuthorizeDecision: "allow", }, }, }, // Create, get, update, patch, delete, list, watch configmaps. { func() { configMap := &apiv1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ Name: "audit-configmap", }, Data: map[string]string{ "map-key": "map-value", }, } _, err := f.ClientSet.CoreV1().ConfigMaps(namespace).Create(configMap) framework.ExpectNoError(err, "failed to create audit-configmap") _, err = f.ClientSet.CoreV1().ConfigMaps(namespace).Get(configMap.Name, metav1.GetOptions{}) framework.ExpectNoError(err, "failed to get audit-configmap") configMapChan, err := f.ClientSet.CoreV1().ConfigMaps(namespace).Watch(watchOptions) framework.ExpectNoError(err, "failed to create watch for config maps") configMapChan.Stop() _, err = f.ClientSet.CoreV1().ConfigMaps(namespace).Update(configMap) framework.ExpectNoError(err, "failed to update audit-configmap") _, err = f.ClientSet.CoreV1().ConfigMaps(namespace).Patch(configMap.Name, types.JSONPatchType, patch) framework.ExpectNoError(err, "failed to patch configmap") _, err = f.ClientSet.CoreV1().ConfigMaps(namespace).List(metav1.ListOptions{}) framework.ExpectNoError(err, "failed to list config maps") err = f.ClientSet.CoreV1().ConfigMaps(namespace).Delete(configMap.Name, &metav1.DeleteOptions{}) framework.ExpectNoError(err, "failed to delete audit-configmap") }, []utils.AuditEvent{ { Level: auditinternal.LevelMetadata, Stage: auditinternal.StageResponseComplete, RequestURI: fmt.Sprintf("/api/v1/namespaces/%s/configmaps", namespace), Verb: "create", Code: 201, User: auditTestUser, Resource: "configmaps", Namespace: namespace, RequestObject: false, ResponseObject: false, AuthorizeDecision: "allow", }, { Level: auditinternal.LevelMetadata, Stage: auditinternal.StageResponseComplete, RequestURI: fmt.Sprintf("/api/v1/namespaces/%s/configmaps/audit-configmap", namespace), Verb: "get", Code: 200, User: auditTestUser, Resource: "configmaps", Namespace: namespace, RequestObject: false, ResponseObject: false, AuthorizeDecision: "allow", }, { Level: auditinternal.LevelMetadata, Stage: auditinternal.StageResponseComplete, RequestURI: fmt.Sprintf("/api/v1/namespaces/%s/configmaps", namespace), Verb: "list", Code: 200, User: auditTestUser, Resource: "configmaps", Namespace: namespace, RequestObject: false, ResponseObject: false, AuthorizeDecision: "allow", }, { Level: auditinternal.LevelMetadata, Stage: auditinternal.StageResponseStarted, RequestURI: fmt.Sprintf("/api/v1/namespaces/%s/configmaps?timeout=%ds&timeoutSeconds=%d&watch=true", namespace, watchTestTimeout, watchTestTimeout), Verb: "watch", Code: 200, User: auditTestUser, Resource: "configmaps", Namespace: namespace, RequestObject: false, ResponseObject: false, AuthorizeDecision: "allow", }, { Level: auditinternal.LevelMetadata, Stage: auditinternal.StageResponseComplete, RequestURI: fmt.Sprintf("/api/v1/namespaces/%s/configmaps?timeout=%ds&timeoutSeconds=%d&watch=true", namespace, watchTestTimeout, watchTestTimeout), Verb: "watch", Code: 200, User: auditTestUser, Resource: "configmaps", Namespace: namespace, RequestObject: false, ResponseObject: false, AuthorizeDecision: "allow", }, { Level: auditinternal.LevelMetadata, Stage: auditinternal.StageResponseComplete, RequestURI: fmt.Sprintf("/api/v1/namespaces/%s/configmaps/audit-configmap", namespace), Verb: "update", Code: 200, User: auditTestUser, Resource: "configmaps", Namespace: namespace, RequestObject: false, ResponseObject: false, AuthorizeDecision: "allow", }, { Level: auditinternal.LevelMetadata, Stage: auditinternal.StageResponseComplete, RequestURI: fmt.Sprintf("/api/v1/namespaces/%s/configmaps/audit-configmap", namespace), Verb: "patch", Code: 200, User: auditTestUser, Resource: "configmaps", Namespace: namespace, RequestObject: false, ResponseObject: false, AuthorizeDecision: "allow", }, { Level: auditinternal.LevelMetadata, Stage: auditinternal.StageResponseComplete, RequestURI: fmt.Sprintf("/api/v1/namespaces/%s/configmaps/audit-configmap", namespace), Verb: "delete", Code: 200, User: auditTestUser, Resource: "configmaps", Namespace: namespace, RequestObject: false, ResponseObject: false, AuthorizeDecision: "allow", }, }, }, // Create, get, update, patch, delete, list, watch secrets. { func() { secret := &apiv1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: "audit-secret", }, Data: map[string][]byte{ "top-secret": []byte("foo-bar"), }, } _, err := f.ClientSet.CoreV1().Secrets(namespace).Create(secret) framework.ExpectNoError(err, "failed to create audit-secret") _, err = f.ClientSet.CoreV1().Secrets(namespace).Get(secret.Name, metav1.GetOptions{}) framework.ExpectNoError(err, "failed to get audit-secret") secretChan, err := f.ClientSet.CoreV1().Secrets(namespace).Watch(watchOptions) framework.ExpectNoError(err, "failed to create watch for secrets") secretChan.Stop() _, err = f.ClientSet.CoreV1().Secrets(namespace).Update(secret) framework.ExpectNoError(err, "failed to update audit-secret") _, err = f.ClientSet.CoreV1().Secrets(namespace).Patch(secret.Name, types.JSONPatchType, patch) framework.ExpectNoError(err, "failed to patch secret") _, err = f.ClientSet.CoreV1().Secrets(namespace).List(metav1.ListOptions{}) framework.ExpectNoError(err, "failed to list secrets") err = f.ClientSet.CoreV1().Secrets(namespace).Delete(secret.Name, &metav1.DeleteOptions{}) framework.ExpectNoError(err, "failed to delete audit-secret") }, []utils.AuditEvent{ { Level: auditinternal.LevelMetadata, Stage: auditinternal.StageResponseComplete, RequestURI: fmt.Sprintf("/api/v1/namespaces/%s/secrets", namespace), Verb: "create", Code: 201, User: auditTestUser, Resource: "secrets", Namespace: namespace, RequestObject: false, ResponseObject: false, AuthorizeDecision: "allow", }, { Level: auditinternal.LevelMetadata, Stage: auditinternal.StageResponseComplete, RequestURI: fmt.Sprintf("/api/v1/namespaces/%s/secrets/audit-secret", namespace), Verb: "get", Code: 200, User: auditTestUser, Resource: "secrets", Namespace: namespace, RequestObject: false, ResponseObject: false, AuthorizeDecision: "allow", }, { Level: auditinternal.LevelMetadata, Stage: auditinternal.StageResponseComplete, RequestURI: fmt.Sprintf("/api/v1/namespaces/%s/secrets", namespace), Verb: "list", Code: 200, User: auditTestUser, Resource: "secrets", Namespace: namespace, RequestObject: false, ResponseObject: false, AuthorizeDecision: "allow", }, { Level: auditinternal.LevelMetadata, Stage: auditinternal.StageResponseStarted, RequestURI: fmt.Sprintf("/api/v1/namespaces/%s/secrets?timeout=%ds&timeoutSeconds=%d&watch=true", namespace, watchTestTimeout, watchTestTimeout), Verb: "watch", Code: 200, User: auditTestUser, Resource: "secrets", Namespace: namespace, RequestObject: false, ResponseObject: false, AuthorizeDecision: "allow", }, { Level: auditinternal.LevelMetadata, Stage: auditinternal.StageResponseComplete, RequestURI: fmt.Sprintf("/api/v1/namespaces/%s/secrets?timeout=%ds&timeoutSeconds=%d&watch=true", namespace, watchTestTimeout, watchTestTimeout), Verb: "watch", Code: 200, User: auditTestUser, Resource: "secrets", Namespace: namespace, RequestObject: false, ResponseObject: false, AuthorizeDecision: "allow", }, { Level: auditinternal.LevelMetadata, Stage: auditinternal.StageResponseComplete, RequestURI: fmt.Sprintf("/api/v1/namespaces/%s/secrets/audit-secret", namespace), Verb: "update", Code: 200, User: auditTestUser, Resource: "secrets", Namespace: namespace, RequestObject: false, ResponseObject: false, AuthorizeDecision: "allow", }, { Level: auditinternal.LevelMetadata, Stage: auditinternal.StageResponseComplete, RequestURI: fmt.Sprintf("/api/v1/namespaces/%s/secrets/audit-secret", namespace), Verb: "patch", Code: 200, User: auditTestUser, Resource: "secrets", Namespace: namespace, RequestObject: false, ResponseObject: false, AuthorizeDecision: "allow", }, { Level: auditinternal.LevelMetadata, Stage: auditinternal.StageResponseComplete, RequestURI: fmt.Sprintf("/api/v1/namespaces/%s/secrets/audit-secret", namespace), Verb: "delete", Code: 200, User: auditTestUser, Resource: "secrets", Namespace: namespace, RequestObject: false, ResponseObject: false, AuthorizeDecision: "allow", }, }, }, // Create and delete custom resource definition. { func() { crd, err = fixtures.CreateNewCustomResourceDefinition(crd, apiExtensionClient, f.DynamicClient) framework.ExpectNoError(err, "failed to create custom resource definition") fixtures.DeleteCustomResourceDefinition(crd, apiExtensionClient) }, []utils.AuditEvent{ { Level: auditinternal.LevelRequestResponse, Stage: auditinternal.StageResponseComplete, RequestURI: "/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions", Verb: "create", Code: 201, User: auditTestUser, Resource: "customresourcedefinitions", RequestObject: true, ResponseObject: true, AuthorizeDecision: "allow", }, { Level: auditinternal.LevelMetadata, Stage: auditinternal.StageResponseComplete, RequestURI: fmt.Sprintf("/apis/%s/v1beta1/%s", crdNamespace, crdName), Verb: "create", Code: 201, User: auditTestUser, Resource: crdName, RequestObject: false, ResponseObject: false, AuthorizeDecision: "allow", }, { Level: auditinternal.LevelRequestResponse, Stage: auditinternal.StageResponseComplete, RequestURI: fmt.Sprintf("/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions/%s", crd.Name), Verb: "delete", Code: 200, User: auditTestUser, Resource: "customresourcedefinitions", RequestObject: false, ResponseObject: true, AuthorizeDecision: "allow", }, { Level: auditinternal.LevelMetadata, Stage: auditinternal.StageResponseComplete, RequestURI: fmt.Sprintf("/apis/%s/v1beta1/%s/setup-instance", crdNamespace, crdName), Verb: "delete", Code: 200, User: auditTestUser, Resource: crdName, RequestObject: false, ResponseObject: false, AuthorizeDecision: "allow", }, }, }, } // test authorizer annotations, RBAC is required. annotationTestCases := []struct { action func() events []utils.AuditEvent }{ // get a pod with unauthorized user { func() { _, err := anonymousClient.CoreV1().Pods(namespace).Get("another-audit-pod", metav1.GetOptions{}) expectForbidden(err) }, []utils.AuditEvent{ { Level: auditinternal.LevelRequest, Stage: auditinternal.StageResponseComplete, RequestURI: fmt.Sprintf("/api/v1/namespaces/%s/pods/another-audit-pod", namespace), Verb: "get", Code: 403, User: auditTestUser, Resource: "pods", Namespace: namespace, RequestObject: false, ResponseObject: false, AuthorizeDecision: "forbid", }, }, }, } if framework.IsRBACEnabled(f) { testCases = append(testCases, annotationTestCases...) } expectedEvents := []utils.AuditEvent{} for _, t := range testCases { t.action() expectedEvents = append(expectedEvents, t.events...) } // The default flush timeout is 30 seconds, therefore it should be enough to retry once // to find all expected events. However, we're waiting for 5 minutes to avoid flakes. pollingInterval := 30 * time.Second pollingTimeout := 5 * time.Minute err = wait.Poll(pollingInterval, pollingTimeout, func() (bool, error) { // Fetch the log stream. stream, err := f.ClientSet.CoreV1().RESTClient().Get().AbsPath("/logs/kube-apiserver-audit.log").Stream() if err != nil { return false, err } defer stream.Close() missing, err := utils.CheckAuditLines(stream, expectedEvents, v1beta1.SchemeGroupVersion) if err != nil { framework.Logf("Failed to observe audit events: %v", err) } else if len(missing) > 0 { framework.Logf("Events %#v not found!", missing) } return len(missing) == 0, nil }) framework.ExpectNoError(err, "after %v failed to observe audit events", pollingTimeout) }) })