diff --git a/Makefile b/Makefile index 272958f28..1cd68758b 100644 --- a/Makefile +++ b/Makefile @@ -71,23 +71,36 @@ test-load: kubectl delete events --all -n testns $(MAKE) gotest-load +define validate-envtest-assets + @ASSETS=$$($(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path) || \ + { echo "ERROR: setup-envtest failed to configure test binaries"; exit 1; }; \ + [ -n "$$ASSETS" ] || \ + { echo "ERROR: setup-envtest returned empty path for KUBEBUILDER_ASSETS"; exit 1; }; \ + [ -x "$$ASSETS/etcd" ] || \ + { echo "ERROR: etcd not found at $$ASSETS/etcd — try deleting .bin/k8s and re-running"; exit 1; }; +endef + .PHONY: gotest gotest: ginkgo - KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" \ + $(validate-envtest-assets) \ + KUBEBUILDER_ASSETS="$$ASSETS" \ ginkgo -r -v --skip-package=tests/e2e -coverprofile cover.out ./... .PHONY: gotest-prod gotest-prod: - KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test -tags rustdiffgen -skip ^TestE2E$$ ./... -coverprofile cover.out + $(validate-envtest-assets) \ + KUBEBUILDER_ASSETS="$$ASSETS" go test -tags rustdiffgen -skip ^TestE2E$$ ./... -coverprofile cover.out .PHONY: gotest-load gotest-load: make -C fixtures/load k6 - LOAD_TEST=1 KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test -v ./tests -skip ^TestE2E$$ -coverprofile cover.out + $(validate-envtest-assets) \ + LOAD_TEST=1 KUBEBUILDER_ASSETS="$$ASSETS" go test -v ./tests -skip ^TestE2E$$ -coverprofile cover.out .PHONY: env env: envtest ## Run tests. - KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" \ + $(validate-envtest-assets) \ + KUBEBUILDER_ASSETS="$$ASSETS" \ ginkgo -r -v --skip-package=tests/e2e -coverprofile cover.out .PHONY: ginkgo @@ -234,12 +247,12 @@ $(CONTROLLER_GEN): $(LOCALBIN) test -s $(LOCALBIN)/controller-gen && $(LOCALBIN)/controller-gen --version | grep -q $(CONTROLLER_TOOLS_VERSION) || \ GOBIN=$(LOCALBIN) go install sigs.k8s.io/controller-tools/cmd/controller-gen@$(CONTROLLER_TOOLS_VERSION) -ENVTEST_K8S_VERSION = 1.25.0 -CONTROLLER_RUNTIME_VERSION = v0.0.0-20240320141353-395cfc7486e6 +ENVTEST_K8S_VERSION = 1.34.0 +ENVTEST_BRANCH = release-0.22 .PHONY: envtest envtest: $(ENVTEST) ## Download envtest-setup locally if necessary. $(ENVTEST): $(LOCALBIN) - test -s $(LOCALBIN)/setup-envtest || GOBIN=$(LOCALBIN) go install sigs.k8s.io/controller-runtime/tools/setup-envtest@$(CONTROLLER_RUNTIME_VERSION) + test -s $(LOCALBIN)/setup-envtest || GOBIN=$(LOCALBIN) go install sigs.k8s.io/controller-runtime/tools/setup-envtest@$(ENVTEST_BRANCH) .PHONY: golangci-lint golangci-lint: $(GOLANGCI_LINT) ## Download golangci-lint locally if necessary. diff --git a/fixtures/data/k8s_rbac_access_test.json b/fixtures/data/k8s_rbac_access_test.json new file mode 100644 index 000000000..bc21b106f --- /dev/null +++ b/fixtures/data/k8s_rbac_access_test.json @@ -0,0 +1,71 @@ +{ + "id": "Kubernetes/test-cluster/Pod/default/nginx", + "config_class": "Pod", + "config": { + "apiVersion": "v1", + "kind": "Pod", + "metadata": { + "name": "nginx", + "namespace": "default" + } + }, + "external_roles": [ + { + "name": "pod-reader", + "account_id": "test-cluster", + "role_type": "ClusterRole", + "aliases": ["Kubernetes/test-cluster/ClusterRole//pod-reader"] + } + ], + "external_users": [ + { + "name": "my-sa", + "account_id": "test-cluster", + "user_type": "ServiceAccount", + "aliases": ["Kubernetes/test-cluster/ServiceAccount/default/my-sa"] + }, + { + "name": "admin@example.com", + "account_id": "test-cluster", + "user_type": "User", + "aliases": ["Kubernetes/test-cluster/User//admin@example.com"] + } + ], + "external_groups": [ + { + "name": "system:authenticated", + "account_id": "test-cluster", + "group_type": "Group", + "aliases": ["Kubernetes/test-cluster/Group//system:authenticated"] + } + ], + "config_access": [ + { + "id": "rbac-access-sa", + "external_config_id": { + "config_type": "Kubernetes::Pod", + "external_id": "Kubernetes/test-cluster/Pod/default/nginx" + }, + "external_user_aliases": ["Kubernetes/test-cluster/ServiceAccount/default/my-sa"], + "external_role_aliases": ["Kubernetes/test-cluster/ClusterRole//pod-reader"] + }, + { + "id": "rbac-access-user", + "external_config_id": { + "config_type": "Kubernetes::Pod", + "external_id": "Kubernetes/test-cluster/Pod/default/nginx" + }, + "external_user_aliases": ["Kubernetes/test-cluster/User//admin@example.com"], + "external_role_aliases": ["Kubernetes/test-cluster/ClusterRole//pod-reader"] + }, + { + "id": "rbac-access-group", + "external_config_id": { + "config_type": "Kubernetes::Pod", + "external_id": "Kubernetes/test-cluster/Pod/default/nginx" + }, + "external_group_aliases": ["Kubernetes/test-cluster/Group//system:authenticated"], + "external_role_aliases": ["Kubernetes/test-cluster/ClusterRole//pod-reader"] + } + ] +} diff --git a/fixtures/file-k8s-rbac-access.yaml b/fixtures/file-k8s-rbac-access.yaml new file mode 100644 index 000000000..bf12071a5 --- /dev/null +++ b/fixtures/file-k8s-rbac-access.yaml @@ -0,0 +1,12 @@ +apiVersion: configs.flanksource.com/v1 +kind: ScrapeConfig +metadata: + name: file-k8s-rbac-access-scraper +spec: + full: true + file: + - type: Kubernetes::Pod + class: Pod + id: $.id + paths: + - fixtures/data/k8s_rbac_access_test.json diff --git a/scrapers/extraction_unit_test.go b/scrapers/extraction_unit_test.go index a260ce868..8f9e0cfbb 100644 --- a/scrapers/extraction_unit_test.go +++ b/scrapers/extraction_unit_test.go @@ -11,14 +11,14 @@ import ( func TestExtractConfigAccess(t *testing.T) { testCases := []struct { - name string - input map[string]any - expectError bool - expectedCount int - expectedIDs []string - expectedUserAliases [][]string - expectedRoleAliases [][]string - expectedGroupAliases [][]string + name string + input map[string]any + expectError bool + expectedCount int + expectedIDs []string + expectedUserAliases [][]string + expectedRoleAliases [][]string + expectedGroupAliases [][]string }{ { name: "extracts config_access with external_user_aliases", diff --git a/scrapers/kubernetes/kubernetes.go b/scrapers/kubernetes/kubernetes.go index 35e7b174f..3e47f81f5 100644 --- a/scrapers/kubernetes/kubernetes.go +++ b/scrapers/kubernetes/kubernetes.go @@ -139,6 +139,16 @@ func ExtractResults(ctx *KubernetesContext, objs []*unstructured.Unstructured) v results = append(results, cluster) + // Initialize RBAC extractor for config access tracking + var rbac *rbacExtractor + var roleBindings []*unstructured.Unstructured + if ctx.Properties().On(true, "kubernetes.rbac_config_access") { + rbacCtx := ctx.ScrapeContext + rbacCtx.Context = rbacCtx.WithKubernetes(ctx.config.KubernetesConnection) + rbac = newRBACExtractor(rbacCtx, clusterName, ctx.ScrapeConfig().GetPersistedID()) + rbac.indexObjects(objs) + } + ctx.Load(objs) if ctx.IsIncrementalScrape() { // On incremental scrape, we do not have all the data in the resource ID map. @@ -419,6 +429,13 @@ func ExtractResults(ctx *KubernetesContext, objs []*unstructured.Unstructured) v } } } + + case "ClusterRole", "Role": + rbac.processRole(obj) + + case "ClusterRoleBinding", "RoleBinding": + // Store bindings for later processing after all roles are processed + roleBindings = append(roleBindings, obj) } if obj.GetNamespace() != "" { @@ -551,6 +568,12 @@ func ExtractResults(ctx *KubernetesContext, objs []*unstructured.Unstructured) v }) } + // Process role bindings after all roles have been processed + for _, binding := range roleBindings { + rbac.processRoleBinding(binding) + } + results = append(results, rbac.results(ctx.config.BaseScraper)) + results = append(results, changeResults...) if ctx.IsIncrementalScrape() { results = append([]v1.ScrapeResult{ctx.cluster}, results...) diff --git a/scrapers/kubernetes/rbac.go b/scrapers/kubernetes/rbac.go new file mode 100644 index 000000000..d8d99c000 --- /dev/null +++ b/scrapers/kubernetes/rbac.go @@ -0,0 +1,501 @@ +// ABOUTME: Extracts RBAC resources (Roles, ClusterRoles, RoleBindings, ClusterRoleBindings) for config access. +// ABOUTME: Creates ExternalRoles, ExternalUsers, ExternalGroups, and ConfigAccess entries from Kubernetes RBAC. + +package kubernetes + +import ( + "strings" + "sync" + "time" + + "github.com/flanksource/config-db/api" + v1 "github.com/flanksource/config-db/api/v1" + "github.com/flanksource/duty/models" + uuidV5 "github.com/gofrs/uuid/v5" + "github.com/google/uuid" + "github.com/lib/pq" + "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +type rbacExtractor struct { + clusterName string + scraperID *uuid.UUID + roles map[uuid.UUID]models.ExternalRole + users map[uuid.UUID]models.ExternalUser + groups map[uuid.UUID]models.ExternalGroup + access []v1.ExternalConfigAccess + + // Maps for lookups + roleRules map[string][]rbacRule // key: kind/namespace/name -> rules + objectsByKind map[string][]*objectRef // key: kind -> list of objects + resourceToKind map[string]string // plural resource name -> Kind (e.g., "pods" -> "Pod") +} + +type rbacRule struct { + APIGroups []string + Resources []string + Verbs []string +} + +type objectRef struct { + obj *unstructured.Unstructured + kind string + namespace string + name string +} + +// builtinResourceKinds maps plural resource names to their Kind for core Kubernetes resources. +var builtinResourceKinds = map[string]string{ + "pods": "Pod", + "services": "Service", + "deployments": "Deployment", + "replicasets": "ReplicaSet", + "statefulsets": "StatefulSet", + "daemonsets": "DaemonSet", + "jobs": "Job", + "cronjobs": "CronJob", + "configmaps": "ConfigMap", + "secrets": "Secret", + "persistentvolumeclaims": "PersistentVolumeClaim", + "persistentvolumes": "PersistentVolume", + "namespaces": "Namespace", + "nodes": "Node", + "serviceaccounts": "ServiceAccount", + "ingresses": "Ingress", + "networkpolicies": "NetworkPolicy", + "roles": "Role", + "rolebindings": "RoleBinding", + "clusterroles": "ClusterRole", + "clusterrolebindings": "ClusterRoleBinding", + "events": "Event", + "endpoints": "Endpoints", + "limitranges": "LimitRange", + "resourcequotas": "ResourceQuota", + "poddisruptionbudgets": "PodDisruptionBudget", + "horizontalpodautoscalers": "HorizontalPodAutoscaler", +} + +var crdResourceKindCache = struct { + sync.Mutex + entries map[string]crdCacheEntry +}{ + entries: make(map[string]crdCacheEntry), +} + +type crdCacheEntry struct { + resourceToKind map[string]string + expiresAt time.Time +} + +const crdCacheTTL = 12 * time.Hour + +// fetchCRDResourceKinds queries the K8s API for CRDs and returns a resource→kind map. +// Results are cached per cluster for 12 hours. +func fetchCRDResourceKinds(ctx api.ScrapeContext, clusterName string) map[string]string { + crdResourceKindCache.Lock() + defer crdResourceKindCache.Unlock() + + if entry, ok := crdResourceKindCache.entries[clusterName]; ok && time.Now().Before(entry.expiresAt) { + return entry.resourceToKind + } + + resourceMap := make(map[string]string) + + if ctx.KubernetesConnection() == nil { + ctx.Debugf("no kubernetes connection available, skipping CRD lookup") + return resourceMap + } + + k8s, err := ctx.Kubernetes() + if err != nil { + ctx.Warnf("failed to get k8s client for CRD lookup: %v", err) + return resourceMap + } + + if k8s == nil || k8s.RestConfig() == nil { + ctx.Warnf("kubernetes client or rest config is nil, skipping CRD lookup") + return resourceMap + } + + cs, err := clientset.NewForConfig(k8s.RestConfig()) + if err != nil { + ctx.Warnf("failed to create apiextensions client for CRD lookup: %v", err) + return resourceMap + } + + allCRDs, err := cs.ApiextensionsV1().CustomResourceDefinitions().List(ctx, metav1.ListOptions{}) + if err != nil { + ctx.Warnf("failed to list CRDs: %v", err) + return resourceMap + } + + for _, crd := range allCRDs.Items { + plural := crd.Spec.Names.Plural + kind := crd.Spec.Names.Kind + if plural != "" && kind != "" { + resourceMap[strings.ToLower(plural)] = kind + } + } + + crdResourceKindCache.entries[clusterName] = crdCacheEntry{ + resourceToKind: resourceMap, + expiresAt: time.Now().Add(crdCacheTTL), + } + + return resourceMap +} + +func newRBACExtractor(ctx api.ScrapeContext, clusterName string, scraperID *uuid.UUID) *rbacExtractor { + if scraperID == nil { + return nil + } + + // Start with built-in resource mappings + resourceMap := make(map[string]string, len(builtinResourceKinds)) + for k, v := range builtinResourceKinds { + resourceMap[k] = v + } + + // Merge CRD mappings from the K8s API + for k, v := range fetchCRDResourceKinds(ctx, clusterName) { + resourceMap[k] = v + } + + return newRBACExtractorWithResourceMap(clusterName, scraperID, resourceMap) +} + +func newRBACExtractorWithResourceMap(clusterName string, scraperID *uuid.UUID, resourceToKind map[string]string) *rbacExtractor { + return &rbacExtractor{ + clusterName: clusterName, + scraperID: scraperID, + roles: make(map[uuid.UUID]models.ExternalRole), + users: make(map[uuid.UUID]models.ExternalUser), + groups: make(map[uuid.UUID]models.ExternalGroup), + roleRules: make(map[string][]rbacRule), + objectsByKind: make(map[string][]*objectRef), + resourceToKind: resourceToKind, + } +} + +// indexObjects builds lookup maps for all scraped objects. +func (r *rbacExtractor) indexObjects(objs []*unstructured.Unstructured) { + if r == nil { + return + } + for _, obj := range objs { + kind := obj.GetKind() + + r.objectsByKind[kind] = append(r.objectsByKind[kind], &objectRef{ + obj: obj, + kind: kind, + namespace: obj.GetNamespace(), + name: obj.GetName(), + }) + } +} + +func (r *rbacExtractor) objectKey(kind, namespace, name string) string { + return kind + "/" + namespace + "/" + name +} + +func (r *rbacExtractor) processRole(obj *unstructured.Unstructured) { + if r == nil { + return + } + kind := obj.GetKind() + if kind != "ClusterRole" && kind != "Role" { + return + } + + name := obj.GetName() + namespace := obj.GetNamespace() + + id := generateRBACID(r.clusterName, kind, namespace, name) + alias := KubernetesAlias(r.clusterName, kind, namespace, name) + + role := models.ExternalRole{ + ID: id, + Name: name, + AccountID: r.clusterName, + ScraperID: r.scraperID, + RoleType: kind, + Aliases: pq.StringArray{alias}, + CreatedAt: time.Now(), + } + + r.roles[id] = role + + // Parse and store the rules for later lookup + rules := r.parseRules(obj) + key := r.objectKey(kind, namespace, name) + r.roleRules[key] = rules +} + +func (r *rbacExtractor) parseRules(obj *unstructured.Unstructured) []rbacRule { + var rules []rbacRule + + rulesSlice, found, _ := unstructured.NestedSlice(obj.Object, "rules") + if !found { + return rules + } + + for _, ruleRaw := range rulesSlice { + ruleMap, ok := ruleRaw.(map[string]any) + if !ok { + continue + } + + rule := rbacRule{} + + if apiGroups, ok := ruleMap["apiGroups"].([]any); ok { + for _, ag := range apiGroups { + if s, ok := ag.(string); ok { + rule.APIGroups = append(rule.APIGroups, s) + } + } + } + + if resources, ok := ruleMap["resources"].([]any); ok { + for _, res := range resources { + if s, ok := res.(string); ok { + rule.Resources = append(rule.Resources, s) + } + } + } + + if verbs, ok := ruleMap["verbs"].([]any); ok { + for _, v := range verbs { + if s, ok := v.(string); ok { + rule.Verbs = append(rule.Verbs, s) + } + } + } + + rules = append(rules, rule) + } + + return rules +} + +func (r *rbacExtractor) processRoleBinding(obj *unstructured.Unstructured) { + if r == nil { + return + } + kind := obj.GetKind() + if kind != "ClusterRoleBinding" && kind != "RoleBinding" { + return + } + + bindingNamespace := obj.GetNamespace() + + // Get roleRef + roleRef, found, _ := unstructured.NestedMap(obj.Object, "roleRef") + if !found { + return + } + + roleKind, _, _ := unstructured.NestedString(roleRef, "kind") + roleName, _, _ := unstructured.NestedString(roleRef, "name") + + // For Roles, they're in the same namespace as the RoleBinding + // For ClusterRoles referenced by RoleBindings, namespace is empty + roleNamespace := "" + if roleKind == "Role" { + roleNamespace = bindingNamespace + } + + // Lookup the role's rules + roleKey := r.objectKey(roleKind, roleNamespace, roleName) + rules, hasRules := r.roleRules[roleKey] + + // Find all target resources based on the rules + targetResources := r.findTargetResources(rules, bindingNamespace, kind == "ClusterRoleBinding") + + // Compute the role alias once for the binding + roleAlias := KubernetesAlias(r.clusterName, roleKind, roleNamespace, roleName) + + // Get subjects + subjects, found, _ := unstructured.NestedSlice(obj.Object, "subjects") + if !found { + return + } + + for _, subj := range subjects { + subjMap, ok := subj.(map[string]any) + if !ok { + continue + } + + subjKind, _ := subjMap["kind"].(string) + subjName, _ := subjMap["name"].(string) + subjNamespace, _ := subjMap["namespace"].(string) + + var userAlias, groupAlias string + + switch subjKind { + case "ServiceAccount": + id := generateRBACID(r.clusterName, "ServiceAccount", subjNamespace, subjName) + alias := KubernetesAlias(r.clusterName, "ServiceAccount", subjNamespace, subjName) + userAlias = alias + + if _, exists := r.users[id]; !exists { + r.users[id] = models.ExternalUser{ + ID: id, + Name: subjName, + UserType: "ServiceAccount", + AccountID: r.clusterName, + ScraperID: *r.scraperID, + Aliases: pq.StringArray{alias}, + CreatedAt: time.Now(), + } + } + + case "User": + id := generateRBACID(r.clusterName, "User", "", subjName) + alias := KubernetesAlias(r.clusterName, "User", "", subjName) + userAlias = alias + + if _, exists := r.users[id]; !exists { + r.users[id] = models.ExternalUser{ + ID: id, + Name: subjName, + UserType: "User", + AccountID: r.clusterName, + ScraperID: *r.scraperID, + Aliases: pq.StringArray{alias}, + CreatedAt: time.Now(), + } + } + + case "Group": + id := generateRBACID(r.clusterName, "Group", "", subjName) + alias := KubernetesAlias(r.clusterName, "Group", "", subjName) + groupAlias = alias + + if _, exists := r.groups[id]; !exists { + r.groups[id] = models.ExternalGroup{ + ID: id, + Name: subjName, + GroupType: "Group", + AccountID: r.clusterName, + ScraperID: *r.scraperID, + Aliases: pq.StringArray{alias}, + CreatedAt: time.Now(), + } + } + } + + // If we have rules and target resources, create ConfigAccess for each resource + if hasRules && len(targetResources) > 0 { + for _, target := range targetResources { + subjectAlias := userAlias + if subjectAlias == "" { + subjectAlias = groupAlias + } + targetExternalID := KubernetesAlias(r.clusterName, target.kind, target.namespace, target.name) + + access := v1.ExternalConfigAccess{ + ConfigAccess: models.ConfigAccess{ + ID: generateRBACID(subjectAlias, targetExternalID, roleAlias).String(), + }, + ConfigExternalID: v1.ExternalID{ + ExternalID: targetExternalID, + ConfigType: GetConfigTypeForKind(target.kind), + }, + ExternalRoleAliases: []string{roleAlias}, + } + + if userAlias != "" { + access.ExternalUserAliases = []string{userAlias} + } + if groupAlias != "" { + access.ExternalGroupAliases = []string{groupAlias} + } + + r.access = append(r.access, access) + } + } + } +} + +// findTargetResources finds all resources that match the given RBAC rules. +func (r *rbacExtractor) findTargetResources(rules []rbacRule, bindingNamespace string, isClusterWide bool) []*objectRef { + var targets []*objectRef + + for _, rule := range rules { + for _, resourceType := range rule.Resources { + if resourceType == "*" { + continue + } + + kind, ok := r.resourceToKind[strings.ToLower(resourceType)] + if !ok { + continue + } + + for _, objRef := range r.objectsByKind[kind] { + // For namespace-scoped bindings, only include resources in the same namespace + if !isClusterWide && objRef.namespace != bindingNamespace { + continue + } + targets = append(targets, objRef) + } + } + } + + return targets +} + +// GetConfigTypeForKind returns the config type for a given Kubernetes kind +func GetConfigTypeForKind(kind string) string { + return ConfigTypePrefix + kind +} + +func (r *rbacExtractor) getRoles() []models.ExternalRole { + roles := make([]models.ExternalRole, 0, len(r.roles)) + for _, role := range r.roles { + roles = append(roles, role) + } + return roles +} + +func (r *rbacExtractor) getUsers() []models.ExternalUser { + users := make([]models.ExternalUser, 0, len(r.users)) + for _, user := range r.users { + users = append(users, user) + } + return users +} + +func (r *rbacExtractor) getGroups() []models.ExternalGroup { + groups := make([]models.ExternalGroup, 0, len(r.groups)) + for _, group := range r.groups { + groups = append(groups, group) + } + return groups +} + +func (r *rbacExtractor) getAccess() []v1.ExternalConfigAccess { + return r.access +} + +func (r *rbacExtractor) results(baseScraper v1.BaseScraper) v1.ScrapeResult { + if r == nil { + return v1.ScrapeResult{} + } + return v1.ScrapeResult{ + BaseScraper: baseScraper, + ExternalRoles: r.getRoles(), + ExternalUsers: r.getUsers(), + ExternalGroups: r.getGroups(), + ConfigAccess: r.getAccess(), + } +} + +func generateRBACID(parts ...string) uuid.UUID { + input := strings.Join(parts, "/") + gen := uuidV5.NewV5(uuidV5.NamespaceOID, input) + return uuid.UUID(gen) +} diff --git a/scrapers/kubernetes/rbac_test.go b/scrapers/kubernetes/rbac_test.go new file mode 100644 index 000000000..960745a13 --- /dev/null +++ b/scrapers/kubernetes/rbac_test.go @@ -0,0 +1,504 @@ +// ABOUTME: Tests for RBAC resource extraction (Roles, RoleBindings, and their cluster variants). +// ABOUTME: Verifies ExternalRoles, ExternalUsers, ExternalGroups, and ConfigAccess are created correctly. + +package kubernetes + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/lib/pq" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func TestRBACExtractor_ProcessRole(t *testing.T) { + clusterName := "test-cluster" + scraperID := uuid.New() + + tests := []struct { + name string + obj *unstructured.Unstructured + expectedRoleName string + expectedRoleType string + expectedAliases []string + }{ + { + name: "ClusterRole", + obj: makeClusterRole("cluster-admin", []rbacRuleSpec{{Resources: []string{"pods"}}}), + expectedRoleName: "cluster-admin", + expectedRoleType: "ClusterRole", + expectedAliases: []string{KubernetesAlias(clusterName, "ClusterRole", "", "cluster-admin")}, + }, + { + name: "Namespaced Role", + obj: makeRole("pod-reader", "default", []rbacRuleSpec{{Resources: []string{"pods"}}}), + expectedRoleName: "pod-reader", + expectedRoleType: "Role", + expectedAliases: []string{KubernetesAlias(clusterName, "Role", "default", "pod-reader")}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + extractor := testRBACExtractor(clusterName, &scraperID) + extractor.processRole(tt.obj) + + roles := extractor.getRoles() + require.Len(t, roles, 1, "expected 1 role") + + role := roles[0] + assert.Equal(t, tt.expectedRoleName, role.Name) + assert.Equal(t, tt.expectedRoleType, role.RoleType) + assert.Equal(t, clusterName, role.AccountID) + assert.Equal(t, &scraperID, role.ScraperID) + assert.Equal(t, pq.StringArray(tt.expectedAliases), role.Aliases) + }) + } +} + +func TestRBACExtractor_ProcessRoleBinding_ServiceAccount(t *testing.T) { + clusterName := "test-cluster" + scraperID := uuid.New() + + // Create test objects: a Role and some Pods in the namespace + role := makeRole("pod-reader", "default", []rbacRuleSpec{ + {APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get", "list"}}, + }) + + pod1 := makePod("pod-1", "default") + pod2 := makePod("pod-2", "default") + podOtherNS := makePod("pod-other", "other-namespace") + + binding := makeRoleBinding("my-binding", "default", "Role", "pod-reader", []subject{ + {Kind: "ServiceAccount", Name: "my-sa", Namespace: "default"}, + }) + + extractor := testRBACExtractor(clusterName, &scraperID) + extractor.indexObjects([]*unstructured.Unstructured{role, pod1, pod2, podOtherNS, binding}) + extractor.processRole(role) + extractor.processRoleBinding(binding) + + users := extractor.getUsers() + require.Len(t, users, 1, "expected 1 user") + + user := users[0] + assert.Equal(t, "my-sa", user.Name) + assert.Equal(t, "ServiceAccount", user.UserType) + assert.Equal(t, clusterName, user.AccountID) + assert.Equal(t, scraperID, user.ScraperID) + expectedUserAlias := KubernetesAlias(clusterName, "ServiceAccount", "default", "my-sa") + assert.Equal(t, pq.StringArray{expectedUserAlias}, user.Aliases) + + // Should have ConfigAccess for the 2 pods in the default namespace only + access := extractor.getAccess() + require.Len(t, access, 2, "expected 2 config access entries (one per pod in namespace)") + + expectedRoleAlias := KubernetesAlias(clusterName, "Role", "default", "pod-reader") + + // Check that access entries point to pods, not the role + for _, a := range access { + assert.Equal(t, ConfigTypePrefix+"Pod", a.ConfigExternalID.ConfigType) + assert.Equal(t, []string{expectedUserAlias}, a.ExternalUserAliases) + assert.Equal(t, []string{expectedRoleAlias}, a.ExternalRoleAliases) + assert.NotEmpty(t, a.ID, "access ID should be set") + } +} + +func TestRBACExtractor_ProcessRoleBinding_User(t *testing.T) { + clusterName := "test-cluster" + scraperID := uuid.New() + + // Create a ClusterRole that grants access to pods and services + role := makeClusterRole("cluster-admin", []rbacRuleSpec{ + {APIGroups: []string{""}, Resources: []string{"pods", "services"}, Verbs: []string{"*"}}, + }) + + pod1 := makePod("pod-1", "ns1") + svc1 := makeService("svc-1", "ns1") + + binding := makeClusterRoleBinding("admin-binding", "ClusterRole", "cluster-admin", []subject{ + {Kind: "User", Name: "admin@example.com"}, + }) + + extractor := testRBACExtractor(clusterName, &scraperID) + extractor.indexObjects([]*unstructured.Unstructured{role, pod1, svc1, binding}) + extractor.processRole(role) + extractor.processRoleBinding(binding) + + users := extractor.getUsers() + require.Len(t, users, 1, "expected 1 user") + + user := users[0] + assert.Equal(t, "admin@example.com", user.Name) + assert.Equal(t, "User", user.UserType) + expectedUserAlias := KubernetesAlias(clusterName, "User", "", "admin@example.com") + assert.Equal(t, pq.StringArray{expectedUserAlias}, user.Aliases) + + // Should have ConfigAccess for the pod and service (cluster-wide) + access := extractor.getAccess() + assert.Len(t, access, 2, "expected 2 config access entries (pod + service)") + + expectedRoleAlias := KubernetesAlias(clusterName, "ClusterRole", "", "cluster-admin") + for _, a := range access { + assert.Equal(t, []string{expectedRoleAlias}, a.ExternalRoleAliases) + assert.NotEmpty(t, a.ID, "access ID should be set") + } +} + +func TestRBACExtractor_ProcessRoleBinding_Group(t *testing.T) { + clusterName := "test-cluster" + scraperID := uuid.New() + + role := makeClusterRole("view", []rbacRuleSpec{ + {APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get", "list"}}, + }) + + pod1 := makePod("pod-1", "default") + + binding := makeClusterRoleBinding("viewers-binding", "ClusterRole", "view", []subject{ + {Kind: "Group", Name: "system:authenticated"}, + }) + + extractor := testRBACExtractor(clusterName, &scraperID) + extractor.indexObjects([]*unstructured.Unstructured{role, pod1, binding}) + extractor.processRole(role) + extractor.processRoleBinding(binding) + + groups := extractor.getGroups() + require.Len(t, groups, 1, "expected 1 group") + + group := groups[0] + assert.Equal(t, "system:authenticated", group.Name) + assert.Equal(t, "Group", group.GroupType) + assert.Equal(t, clusterName, group.AccountID) + expectedGroupAlias := KubernetesAlias(clusterName, "Group", "", "system:authenticated") + assert.Equal(t, pq.StringArray{expectedGroupAlias}, group.Aliases) + + access := extractor.getAccess() + require.Len(t, access, 1, "expected 1 config access entry") + assert.Empty(t, access[0].ExternalUserAliases) + assert.Equal(t, []string{expectedGroupAlias}, access[0].ExternalGroupAliases) + + expectedRoleAlias := KubernetesAlias(clusterName, "ClusterRole", "", "view") + assert.Equal(t, []string{expectedRoleAlias}, access[0].ExternalRoleAliases) + assert.NotEmpty(t, access[0].ID, "access ID should be set") +} + +func TestRBACExtractor_ProcessRoleBinding_MixedSubjects(t *testing.T) { + clusterName := "test-cluster" + scraperID := uuid.New() + + role := makeClusterRole("edit", []rbacRuleSpec{ + {APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"*"}}, + }) + + pod1 := makePod("pod-1", "default") + + binding := makeClusterRoleBinding("mixed-binding", "ClusterRole", "edit", []subject{ + {Kind: "ServiceAccount", Name: "ci-bot", Namespace: "ci"}, + {Kind: "User", Name: "developer@example.com"}, + {Kind: "Group", Name: "developers"}, + }) + + extractor := testRBACExtractor(clusterName, &scraperID) + extractor.indexObjects([]*unstructured.Unstructured{role, pod1, binding}) + extractor.processRole(role) + extractor.processRoleBinding(binding) + + users := extractor.getUsers() + assert.Len(t, users, 2, "expected 2 users (SA + User)") + + groups := extractor.getGroups() + assert.Len(t, groups, 1, "expected 1 group") + + // Each subject gets one ConfigAccess entry for the pod + access := extractor.getAccess() + assert.Len(t, access, 3, "expected 3 config access entries (one per subject, all pointing to same pod)") + + expectedRoleAlias := KubernetesAlias(clusterName, "ClusterRole", "", "edit") + for _, a := range access { + assert.Equal(t, []string{expectedRoleAlias}, a.ExternalRoleAliases) + assert.NotEmpty(t, a.ID, "access ID should be set") + } +} + +func TestRBACExtractor_Deduplication(t *testing.T) { + clusterName := "test-cluster" + scraperID := uuid.New() + + extractor := testRBACExtractor(clusterName, &scraperID) + + // Process the same role twice + role := makeClusterRole("cluster-admin", []rbacRuleSpec{{Resources: []string{"pods"}}}) + extractor.processRole(role) + extractor.processRole(role) + + roles := extractor.getRoles() + assert.Len(t, roles, 1, "duplicate roles should be deduplicated") +} + +func TestRBACExtractor_NamespaceScoping(t *testing.T) { + clusterName := "test-cluster" + scraperID := uuid.New() + + // A Role in namespace "default" granting access to pods + role := makeRole("pod-reader", "default", []rbacRuleSpec{ + {APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get"}}, + }) + + // Pods in different namespaces + podDefault1 := makePod("pod-1", "default") + podDefault2 := makePod("pod-2", "default") + podOther := makePod("pod-other", "other") + + // RoleBinding in default namespace + binding := makeRoleBinding("my-binding", "default", "Role", "pod-reader", []subject{ + {Kind: "User", Name: "user1"}, + }) + + extractor := testRBACExtractor(clusterName, &scraperID) + extractor.indexObjects([]*unstructured.Unstructured{role, podDefault1, podDefault2, podOther, binding}) + extractor.processRole(role) + extractor.processRoleBinding(binding) + + // Should only have access to pods in "default" namespace + access := extractor.getAccess() + assert.Len(t, access, 2, "should only have access to 2 pods in default namespace") + + for _, a := range access { + // Verify the config external ID contains "default" namespace + assert.Contains(t, a.ConfigExternalID.ExternalID, "/default/") + } +} + +func TestRBACExtractor_CRDResourceResolution(t *testing.T) { + clusterName := "test-cluster" + scraperID := uuid.New() + + // Simulate CRD resource mapping (as if fetched from K8s API) + resourceMap := make(map[string]string, len(builtinResourceKinds)) + for k, v := range builtinResourceKinds { + resourceMap[k] = v + } + resourceMap["canaries"] = "Canary" + + // A custom resource instance + canary := makeCustomResource("Canary", "my-canary", "default") + + // A ClusterRole granting access to the custom resource + role := makeClusterRole("canary-admin", []rbacRuleSpec{ + {APIGroups: []string{"flanksource.com"}, Resources: []string{"canaries"}, Verbs: []string{"*"}}, + }) + + binding := makeClusterRoleBinding("canary-binding", "ClusterRole", "canary-admin", []subject{ + {Kind: "User", Name: "ops@example.com"}, + }) + + extractor := newRBACExtractorWithResourceMap(clusterName, &scraperID, resourceMap) + extractor.indexObjects([]*unstructured.Unstructured{canary, role, binding}) + extractor.processRole(role) + extractor.processRoleBinding(binding) + + access := extractor.getAccess() + require.Len(t, access, 1, "expected 1 config access entry for the canary instance") + assert.Equal(t, ConfigTypePrefix+"Canary", access[0].ConfigExternalID.ConfigType) + assert.Equal(t, KubernetesAlias(clusterName, "Canary", "default", "my-canary"), access[0].ConfigExternalID.ExternalID) + + expectedRoleAlias := KubernetesAlias(clusterName, "ClusterRole", "", "canary-admin") + assert.Equal(t, []string{expectedRoleAlias}, access[0].ExternalRoleAliases) + assert.NotEmpty(t, access[0].ID, "access ID should be set") +} + +// Helper types and functions + +func testRBACExtractor(clusterName string, scraperID *uuid.UUID) *rbacExtractor { + resourceMap := make(map[string]string, len(builtinResourceKinds)) + for k, v := range builtinResourceKinds { + resourceMap[k] = v + } + return newRBACExtractorWithResourceMap(clusterName, scraperID, resourceMap) +} + +type subject struct { + Kind string + Name string + Namespace string +} + +type rbacRuleSpec struct { + APIGroups []string + Resources []string + Verbs []string +} + +func makeClusterRole(name string, rules []rbacRuleSpec) *unstructured.Unstructured { + rulesData := make([]any, len(rules)) + for i, r := range rules { + rule := map[string]any{} + if len(r.APIGroups) > 0 { + apiGroups := make([]any, len(r.APIGroups)) + for j, ag := range r.APIGroups { + apiGroups[j] = ag + } + rule["apiGroups"] = apiGroups + } + if len(r.Resources) > 0 { + resources := make([]any, len(r.Resources)) + for j, res := range r.Resources { + resources[j] = res + } + rule["resources"] = resources + } + if len(r.Verbs) > 0 { + verbs := make([]any, len(r.Verbs)) + for j, v := range r.Verbs { + verbs[j] = v + } + rule["verbs"] = verbs + } + rulesData[i] = rule + } + + return &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "ClusterRole", + "metadata": map[string]any{ + "uid": uuid.NewString(), + "name": name, + "creationTimestamp": time.Now().Format(time.RFC3339), + }, + "rules": rulesData, + }, + } +} + +func makeRole(name, namespace string, rules []rbacRuleSpec) *unstructured.Unstructured { + obj := makeClusterRole(name, rules) + obj.Object["kind"] = "Role" + obj.Object["metadata"].(map[string]any)["namespace"] = namespace + return obj +} + +func makeRoleBinding(name, namespace, roleKind, roleName string, subjects []subject) *unstructured.Unstructured { + subjectsMap := make([]any, len(subjects)) + for i, s := range subjects { + subj := map[string]any{ + "kind": s.Kind, + "name": s.Name, + } + if s.Namespace != "" { + subj["namespace"] = s.Namespace + } + if s.Kind == "ServiceAccount" { + subj["apiGroup"] = "" + } else { + subj["apiGroup"] = "rbac.authorization.k8s.io" + } + subjectsMap[i] = subj + } + + return &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "RoleBinding", + "metadata": map[string]any{ + "uid": uuid.NewString(), + "name": name, + "namespace": namespace, + "creationTimestamp": time.Now().Format(time.RFC3339), + }, + "subjects": subjectsMap, + "roleRef": map[string]any{ + "apiGroup": "rbac.authorization.k8s.io", + "kind": roleKind, + "name": roleName, + }, + }, + } +} + +func makeClusterRoleBinding(name, roleKind, roleName string, subjects []subject) *unstructured.Unstructured { + subjectsMap := make([]any, len(subjects)) + for i, s := range subjects { + subj := map[string]any{ + "kind": s.Kind, + "name": s.Name, + } + if s.Namespace != "" { + subj["namespace"] = s.Namespace + } + if s.Kind == "ServiceAccount" { + subj["apiGroup"] = "" + } else { + subj["apiGroup"] = "rbac.authorization.k8s.io" + } + subjectsMap[i] = subj + } + + return &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "ClusterRoleBinding", + "metadata": map[string]any{ + "uid": uuid.NewString(), + "name": name, + "creationTimestamp": time.Now().Format(time.RFC3339), + }, + "subjects": subjectsMap, + "roleRef": map[string]any{ + "apiGroup": "rbac.authorization.k8s.io", + "kind": roleKind, + "name": roleName, + }, + }, + } +} + +func makePod(name, namespace string) *unstructured.Unstructured { + return &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]any{ + "uid": uuid.NewString(), + "name": name, + "namespace": namespace, + "creationTimestamp": time.Now().Format(time.RFC3339), + }, + }, + } +} + +func makeService(name, namespace string) *unstructured.Unstructured { + return &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "Service", + "metadata": map[string]any{ + "uid": uuid.NewString(), + "name": name, + "namespace": namespace, + "creationTimestamp": time.Now().Format(time.RFC3339), + }, + }, + } +} + +func makeCustomResource(kind, name, namespace string) *unstructured.Unstructured { + return &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "flanksource.com/v1", + "kind": kind, + "metadata": map[string]any{ + "uid": uuid.NewString(), + "name": name, + "namespace": namespace, + "creationTimestamp": time.Now().Format(time.RFC3339), + }, + }, + } +} diff --git a/scrapers/run_test.go b/scrapers/run_test.go index 834f41ca0..7237009bd 100644 --- a/scrapers/run_test.go +++ b/scrapers/run_test.go @@ -1757,3 +1757,143 @@ var _ = Describe("Config access logs upsert", Ordered, func() { Expect(storedLog.CreatedAt).To(BeTemporally("~", latestAccessTime, time.Second)) }) }) + +var _ = Describe("Kubernetes RBAC config access e2e test", Ordered, func() { + var scrapeConfig v1.ScrapeConfig + var scraperCtx api.ScrapeContext + var scraperModel dutymodels.ConfigScraper + + BeforeAll(func() { + scrapeConfig = getConfigSpec("file-k8s-rbac-access") + + scModel, err := scrapeConfig.ToModel() + Expect(err).NotTo(HaveOccurred(), "failed to convert scrape config to model") + scModel.Source = dutymodels.SourceUI + + err = DefaultContext.DB().Create(&scModel).Error + Expect(err).NotTo(HaveOccurred(), "failed to create scrape config") + + scrapeConfig.SetUID(k8sTypes.UID(scModel.ID.String())) + scraperCtx = api.NewScrapeContext(DefaultContext).WithScrapeConfig(&scrapeConfig) + + scraperModel = scModel + }) + + AfterAll(func() { + err := DefaultContext.DB().Where("scraper_id = ?", scraperModel.ID).Delete(&dutymodels.ConfigAccess{}).Error + Expect(err).NotTo(HaveOccurred(), "failed to delete config access") + + err = DefaultContext.DB().Where("scraper_id = ?", scraperModel.ID).Delete(&dutymodels.ExternalGroup{}).Error + Expect(err).NotTo(HaveOccurred(), "failed to delete external groups") + + err = DefaultContext.DB().Where("scraper_id = ?", scraperModel.ID).Delete(&dutymodels.ExternalRole{}).Error + Expect(err).NotTo(HaveOccurred(), "failed to delete external roles") + + err = DefaultContext.DB().Where("scraper_id = ?", scraperModel.ID).Delete(&dutymodels.ExternalUser{}).Error + Expect(err).NotTo(HaveOccurred(), "failed to delete external users") + + err = DefaultContext.DB().Where("scraper_id = ?", scraperModel.ID).Delete(&models.ConfigItem{}).Error + Expect(err).NotTo(HaveOccurred(), "failed to delete config items") + + err = DefaultContext.DB().Delete(&scraperModel).Error + Expect(err).NotTo(HaveOccurred(), "failed to delete scrape config") + }) + + It("should scrape and save RBAC entities and config access", func() { + _, err := RunScraper(scraperCtx) + Expect(err).To(BeNil()) + }) + + It("should have saved the external role to the database", func() { + var roles []dutymodels.ExternalRole + err := DefaultContext.DB().Where("scraper_id = ?", scraperModel.ID).Find(&roles).Error + Expect(err).NotTo(HaveOccurred()) + Expect(roles).To(HaveLen(1)) + Expect(roles[0].Name).To(Equal("pod-reader")) + Expect(roles[0].RoleType).To(Equal("ClusterRole")) + Expect(roles[0].AccountID).To(Equal("test-cluster")) + }) + + It("should have saved external users to the database", func() { + var users []dutymodels.ExternalUser + err := DefaultContext.DB().Where("scraper_id = ?", scraperModel.ID).Find(&users).Error + Expect(err).NotTo(HaveOccurred()) + Expect(users).To(HaveLen(2)) + + userNames := lo.Map(users, func(u dutymodels.ExternalUser, _ int) string { return u.Name }) + Expect(userNames).To(ContainElements("my-sa", "admin@example.com")) + }) + + It("should have saved the external group to the database", func() { + var groups []dutymodels.ExternalGroup + err := DefaultContext.DB().Where("scraper_id = ?", scraperModel.ID).Find(&groups).Error + Expect(err).NotTo(HaveOccurred()) + Expect(groups).To(HaveLen(1)) + Expect(groups[0].Name).To(Equal("system:authenticated")) + Expect(groups[0].GroupType).To(Equal("Group")) + }) + + It("should have saved config access entries linking subjects to the config item via the role", func() { + var role dutymodels.ExternalRole + err := DefaultContext.DB().Where("scraper_id = ?", scraperModel.ID).First(&role).Error + Expect(err).NotTo(HaveOccurred()) + + var configAccesses []dutymodels.ConfigAccess + err = DefaultContext.DB().Where("scraper_id = ?", scraperModel.ID).Find(&configAccesses).Error + Expect(err).NotTo(HaveOccurred()) + Expect(configAccesses).To(HaveLen(3)) + + accessByID := make(map[string]dutymodels.ConfigAccess) + for _, ca := range configAccesses { + accessByID[ca.ID] = ca + } + + // All config access entries should have the role resolved + for _, id := range []string{"rbac-access-sa", "rbac-access-user", "rbac-access-group"} { + ca := accessByID[id] + Expect(ca.ExternalRoleID).NotTo(BeNil(), "external_role_id should be resolved for %s", id) + Expect(*ca.ExternalRoleID).To(Equal(role.ID), "external_role_id should match pod-reader role for %s", id) + } + + // ServiceAccount access should have user resolved + var saUser dutymodels.ExternalUser + err = DefaultContext.DB().Where("scraper_id = ? AND name = ?", scraperModel.ID, "my-sa").First(&saUser).Error + Expect(err).NotTo(HaveOccurred()) + saAccess := accessByID["rbac-access-sa"] + Expect(saAccess.ExternalUserID).NotTo(BeNil()) + Expect(*saAccess.ExternalUserID).To(Equal(saUser.ID)) + + // User access should have user resolved + var adminUser dutymodels.ExternalUser + err = DefaultContext.DB().Where("scraper_id = ? AND name = ?", scraperModel.ID, "admin@example.com").First(&adminUser).Error + Expect(err).NotTo(HaveOccurred()) + userAccess := accessByID["rbac-access-user"] + Expect(userAccess.ExternalUserID).NotTo(BeNil()) + Expect(*userAccess.ExternalUserID).To(Equal(adminUser.ID)) + + // Group access should have group resolved + var group dutymodels.ExternalGroup + err = DefaultContext.DB().Where("scraper_id = ?", scraperModel.ID).First(&group).Error + Expect(err).NotTo(HaveOccurred()) + groupAccess := accessByID["rbac-access-group"] + Expect(groupAccess.ExternalGroupID).NotTo(BeNil()) + Expect(*groupAccess.ExternalGroupID).To(Equal(group.ID)) + }) + + It("should have config access entries pointing to the correct config item", func() { + var configItem models.ConfigItem + err := DefaultContext.DB().Where("scraper_id = ? AND type = ?", scraperModel.ID, "Kubernetes::Pod").First(&configItem).Error + Expect(err).NotTo(HaveOccurred()) + + var configAccesses []dutymodels.ConfigAccess + err = DefaultContext.DB().Where("scraper_id = ?", scraperModel.ID).Find(&configAccesses).Error + Expect(err).NotTo(HaveOccurred()) + + configItemID, err := uuid.Parse(configItem.ID) + Expect(err).NotTo(HaveOccurred()) + + for _, ca := range configAccesses { + Expect(ca.ConfigID).To(Equal(configItemID), "config_id should point to the Pod config item for access %s", ca.ID) + } + }) +})