diff --git a/.github/workflows/build-and-upload.yml b/.github/workflows/build-and-upload.yml index ea22c7e..c9e037e 100644 --- a/.github/workflows/build-and-upload.yml +++ b/.github/workflows/build-and-upload.yml @@ -22,7 +22,7 @@ jobs: - name: Authenticate gooci cli run: gooci login ghcr.io --username ${{ github.actor }} --password ${{ secrets.GITHUB_TOKEN }} - name: gooci Upload Version - run: gooci upload dist/ ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}:${{github.ref_name}} + run: gooci upload --annotate="org.ccf.plugin.protocol.version=2" dist/ ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}:${{github.ref_name}} - name: gooci Upload Latest if: "!github.event.release.prerelease" - run: gooci upload dist/ ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}:latest + run: gooci upload --annotate="org.ccf.plugin.protocol.version=2" dist/ ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}:latest diff --git a/README.md b/README.md index ac7adc0..3c858f3 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ A [Continuous Compliance Framework](https://compliance-framework.github.io/docs/ Supports multiple authentication methods (EKS via AWS STS, kubeconfig for any cluster) and concurrent multi-cluster collection. +> **Breaking change in this release.** The plugin now implements the `RunnerV2` interface and evaluates policies **per individual resource** rather than per cluster. The Rego input schema has moved from `v1` (`input.clusters[...]`) to `v2` (`input.main` + `input.context`). Existing policies that read `input.clusters[...]` must be rewritten. Companion policy repos (`plugin-k8s-policies`, `plugin-k8s-opres-policies`) will be updated in a coordinated follow-up. + ## Plugin Configuration The plugin receives configuration as flat string fields from the CCF agent. All structured values are JSON-encoded strings. @@ -12,10 +14,12 @@ The plugin receives configuration as flat string fields from the CCF agent. All |---|---|---| | `clusters` | Yes | JSON array of cluster connection configs | | `resources` | Yes | JSON array of Kubernetes resource types to collect (e.g. `"nodes"`, `"pods"`, `"deployments"`) | +| `main_resources` | No | JSON array — subset of `resources` that produces per-instance subjects and evidence. Defaults to all of `resources`. Types in `resources` but not in `main_resources` are collected as policy context only. | +| `identity_labels` | No | JSON object mapping identity-label key → ordered list of `metadata.labels` keys to try. Default: `{"app_name": ["app.kubernetes.io/name", "app"]}`. First match wins; falls back to `metadata.name` when none are present. | | `namespace_include` | No | JSON array of namespaces to include (empty = all) | | `namespace_exclude` | No | JSON array of namespaces to exclude | | `policy_labels` | No | JSON object of key-value labels added to evidence metadata | -| `policy_input` | No | JSON object of custom fields merged into the Rego input document | +| `policy_input` | No | JSON object of custom fields merged into the Rego input document. Reserved keys: `schema_version`, `source`, `main`, `context`, `subject`, `fleet`. | ### Cluster Configuration @@ -31,192 +35,148 @@ Each entry in `clusters` has: | `kubeconfig` | No | kubeconfig | Path to kubeconfig file (default: `~/.kube/config`) | | `context` | No | kubeconfig | Kubeconfig context to use (default: current-context) | +## Subjects and evidence model + +During `Init`, the plugin registers one `SubjectTemplate` per entry in `main_resources` (e.g. `k8s-pods`, `k8s-nodes`). Each template has the identity label keys: + +- `cluster_name` +- `namespace` (only for namespaced resources) +- `app_name` (resolved from `metadata.labels` via `identity_labels`; falls back to `metadata.name`) +- `name` + +For cluster-scoped resources such as `nodes`, the emitted evidence labels still include `namespace` with an empty string so label contracts stay stable, but the corresponding `SubjectTemplate` omits `namespace` from its identity keys and rendering. + +During `Eval`, every concrete Kubernetes resource instance that matches a `main_resources` type becomes its own subject and receives its own evidence for every configured policy path. Policies are invoked once per `(resource instance, policy path)` pair. Evidence is batched per cluster, but may be sent via multiple `CreateEvidence` calls when the `evidenceBatchSize` threshold is reached. + ## Configuration Examples -### Single EKS Cluster +### Single EKS cluster — evidence per pod, nodes as context ```json { "clusters": "[{\"name\":\"prod\",\"region\":\"us-east-1\",\"cluster_name\":\"prod-eks\"}]", - "resources": "[\"nodes\",\"pods\",\"deployments\"]", + "resources": "[\"nodes\",\"pods\"]", + "main_resources": "[\"pods\"]", "namespace_exclude": "[\"kube-system\",\"kube-public\"]", "policy_labels": "{\"team\":\"platform\",\"environment\":\"production\"}", "policy_input": "{\"expected_azs\":[\"us-east-1a\",\"us-east-1b\",\"us-east-1c\"]}" } ``` -### EKS with Cross-Account Role Assumption +### Custom identity label ```json { - "clusters": "[{\"name\":\"prod\",\"region\":\"us-east-1\",\"cluster_name\":\"prod-eks\",\"role_arn\":\"arn:aws:iam::123456789012:role/eks-readonly\"}]", - "resources": "[\"nodes\",\"pods\"]", - "policy_input": "{\"expected_azs\":[\"us-east-1a\",\"us-east-1b\"]}" + "clusters": "[{\"name\":\"prod\",\"region\":\"us-east-1\",\"cluster_name\":\"prod-eks\"}]", + "resources": "[\"pods\"]", + "identity_labels": "{\"app_name\":[\"app.company.io/service\",\"app.kubernetes.io/name\"],\"team\":[\"team.company.io/owner\"]}" } ``` -### Local Cluster via Kubeconfig (kind, minikube, k3s) +### EKS with cross-account role assumption ```json { - "clusters": "[{\"name\":\"local-dev\",\"provider\":\"kubeconfig\"}]", - "resources": "[\"pods\",\"services\",\"deployments\"]", - "namespace_include": "[\"default\",\"app\"]" + "clusters": "[{\"name\":\"prod\",\"region\":\"us-east-1\",\"cluster_name\":\"prod-eks\",\"role_arn\":\"arn:aws:iam::123456789012:role/eks-readonly\"}]", + "resources": "[\"nodes\",\"pods\"]", + "policy_input": "{\"expected_azs\":[\"us-east-1a\",\"us-east-1b\"]}" } ``` -### Kubeconfig with Explicit Path and Context +### Local cluster via kubeconfig (kind, minikube, k3s) ```json { - "clusters": "[{\"name\":\"staging\",\"provider\":\"kubeconfig\",\"kubeconfig\":\"/etc/kube/staging.yaml\",\"context\":\"staging-admin\"}]", - "resources": "[\"pods\",\"nodes\",\"networkpolicies\"]" + "clusters": "[{\"name\":\"local-dev\",\"provider\":\"kubeconfig\"}]", + "resources": "[\"pods\",\"services\",\"deployments\"]", + "namespace_include": "[\"default\",\"app\"]" } ``` -### Multi-Cluster (Mixed Providers) +### Multi-cluster (mixed providers) ```json { "clusters": "[{\"name\":\"prod-east\",\"region\":\"us-east-1\",\"cluster_name\":\"prod-east-eks\"},{\"name\":\"prod-west\",\"region\":\"us-west-2\",\"cluster_name\":\"prod-west-eks\"},{\"name\":\"dev\",\"provider\":\"kubeconfig\",\"context\":\"kind-dev\"}]", "resources": "[\"nodes\",\"pods\"]", + "main_resources": "[\"pods\"]", "namespace_exclude": "[\"kube-system\"]", "policy_input": "{\"expected_azs\":[\"us-east-1a\",\"us-east-1b\",\"us-west-2a\",\"us-west-2b\"]}" } ``` -## Policy Input Schema +## Policy Input Schema (v2) -The plugin builds a Rego input document from collected data and passes it to each policy bundle. Policies access it via `input`. - -### Schema +Every policy evaluation receives one `main` resource and the full cluster snapshot as `context`. ```json { - "schema_version": "v1", + "schema_version": "v2", "source": "plugin-kubernetes", - "clusters": { - "": { - "name": "string", - "region": "string", - "resources": { - "": [ - { "apiVersion": "...", "kind": "...", "metadata": {...}, "spec": {...}, ... } - ] - } + "main": { /* the single Kubernetes resource being evaluated (unstructured) */ }, + "subject": { + /* stable per-resource identity metadata derived for evidence and policy use */ + }, + "context": { + "cluster": { "name": "prod", "region": "us-east-1", "provider": "eks" }, + "resources": { + "nodes": [ /* every collected node in this cluster */ ], + "pods": [ /* every collected pod in this cluster — includes the one in input.main */ ] } + }, + "fleet": { + /* multi-cluster collection metadata always included in the evaluation input */ } } ``` -Any fields from `policy_input` config are merged at the top level. Reserved keys (`schema_version`, `source`, `clusters`) cannot be overridden. +Any fields from `policy_input` config are merged at the top level. Reserved keys (`schema_version`, `source`, `main`, `context`, `subject`, `fleet`) cannot be overridden. -### Field Reference +### Field reference | Path | Type | Source | Description | |---|---|---|---| -| `input.schema_version` | string | Plugin | Always `"v1"` | +| `input.schema_version` | string | Plugin | Always `"v2"` | | `input.source` | string | Plugin | Always `"plugin-kubernetes"` | -| `input.clusters` | object | Plugin | Map of cluster name to collected data | -| `input.clusters[name].name` | string | Plugin | Cluster display name | -| `input.clusters[name].region` | string | Plugin | AWS region (empty for kubeconfig clusters) | -| `input.clusters[name].resources` | object | Plugin | Map of resource type to array of Kubernetes objects | -| `input.clusters[name].resources[type][]` | object | Plugin | Full unstructured Kubernetes resource objects | -| `input.` | any | `policy_input` | User-defined fields for policy logic | - -### Example: Full Rego Input +| `input.main` | object | Plugin | The full unstructured Kubernetes resource currently being evaluated | +| `input.subject` | object | Plugin | Normalized subject identity for the resource under evaluation (`cluster_name`, `resource_type`, `name`, `identifier`, `identity_labels`, and `namespace` for namespaced resources) | +| `input.context.cluster` | object | Plugin | `{name, region, provider}` for the cluster this resource belongs to | +| `input.context.resources` | object | Plugin | Map of resource type → array of Kubernetes objects (full cluster snapshot, including the main resource) | +| `input.fleet` | object | Plugin | Multi-cluster snapshot keyed by cluster name, each with `{cluster, resources}` | +| `input.` | any | `policy_input` | User-defined fields | -Given this config: - -```json -{ - "clusters": "[{\"name\":\"prod\",\"region\":\"us-east-1\",\"cluster_name\":\"prod-eks\"}]", - "resources": "[\"nodes\",\"pods\"]", - "policy_input": "{\"expected_azs\":[\"us-east-1a\",\"us-east-1b\",\"us-east-1c\"],\"app_label\":\"app.kubernetes.io/name\"}" -} -``` - -The Rego input document will look like: - -```json -{ - "schema_version": "v1", - "source": "plugin-kubernetes", - "expected_azs": ["us-east-1a", "us-east-1b", "us-east-1c"], - "app_label": "app.kubernetes.io/name", - "clusters": { - "prod": { - "name": "prod", - "region": "us-east-1", - "resources": { - "nodes": [ - { - "apiVersion": "v1", - "kind": "Node", - "metadata": { - "name": "ip-10-0-1-100.ec2.internal", - "labels": { - "topology.kubernetes.io/zone": "us-east-1a", - "node.kubernetes.io/instance-type": "m5.xlarge" - } - }, - "spec": { - "providerID": "aws:///us-east-1a/i-0abc123" - } - } - ], - "pods": [ - { - "apiVersion": "v1", - "kind": "Pod", - "metadata": { - "name": "web-abc123", - "namespace": "default", - "labels": { - "app.kubernetes.io/name": "web" - } - }, - "spec": { - "nodeName": "ip-10-0-1-100.ec2.internal", - "containers": [ - { "name": "web", "image": "nginx:1.25" } - ] - } - } - ] - } - } - } -} -``` - -### Writing Policies Against This Input - -Policies are Rego files that produce `violation` and metadata. Example pattern: +### Example: writing a policy ```rego -package compliance_framework.my_policy +package compliance_framework.pod_has_node_binding import rego.v1 -# Access custom policy_input fields -_expected_azs := object.get(input, "expected_azs", []) +violation contains {"remarks": msg} if { + input.main.kind == "Pod" + not input.main.spec.nodeName + msg := sprintf("Pod %q has no nodeName assigned", [input.main.metadata.name]) +} -# Iterate over clusters and their resources +# Cross-reference sibling resources via input.context violation contains {"remarks": msg} if { - some cluster_name, cluster in input.clusters - some pod in object.get(object.get(cluster, "resources", {}), "pods", []) - not pod.spec.nodeName - msg := sprintf("Cluster %q: pod %q has no nodeName assigned", [cluster_name, pod.metadata.name]) + input.main.kind == "Pod" + node_name := input.main.spec.nodeName + not node_exists(node_name) + msg := sprintf("Pod %q binds to unknown node %q", [input.main.metadata.name, node_name]) } -title := "My Compliance Check" +node_exists(name) if { + some n in input.context.resources.nodes + n.metadata.name == name +} -description := sprintf("Evaluated %d cluster(s)", [count(input.clusters)]) +title := "Pod node binding" +description := "Every pod must bind to a known node in its cluster." ``` Key patterns: -- Use `object.get(obj, key, default)` for safe field access -- Iterate clusters with `some cluster_name, cluster in input.clusters` -- Access resources via `cluster.resources.` (e.g. `cluster.resources.nodes`, `cluster.resources.pods`) -- Custom `policy_input` fields are available directly on `input` (e.g. `input.expected_azs`) +- `input.main` is always the single resource under evaluation. +- `input.context.resources.` is the full cluster snapshot; iterate with `some r in ...`. +- `input.context.cluster` gives the cluster name/region/provider for labels and messaging. +- Filter out the main resource from peer checks with `r.metadata.uid != input.main.metadata.uid`. diff --git a/config.go b/config.go index 3803bc6..3c611f6 100644 --- a/config.go +++ b/config.go @@ -9,18 +9,34 @@ import ( "github.com/compliance-framework/plugin-k8s/auth" ) +func normalizeResourceName(name string) string { + return strings.ToLower(strings.TrimSpace(name)) +} + // reservedInputKeys are top-level keys managed by the plugin that users cannot // override via policy_input. var reservedInputKeys = map[string]bool{ "schema_version": true, "source": true, - "clusters": true, + "main": true, + "subject": true, + "context": true, + "fleet": true, +} + +// defaultIdentityLabels is the fallback identity-label config used when the +// user does not supply one. app_name tries the standard Kubernetes recommended +// label first, then the legacy `app` label. +var defaultIdentityLabels = map[string][]string{ + "app_name": {"app.kubernetes.io/name", "app"}, } // PluginConfig receives string-only config from the agent gRPC interface. type PluginConfig struct { Clusters string `mapstructure:"clusters"` Resources string `mapstructure:"resources"` + MainResources string `mapstructure:"main_resources"` + IdentityLabels string `mapstructure:"identity_labels"` NamespaceInclude string `mapstructure:"namespace_include"` NamespaceExclude string `mapstructure:"namespace_exclude"` PolicyLabels string `mapstructure:"policy_labels"` @@ -31,6 +47,8 @@ type PluginConfig struct { type ParsedConfig struct { Clusters []auth.ClusterConfig Resources []string + MainResources []string + IdentityLabels map[string][]string NamespaceInclude []string NamespaceExclude []string PolicyLabels map[string]string @@ -82,10 +100,82 @@ func (c *PluginConfig) Parse() (*ParsedConfig, error) { if len(resources) == 0 { return nil, errors.New("resources must not be empty") } + resourceSet := make(map[string]bool, len(resources)) for i, r := range resources { - if strings.TrimSpace(r) == "" { + normalized := normalizeResourceName(r) + if normalized == "" { return nil, fmt.Errorf("resource at index %d is empty", i) } + if resourceSet[normalized] { + return nil, fmt.Errorf("resources contains duplicate entry %q after normalization", normalized) + } + resources[i] = normalized + resourceSet[normalized] = true + } + + // --- main_resources (optional; defaults to all resources) --- + var mainResources []string + if strings.TrimSpace(c.MainResources) != "" { + mainResourceSet := map[string]bool{} + if err := json.Unmarshal([]byte(c.MainResources), &mainResources); err != nil { + return nil, fmt.Errorf("could not parse main_resources: %w", err) + } + for i, r := range mainResources { + normalized := normalizeResourceName(r) + if normalized == "" { + return nil, fmt.Errorf("main_resources at index %d is empty", i) + } + if !resourceSet[normalized] { + return nil, fmt.Errorf("main_resources entry %q is not present in resources", r) + } + if mainResourceSet[normalized] { + return nil, fmt.Errorf("main_resources contains duplicate entry %q after normalization", normalized) + } + mainResources[i] = normalized + mainResourceSet[normalized] = true + } + } + if len(mainResources) == 0 { + mainResources = append([]string(nil), resources...) + } + + // --- identity_labels (optional; defaults to defaultIdentityLabels) --- + identityLabels := map[string][]string{} + if strings.TrimSpace(c.IdentityLabels) != "" { + if err := json.Unmarshal([]byte(c.IdentityLabels), &identityLabels); err != nil { + return nil, fmt.Errorf("could not parse identity_labels: %w", err) + } + normalizedIdentityLabels := make(map[string][]string, len(identityLabels)) + for key, candidates := range identityLabels { + trimmedKey := strings.TrimSpace(key) + if trimmedKey == "" { + return nil, errors.New("identity_labels contains an empty key") + } + if _, exists := normalizedIdentityLabels[trimmedKey]; exists { + return nil, fmt.Errorf("identity_labels contains duplicate key %q after trimming", trimmedKey) + } + if len(candidates) == 0 { + return nil, fmt.Errorf("identity_labels key %q must have at least one candidate label", trimmedKey) + } + for i, candidate := range candidates { + trimmedCandidate := strings.TrimSpace(candidate) + if trimmedCandidate == "" { + return nil, fmt.Errorf("identity_labels key %q has empty candidate at index %d", trimmedKey, i) + } + candidates[i] = trimmedCandidate + } + normalizedIdentityLabels[trimmedKey] = candidates + } + if _, ok := normalizedIdentityLabels["app_name"]; !ok { + return nil, errors.New("identity_labels must include app_name") + } + identityLabels = normalizedIdentityLabels + } + if len(identityLabels) == 0 { + identityLabels = make(map[string][]string, len(defaultIdentityLabels)) + for k, v := range defaultIdentityLabels { + identityLabels[k] = append([]string(nil), v...) + } } // --- namespace_include (optional) --- @@ -128,6 +218,8 @@ func (c *PluginConfig) Parse() (*ParsedConfig, error) { return &ParsedConfig{ Clusters: clusters, Resources: resources, + MainResources: mainResources, + IdentityLabels: identityLabels, NamespaceInclude: nsInclude, NamespaceExclude: nsExclude, PolicyLabels: policyLabels, diff --git a/config_test.go b/config_test.go index 7207415..779c5ea 100644 --- a/config_test.go +++ b/config_test.go @@ -27,6 +27,12 @@ func TestPluginConfigParse(t *testing.T) { if len(parsed.Resources) != 1 || parsed.Resources[0] != "nodes" { t.Fatalf("unexpected resources: %v", parsed.Resources) } + if len(parsed.MainResources) != 1 || parsed.MainResources[0] != "nodes" { + t.Fatalf("expected main_resources to default to resources, got %v", parsed.MainResources) + } + if got := parsed.IdentityLabels["app_name"]; len(got) == 0 || got[0] != "app.kubernetes.io/name" { + t.Fatalf("expected default identity_labels, got %v", parsed.IdentityLabels) + } if len(parsed.PolicyLabels) != 0 { t.Fatalf("expected empty policy labels") } @@ -151,6 +157,34 @@ func TestPluginConfigParse(t *testing.T) { } }) + t.Run("resources are trimmed and lowercased", func(t *testing.T) { + cfg := &PluginConfig{Clusters: validClusters, Resources: `[" Nodes ","PODS"]`, MainResources: `[" pods "]`} + parsed, err := cfg.Parse() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(parsed.Resources) != 2 || parsed.Resources[0] != "nodes" || parsed.Resources[1] != "pods" { + t.Fatalf("expected normalized resources, got %v", parsed.Resources) + } + if len(parsed.MainResources) != 1 || parsed.MainResources[0] != "pods" { + t.Fatalf("expected normalized main_resources, got %v", parsed.MainResources) + } + }) + + t.Run("duplicate resources after normalization rejected", func(t *testing.T) { + _, err := (&PluginConfig{Clusters: validClusters, Resources: `["Pods"," pods "]`}).Parse() + if err == nil || !strings.Contains(err.Error(), "duplicate entry") { + t.Fatalf("expected duplicate resource error, got: %v", err) + } + }) + + t.Run("duplicate main_resources after normalization rejected", func(t *testing.T) { + _, err := (&PluginConfig{Clusters: validClusters, Resources: `["pods","nodes"]`, MainResources: `["Pods"," pods "]`}).Parse() + if err == nil || !strings.Contains(err.Error(), "main_resources contains duplicate entry") { + t.Fatalf("expected duplicate main_resources error, got: %v", err) + } + }) + t.Run("invalid namespace_include JSON", func(t *testing.T) { _, err := (&PluginConfig{Clusters: validClusters, Resources: validResources, NamespaceInclude: "bad"}).Parse() if err == nil || !strings.Contains(err.Error(), "could not parse namespace_include") { @@ -180,10 +214,10 @@ func TestPluginConfigParse(t *testing.T) { }) t.Run("reserved key in policy_input rejected", func(t *testing.T) { - for _, key := range []string{"schema_version", "source", "clusters"} { + for _, key := range []string{"schema_version", "source", "main", "subject", "context", "fleet"} { _, err := (&PluginConfig{ - Clusters: validClusters, - Resources: validResources, + Clusters: validClusters, + Resources: validResources, PolicyInput: `{"` + key + `":"override"}`, }).Parse() if err == nil || !strings.Contains(err.Error(), "reserved key") { @@ -248,6 +282,70 @@ func TestPluginConfigParse(t *testing.T) { } }) + t.Run("main_resources subset defaults to resources", func(t *testing.T) { + cfg := &PluginConfig{Clusters: validClusters, Resources: `["nodes","pods"]`} + parsed, err := cfg.Parse() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(parsed.MainResources) != 2 { + t.Fatalf("expected main_resources to default to resources") + } + }) + + t.Run("main_resources subset honored", func(t *testing.T) { + cfg := &PluginConfig{Clusters: validClusters, Resources: `["nodes","pods"]`, MainResources: `["pods"]`} + parsed, err := cfg.Parse() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(parsed.MainResources) != 1 || parsed.MainResources[0] != "pods" { + t.Fatalf("expected MainResources=[pods], got %v", parsed.MainResources) + } + }) + + t.Run("main_resources entry not in resources rejected", func(t *testing.T) { + _, err := (&PluginConfig{Clusters: validClusters, Resources: `["pods"]`, MainResources: `["nodes"]`}).Parse() + if err == nil || !strings.Contains(err.Error(), "not present in resources") { + t.Fatalf("expected not-present error, got: %v", err) + } + }) + + t.Run("identity_labels parsed", func(t *testing.T) { + cfg := &PluginConfig{ + Clusters: validClusters, + Resources: validResources, + IdentityLabels: `{" app_name ":[" custom-label "],"env":[" environment "]}`, + } + parsed, err := cfg.Parse() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := parsed.IdentityLabels["app_name"]; len(got) != 1 || got[0] != "custom-label" { + t.Fatalf("expected app_name=[custom-label], got %v", got) + } + if _, ok := parsed.IdentityLabels["env"]; !ok { + t.Fatalf("expected env identity key") + } + if got := parsed.IdentityLabels["env"]; len(got) != 1 || got[0] != "environment" { + t.Fatalf("expected env=[environment], got %v", got) + } + }) + + t.Run("identity_labels with empty candidate list rejected", func(t *testing.T) { + _, err := (&PluginConfig{Clusters: validClusters, Resources: validResources, IdentityLabels: `{"app_name":[]}`}).Parse() + if err == nil || !strings.Contains(err.Error(), "at least one candidate") { + t.Fatalf("expected at-least-one-candidate error, got: %v", err) + } + }) + + t.Run("identity_labels without app_name rejected", func(t *testing.T) { + _, err := (&PluginConfig{Clusters: validClusters, Resources: validResources, IdentityLabels: `{"env":["environment"]}`}).Parse() + if err == nil || !strings.Contains(err.Error(), "must include app_name") { + t.Fatalf("expected app_name validation error, got: %v", err) + } + }) + t.Run("empty provider defaults to eks", func(t *testing.T) { cfg := &PluginConfig{ Clusters: validClusters, diff --git a/go.mod b/go.mod index cf38c3b..c1edba6 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/compliance-framework/plugin-k8s -go 1.25.8 +go 1.26.1 require ( github.com/aws/aws-sdk-go-v2 v1.41.4 @@ -9,7 +9,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/eks v1.81.1 github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 github.com/aws/smithy-go v1.24.2 - github.com/compliance-framework/agent v0.2.3 + github.com/compliance-framework/agent v0.4.0 github.com/hashicorp/go-hclog v1.6.3 github.com/hashicorp/go-plugin v1.7.0 github.com/mitchellh/mapstructure v1.5.0 @@ -29,10 +29,12 @@ require ( github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/compliance-framework/api v0.12.0 // indirect + github.com/compliance-framework/api v0.15.0-rc1 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/defenseunicorns/go-oscal v0.7.0 // indirect + github.com/docker/docker v28.5.2+incompatible // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/fatih/color v1.18.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect @@ -80,16 +82,16 @@ require ( go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/net v0.51.0 // indirect - golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect - golang.org/x/term v0.40.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/term v0.41.0 // indirect + golang.org/x/text v0.35.0 // indirect golang.org/x/time v0.15.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect - google.golang.org/grpc v1.79.2 // indirect + google.golang.org/grpc v1.79.3 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/go.sum b/go.sum index 451d292..ea6515f 100644 --- a/go.sum +++ b/go.sum @@ -60,10 +60,14 @@ github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqy github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/compliance-framework/agent v0.2.3 h1:f7xFSe6qOxk5Ht5tqXRQZaFeDZX8WlrZ39WrGuZ4MPo= -github.com/compliance-framework/agent v0.2.3/go.mod h1:kAPAVruXWPqM4xwSx5Fhe+xxwCC4s4SJB9Fi7tAaYZA= -github.com/compliance-framework/api v0.12.0 h1:SnSxsKoVejJB07PJSEGCvQHFQY+BOlyIjycMusPFpCA= -github.com/compliance-framework/api v0.12.0/go.mod h1:t5wpPBlgNNr3sqXoKo3Gyg+B2Qi6dcrcawGuGAaLJUs= +github.com/compliance-framework/agent v0.4.0 h1:UFAgG+w5quvu3XpU8dVPryRwkfAV0lAm0i2H/ozKY1o= +github.com/compliance-framework/agent v0.4.0/go.mod h1:0SDZLOSEXX8NTZDCeuiKYnN6ZqOdWFKaBPRss2qXl+Y= +github.com/compliance-framework/api v0.15.0-rc1 h1:I9SSnMjDWr+izQXmsZSQ1FHuaLmM8hfiO+bqDIUBLd8= +github.com/compliance-framework/api v0.15.0-rc1/go.mod h1:o4up7nNysmeqYT85ZwOOfnHaoavdBRNcnf2i9IfSYZw= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v1.0.0-rc.2 h1:0SPgaNZPVWGEi4grZdV8VRYQn78y+nm6acgLGv/QzE4= @@ -88,8 +92,8 @@ github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7c github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/docker v28.0.1+incompatible h1:FCHjSRdXhNRFjlHMTv4jUNlIBbTeRjrWfeFuJp7jpo0= -github.com/docker/docker v28.0.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= @@ -165,8 +169,6 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -254,6 +256,8 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= @@ -322,6 +326,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= @@ -414,14 +420,14 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -432,20 +438,20 @@ golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= -google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/main.go b/main.go index 400692d..d84a696 100644 --- a/main.go +++ b/main.go @@ -21,8 +21,9 @@ import ( ) const ( - schemaVersionV1 = "v1" - sourcePluginK8s = "plugin-kubernetes" + schemaVersionV2 = "v2" + sourcePluginK8s = "plugin-kubernetes" + evidenceBatchSize = 100 ) // PolicyEvaluator wraps OPA policy execution so eval loop behavior can be tested with mocks. @@ -56,7 +57,6 @@ func (e *DefaultPolicyEvaluator) Generate( activities []*proto.Activity, data interface{}, ) ([]*proto.Evidence, error) { - e.Logger.Debug("Evaluating OPA policy", "policy_path", policyPath) processor := policyManager.NewPolicyProcessor( e.Logger, labels, @@ -66,7 +66,6 @@ func (e *DefaultPolicyEvaluator) Generate( actors, activities, ) - e.Logger.Debug("policy data", "type", fmt.Sprintf("%T", data), "data", data) return processor.GenerateResults(ctx, policyPath, data) } @@ -116,10 +115,25 @@ func (p *Plugin) Configure(req *proto.ConfigureRequest) (*proto.ConfigureRespons p.Logger.Info("Kubernetes Plugin configured", "clusters", len(parsed.Clusters), "resources", parsed.Resources, + "main_resources", parsed.MainResources, ) return &proto.ConfigureResponse{}, nil } +// Init registers one SubjectTemplate per configured main resource type and +// extracts RiskTemplates from the supplied policy bundles. +func (p *Plugin) Init(req *proto.InitRequest, apiHelper runner.ApiHelper) (*proto.InitResponse, error) { + ctx := context.Background() + if p.parsedConfig == nil { + return nil, errors.New("plugin not configured") + } + + templates := buildSubjectTemplates(p.parsedConfig.MainResources) + p.Logger.Debug("Init: registering subject templates", "count", len(templates)) + + return runner.InitWithSubjectsAndRisksFromPolicies(ctx, p.Logger, req, apiHelper, templates) +} + func (p *Plugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelper) (*proto.EvalResponse, error) { ctx := context.Background() @@ -130,7 +144,6 @@ func (p *Plugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelper) (*prot return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, errors.New("no policy paths provided") } - // Collect resources from all clusters concurrently. clusterData, err := CollectAll( ctx, p.collector, @@ -144,19 +157,437 @@ func (p *Plugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelper) (*prot return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, err } - // Build the Rego input document. - regoInput := buildRegoInput(clusterData, p.parsedConfig.PolicyInput) + clusterByName := map[string]auth.ClusterConfig{} + for _, cl := range p.parsedConfig.Clusters { + clusterByName[cl.Name] = cl + } + + basePolicyLabels := map[string]string{} + maps.Copy(basePolicyLabels, p.parsedConfig.PolicyLabels) + basePolicyLabels["source"] = sourcePluginK8s + basePolicyLabels["tool"] = sourcePluginK8s + if _, exists := basePolicyLabels["provider"]; !exists { + basePolicyLabels["provider"] = inferProvider(p.parsedConfig.Clusters) + } + + actors := defaultActors() + activities := defaultActivities() - // Build evidence metadata. - labels := map[string]string{} - maps.Copy(labels, p.parsedConfig.PolicyLabels) - labels["source"] = sourcePluginK8s - labels["tool"] = sourcePluginK8s - if _, exists := labels["provider"]; !exists { - labels["provider"] = inferProvider(p.parsedConfig.Clusters) + totalEvaluatorCalls := 0 + successfulPolicyCalls := 0 + var accumulatedErrors error + flushEvidences := func(clusterName string, evidences []*proto.Evidence) error { + if len(evidences) == 0 { + return nil + } + if err := apiHelper.CreateEvidence(ctx, evidences); err != nil { + p.Logger.Error("Error creating evidence", "cluster", clusterName, "error", err) + return err + } + return nil + } + + fleetContext := buildFleetContext(clusterByName, clusterData) + + for clusterName, cluster := range clusterData { + cfg, ok := clusterByName[clusterName] + if !ok { + cfg = auth.ClusterConfig{Name: clusterName, Region: cluster.Region} + } + + clusterComponent := buildClusterComponent(cfg) + clusterInventory := buildClusterInventory(cfg) + clusterContext := buildClusterContext(cfg, cluster) + + clusterEvidences := make([]*proto.Evidence, 0) + + for _, resourceType := range p.parsedConfig.MainResources { + items, hasItems := cluster.Resources[resourceType] + if !hasItems { + p.Logger.Debug("Eval: no items collected for main_resource", "cluster", clusterName, "resource_type", resourceType) + continue + } + + for _, item := range items { + instance := newResourceInstance(cfg, resourceType, item, p.parsedConfig.IdentityLabels) + + labels := buildInstanceLabels(basePolicyLabels, instance) + subjects := buildInstanceSubjects(instance, clusterComponent) + inventoryItems := append([]*proto.InventoryItem{buildInstanceInventory(instance)}, clusterInventory...) + components := []*proto.Component{clusterComponent} + + regoInput := buildRegoInput(item, buildInputSubject(instance), clusterContext, fleetContext, p.parsedConfig.PolicyInput) + + for _, policyPath := range req.GetPolicyPaths() { + totalEvaluatorCalls++ + evidences, evalErr := p.evaluator.Generate( + ctx, + policyPath, + labels, + subjects, + components, + inventoryItems, + actors, + activities, + regoInput, + ) + if evalErr != nil { + p.Logger.Warn("Policy evaluation failed", "policy_path", policyPath, "resource", instance.Name, "namespace", instance.Namespace, "cluster", clusterName, "error", evalErr) + resourceLocation := fmt.Sprintf("%s/%s", clusterName, instance.Name) + if instance.Namespace != "" { + resourceLocation = fmt.Sprintf("%s/%s/%s", clusterName, instance.Namespace, instance.Name) + } + accumulatedErrors = errors.Join(accumulatedErrors, fmt.Errorf("policy %s [%s]: %w", policyPath, resourceLocation, evalErr)) + continue + } + clusterEvidences = append(clusterEvidences, evidences...) + if len(clusterEvidences) >= evidenceBatchSize { + if sendErr := flushEvidences(clusterName, clusterEvidences); sendErr != nil { + return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, sendErr + } + clusterEvidences = clusterEvidences[:0] + } + successfulPolicyCalls++ + } + } + } + + if len(clusterEvidences) > 0 { + if sendErr := flushEvidences(clusterName, clusterEvidences); sendErr != nil { + return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, sendErr + } + } + } + + if totalEvaluatorCalls > 0 && successfulPolicyCalls == 0 { + if accumulatedErrors == nil { + accumulatedErrors = errors.New("policy evaluation failed for all paths") + } + return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, accumulatedErrors + } + + return &proto.EvalResponse{Status: proto.ExecutionStatus_SUCCESS}, nil +} + +// resourceInstance is the per-resource state used to build labels, subjects and inventory. +type resourceInstance struct { + ClusterName string + ResourceType string + Namespace string // empty for cluster-scoped + Name string + IdentityLabels map[string]string // derived from metadata.labels via config +} + +func newResourceInstance(cluster auth.ClusterConfig, resourceType string, resource map[string]interface{}, identityCfg map[string][]string) *resourceInstance { + namespace, name := extractResourceIdentity(resource) + return &resourceInstance{ + ClusterName: cluster.Name, + ResourceType: resourceType, + Namespace: namespace, + Name: name, + IdentityLabels: resolveIdentityLabels(resource, identityCfg), + } +} + +// extractResourceIdentity reads metadata.name and metadata.namespace from an unstructured resource. +func extractResourceIdentity(resource map[string]interface{}) (namespace, name string) { + meta, ok := resource["metadata"].(map[string]interface{}) + if !ok { + return "", "" + } + if n, ok := meta["name"].(string); ok { + name = n + } + if ns, ok := meta["namespace"].(string); ok { + namespace = ns + } + return namespace, name +} + +// resolveIdentityLabels resolves each configured identity key by walking the +// candidate list against metadata.labels on the resource; falls back to +// metadata.name when none match. The resolved value may still be empty if +// metadata.name is missing or empty. +func resolveIdentityLabels(resource map[string]interface{}, config map[string][]string) map[string]string { + resolved := make(map[string]string, len(config)) + var resourceName string + labelSources := extractLabelSources(resource) + + if meta, ok := resource["metadata"].(map[string]interface{}); ok { + if n, ok := meta["name"].(string); ok { + resourceName = n + } } - actors := []*proto.OriginActor{ + for key, candidates := range config { + value := "" + for _, candidate := range candidates { + for _, labels := range labelSources { + if v, ok := labels[candidate].(string); ok && v != "" { + value = v + break + } + } + if value != "" { + break + } + } + if value == "" { + value = resourceName + } + resolved[key] = value + } + return resolved +} + +func extractLabelSources(resource map[string]interface{}) []map[string]interface{} { + labelSources := make([]map[string]interface{}, 0, 3) + + if meta, ok := resource["metadata"].(map[string]interface{}); ok { + if labels, ok := meta["labels"].(map[string]interface{}); ok { + labelSources = append(labelSources, labels) + } + } + + if spec, ok := resource["spec"].(map[string]interface{}); ok { + if template, ok := spec["template"].(map[string]interface{}); ok { + if templateMeta, ok := template["metadata"].(map[string]interface{}); ok { + if labels, ok := templateMeta["labels"].(map[string]interface{}); ok { + labelSources = append(labelSources, labels) + } + } + } + if selector, ok := spec["selector"].(map[string]interface{}); ok { + if matchLabels, ok := selector["matchLabels"].(map[string]interface{}); ok { + labelSources = append(labelSources, matchLabels) + } + } + } + + return labelSources +} + +// buildInstanceLabels merges base policy labels with per-instance identity labels. +func buildInstanceLabels(base map[string]string, instance *resourceInstance) map[string]string { + labels := make(map[string]string, len(base)+len(instance.IdentityLabels)+4) + maps.Copy(labels, base) + labels["cluster_name"] = instance.ClusterName + labels["resource_type"] = instance.ResourceType + labels["name"] = instance.Name + labels["namespace"] = instance.Namespace + // Identity labels (e.g. app_name) may override nothing — but a user-provided + // policy_labels entry for the same key stays authoritative. Merge them LAST + // only when not already set. + for k, v := range instance.IdentityLabels { + if _, exists := labels[k]; !exists { + labels[k] = v + } + } + return labels +} + +// resourceInstanceIdentifier composes the stable subject identifier for a single resource. +func resourceInstanceIdentifier(instance *resourceInstance) string { + appIdentity := instance.IdentityLabels["app_name"] + if appIdentity == "" { + appIdentity = instance.Name + } + parts := []string{ + "k8s-" + sanitizeIdentifier(instance.ResourceType), + sanitizeIdentifier(instance.ClusterName), + } + if instance.Namespace != "" { + parts = append(parts, sanitizeIdentifier(instance.Namespace)) + } + parts = append(parts, sanitizeIdentifier(appIdentity)) + parts = append(parts, sanitizeIdentifier(instance.Name)) + return strings.Join(parts, "/") +} + +func buildInstanceSubjects(instance *resourceInstance, clusterComponent *proto.Component) []*proto.Subject { + return []*proto.Subject{ + { + Type: proto.SubjectType_SUBJECT_TYPE_COMPONENT, + Identifier: resourceInstanceIdentifier(instance), + }, + { + Type: proto.SubjectType_SUBJECT_TYPE_COMPONENT, + Identifier: clusterComponent.GetIdentifier(), + }, + } +} + +func buildInstanceInventory(instance *resourceInstance) *proto.InventoryItem { + props := []*proto.Property{ + {Name: "cluster_name", Value: instance.ClusterName}, + {Name: "resource_type", Value: instance.ResourceType}, + {Name: "name", Value: instance.Name}, + } + if instance.Namespace != "" { + props = append(props, &proto.Property{Name: "namespace", Value: instance.Namespace}) + } + for k, v := range instance.IdentityLabels { + props = append(props, &proto.Property{Name: k, Value: v}) + } + + title := fmt.Sprintf("Kubernetes %s %s", instance.ResourceType, instance.Name) + if instance.Namespace != "" { + title = fmt.Sprintf("Kubernetes %s %s/%s", instance.ResourceType, instance.Namespace, instance.Name) + } + + return &proto.InventoryItem{ + Identifier: resourceInstanceIdentifier(instance), + Type: "k8s-" + strings.ToLower(instance.ResourceType), + Title: title, + Props: props, + } +} + +func buildClusterComponent(cluster auth.ClusterConfig) *proto.Component { + clusterID := fmt.Sprintf("k8s-cluster/%s", sanitizeIdentifier(cluster.Name)) + description := fmt.Sprintf("Kubernetes cluster %q", cluster.Name) + if provider := cluster.EffectiveProvider(); provider != "" { + description = fmt.Sprintf("%s using provider %s", description, provider) + } + if cluster.ClusterName != "" { + description = fmt.Sprintf("%s (cluster name %q)", description, cluster.ClusterName) + } + if cluster.Region != "" { + description = fmt.Sprintf("%s in region %s", description, cluster.Region) + } + return &proto.Component{ + Identifier: clusterID, + Type: "service", + Title: fmt.Sprintf("Kubernetes Cluster: %s", cluster.Name), + Description: description, + Purpose: "Kubernetes cluster providing resource data for compliance evaluation.", + } +} + +func buildClusterInventory(cluster auth.ClusterConfig) []*proto.InventoryItem { + clusterID := fmt.Sprintf("k8s-cluster/%s", sanitizeIdentifier(cluster.Name)) + return []*proto.InventoryItem{ + { + Identifier: clusterID, + Type: "k8s-cluster", + Title: fmt.Sprintf("Kubernetes Cluster %s", cluster.Name), + ImplementedComponents: []*proto.InventoryItemImplementedComponent{ + {Identifier: clusterID}, + }, + }, + } +} + +func buildInputSubject(instance *resourceInstance) map[string]interface{} { + identityLabels := make(map[string]interface{}, len(instance.IdentityLabels)) + for k, v := range instance.IdentityLabels { + identityLabels[k] = v + } + + subject := map[string]interface{}{ + "cluster_name": instance.ClusterName, + "resource_type": instance.ResourceType, + "name": instance.Name, + "identifier": resourceInstanceIdentifier(instance), + "identity_labels": identityLabels, + } + if instance.Namespace != "" { + subject["namespace"] = instance.Namespace + } + return subject +} + +// buildClusterContext assembles the cluster metadata + raw resource snapshot +// exposed to policies as input.context. +func buildClusterContext(cluster auth.ClusterConfig, data *ClusterResources) map[string]interface{} { + return map[string]interface{}{ + "cluster": map[string]interface{}{ + "name": cluster.Name, + "region": cluster.Region, + "provider": cluster.EffectiveProvider(), + }, + "resources": data.Resources, + } +} + +func buildFleetContext( + clusterByName map[string]auth.ClusterConfig, + clusterData map[string]*ClusterResources, +) map[string]interface{} { + clusters := make(map[string]interface{}, len(clusterData)) + + for clusterName, data := range clusterData { + cfg, ok := clusterByName[clusterName] + if !ok { + cfg = auth.ClusterConfig{Name: clusterName, Region: data.Region} + } + + clusterInfo := map[string]interface{}{ + "name": cfg.Name, + "region": cfg.Region, + "provider": cfg.EffectiveProvider(), + } + clusters[clusterName] = map[string]interface{}{ + "cluster": clusterInfo, + "resources": data.Resources, + } + } + + return map[string]interface{}{ + "clusters": clusters, + } +} + +// buildRegoInput shapes the per-resource Rego input document. +func buildRegoInput(main map[string]interface{}, subject map[string]interface{}, clusterContext map[string]interface{}, fleet map[string]interface{}, policyInput map[string]interface{}) map[string]interface{} { + input := map[string]interface{}{ + "schema_version": schemaVersionV2, + "source": sourcePluginK8s, + "main": main, + "subject": subject, + "context": clusterContext, + "fleet": fleet, + } + for k, v := range policyInput { + input[k] = v + } + return input +} + +// buildSubjectTemplates produces one SubjectTemplate per main resource type. +// Templates always include namespace in the label schema/identity contract, and +// use conditional rendering so cluster-scoped resources with empty namespace do +// not produce awkward titles or descriptions. +func buildSubjectTemplates(mainResources []string) []*proto.SubjectTemplate { + templates := make([]*proto.SubjectTemplate, 0, len(mainResources)) + for _, resourceType := range mainResources { + templateName := "k8s-" + strings.ToLower(resourceType) + identityKeys := []string{"cluster_name", "namespace", "app_name", "name"} + labelSchema := []*proto.SubjectLabelSchema{ + {Key: "cluster_name", Description: "Name of the Kubernetes cluster this resource belongs to"}, + {Key: "namespace", Description: "Namespace of the resource for namespaced resources; empty for cluster-scoped resources"}, + {Key: "app_name", Description: "Application name resolved from metadata.labels via identity_labels config, falling back to metadata.name"}, + {Key: "name", Description: "Value of metadata.name on the Kubernetes resource"}, + {Key: "resource_type", Description: "Kubernetes resource type (e.g. pods, nodes, deployments)"}, + } + titleTemplate := fmt.Sprintf("Kubernetes %s {{ if .namespace }}{{ .namespace }}/{{ end }}{{ .name }} in {{ .cluster_name }}", resourceType) + descriptionTemplate := fmt.Sprintf("Kubernetes %s %s in cluster {{ .cluster_name }}{{ if .namespace }} under namespace {{ .namespace }}{{ end }}", resourceType, "{{ .name }}") + + templates = append(templates, &proto.SubjectTemplate{ + Name: templateName, + Type: proto.SubjectType_SUBJECT_TYPE_COMPONENT, + TitleTemplate: titleTemplate, + DescriptionTemplate: descriptionTemplate, + PurposeTemplate: fmt.Sprintf("Individual Kubernetes %s instance evaluated by the Kubernetes plugin.", resourceType), + IdentityLabelKeys: identityKeys, + LabelSchema: labelSchema, + }) + } + return templates +} + +func defaultActors() []*proto.OriginActor { + return []*proto.OriginActor{ { Title: "The Continuous Compliance Framework", Type: "assessment-platform", @@ -180,35 +611,10 @@ func (p *Plugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelper) (*prot }, }, } +} - var clusterComponents []*proto.Component - var clusterInventory []*proto.InventoryItem - var subjects []*proto.Subject - - for _, cl := range p.parsedConfig.Clusters { - clusterID := fmt.Sprintf("k8s-cluster/%s", sanitizeIdentifier(cl.Name)) - clusterComponents = append(clusterComponents, &proto.Component{ - Identifier: clusterID, - Type: "service", - Title: fmt.Sprintf("Kubernetes Cluster: %s", cl.Name), - Description: fmt.Sprintf("Kubernetes cluster %q in region %s", cl.ClusterName, cl.Region), - Purpose: "Kubernetes cluster providing resource data for compliance evaluation.", - }) - clusterInventory = append(clusterInventory, &proto.InventoryItem{ - Identifier: clusterID, - Type: "k8s-cluster", - Title: fmt.Sprintf("Kubernetes Cluster %s", cl.Name), - ImplementedComponents: []*proto.InventoryItemImplementedComponent{ - {Identifier: clusterID}, - }, - }) - subjects = append(subjects, &proto.Subject{ - Type: proto.SubjectType_SUBJECT_TYPE_COMPONENT, - Identifier: clusterID, - }) - } - - activities := []*proto.Activity{ +func defaultActivities() []*proto.Activity { + return []*proto.Activity{ { Title: "Collect Kubernetes Cluster Resources", Steps: []*proto.Step{ @@ -220,67 +626,11 @@ func (p *Plugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelper) (*prot { Title: "Evaluate OPA Policy Bundles", Steps: []*proto.Step{ - {Title: "Build Rego Input", Description: "Combine cluster data with user-provided policy input."}, - {Title: "Evaluate Policies", Description: "Run policy bundles against the combined Rego input document."}, + {Title: "Build Rego Input", Description: "Shape per-resource Rego input with main + cluster context."}, + {Title: "Evaluate Policies", Description: "Run policy bundles against each main resource instance."}, }, }, } - - // Evaluate each policy path. - allEvidences := make([]*proto.Evidence, 0) - var accumulatedErrors error - successfulRuns := 0 - - for _, policyPath := range req.GetPolicyPaths() { - evidences, evalErr := p.evaluator.Generate( - ctx, - policyPath, - labels, - subjects, - clusterComponents, - clusterInventory, - actors, - activities, - regoInput, - ) - allEvidences = append(allEvidences, evidences...) - if evalErr != nil { - p.Logger.Warn("Policy evaluation failed", "policy_path", policyPath, "error", evalErr) - accumulatedErrors = errors.Join(accumulatedErrors, fmt.Errorf("policy %s: %w", policyPath, evalErr)) - continue - } - successfulRuns++ - } - - if len(allEvidences) > 0 { - if err := apiHelper.CreateEvidence(ctx, allEvidences); err != nil { - p.Logger.Error("Error creating evidence", "error", err) - return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, err - } - } - - if successfulRuns == 0 && len(allEvidences) == 0 { - if accumulatedErrors == nil { - accumulatedErrors = errors.New("policy evaluation failed for all paths") - } - return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, accumulatedErrors - } - - return &proto.EvalResponse{Status: proto.ExecutionStatus_SUCCESS}, nil -} - -// buildRegoInput constructs the Rego input document from cluster data and user-provided policy input. -func buildRegoInput(clusters map[string]*ClusterResources, policyInput map[string]interface{}) map[string]interface{} { - input := map[string]interface{}{ - "schema_version": schemaVersionV1, - "source": sourcePluginK8s, - "clusters": clusters, - } - // Merge user-provided policy_input keys (reserved keys are already rejected at config time). - for k, v := range policyInput { - input[k] = v - } - return input } // inferProvider returns the provider label based on cluster configs. @@ -352,7 +702,7 @@ func main() { goplugin.Serve(&goplugin.ServeConfig{ HandshakeConfig: runner.HandshakeConfig, Plugins: map[string]goplugin.Plugin{ - "runner": &runner.RunnerGRPCPlugin{Impl: plugin}, + "runner": &runner.RunnerV2GRPCPlugin{Impl: plugin}, }, GRPCServer: goplugin.DefaultGRPCServer, }) diff --git a/main_test.go b/main_test.go index 6b0ccf6..43c1c0f 100644 --- a/main_test.go +++ b/main_test.go @@ -13,10 +13,18 @@ import ( "github.com/hashicorp/go-hclog" ) +type evalCapture struct { + policyPath string + labels map[string]string + subjects []*proto.Subject + inventory []*proto.InventoryItem + data interface{} +} + type fakePolicyEvaluator struct { - calls []string - failPaths map[string]bool - labelsSeen []map[string]string + calls []evalCapture + failPaths map[string]bool + failPathsWithEvidence map[string]bool } func (f *fakePolicyEvaluator) Generate( @@ -30,40 +38,87 @@ func (f *fakePolicyEvaluator) Generate( activities []*proto.Activity, data interface{}, ) ([]*proto.Evidence, error) { - f.calls = append(f.calls, policyPath) copiedLabels := map[string]string{} for k, v := range labels { copiedLabels[k] = v } - f.labelsSeen = append(f.labelsSeen, copiedLabels) + f.calls = append(f.calls, evalCapture{ + policyPath: policyPath, + labels: copiedLabels, + subjects: subjects, + inventory: inventory, + data: data, + }) if f.failPaths != nil && f.failPaths[policyPath] { return nil, errors.New("forced evaluator error") } - return []*proto.Evidence{{UUID: fmt.Sprintf("ev-%s", policyPath), Labels: labels}}, nil + if f.failPathsWithEvidence != nil && f.failPathsWithEvidence[policyPath] { + return []*proto.Evidence{{UUID: fmt.Sprintf("ev-%s-%d", policyPath, len(f.calls)), Labels: copiedLabels}}, errors.New("forced evaluator error") + } + return []*proto.Evidence{{UUID: fmt.Sprintf("ev-%s-%d", policyPath, len(f.calls)), Labels: copiedLabels}}, nil } type fakeAPIHelper struct { - calls int - evidence []*proto.Evidence - err error + createCalls int + evidence []*proto.Evidence + createErr error + subjectTemplates []*proto.SubjectTemplate + riskTemplatesBy map[string][]*proto.RiskTemplate } func (f *fakeAPIHelper) CreateEvidence(ctx context.Context, evidence []*proto.Evidence) error { - f.calls++ + f.createCalls++ f.evidence = append(f.evidence, evidence...) - return f.err + return f.createErr +} + +func (f *fakeAPIHelper) UpsertSubjectTemplates(ctx context.Context, templates []*proto.SubjectTemplate) error { + f.subjectTemplates = append(f.subjectTemplates, templates...) + return nil +} + +func (f *fakeAPIHelper) UpsertRiskTemplates(ctx context.Context, packageName string, templates []*proto.RiskTemplate) error { + if f.riskTemplatesBy == nil { + f.riskTemplatesBy = map[string][]*proto.RiskTemplate{} + } + f.riskTemplatesBy[packageName] = append(f.riskTemplatesBy[packageName], templates...) + return nil +} + +func newPodItem(name, namespace, appLabel string) map[string]interface{} { + meta := map[string]interface{}{ + "name": name, + "namespace": namespace, + } + if appLabel != "" { + meta["labels"] = map[string]interface{}{"app.kubernetes.io/name": appLabel} + } + return map[string]interface{}{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": meta, + } +} + +func newNodeItem(name string) map[string]interface{} { + return map[string]interface{}{ + "apiVersion": "v1", + "kind": "Node", + "metadata": map[string]interface{}{"name": name}, + } } func TestEvalLoopBehavior(t *testing.T) { - t.Run("successful collection and evaluation", func(t *testing.T) { + t.Run("per-resource evaluation emits one evidence per (instance, policy)", func(t *testing.T) { collector := &fakeCollector{ results: map[string]*ClusterResources{ "prod": { Name: "prod", Region: "us-east-1", Resources: map[string][]map[string]interface{}{ - "nodes": {{"metadata": map[string]interface{}{"name": "node-1"}}}, + "pods": {newPodItem("api-1", "app", "api"), newPodItem("worker-1", "app", "worker")}, + "nodes": {newNodeItem("node-1")}, }, }, }, @@ -74,10 +129,12 @@ func TestEvalLoopBehavior(t *testing.T) { plugin := &Plugin{ Logger: hclog.NewNullLogger(), parsedConfig: &ParsedConfig{ - Clusters: []auth.ClusterConfig{{Name: "prod", Region: "us-east-1", ClusterName: "prod-eks"}}, - Resources: []string{"nodes"}, - PolicyLabels: map[string]string{"team": "platform"}, - PolicyInput: map[string]interface{}{"expected_azs": []interface{}{"us-east-1a", "us-east-1b", "us-east-1c"}}, + Clusters: []auth.ClusterConfig{{Name: "prod", Region: "us-east-1", ClusterName: "prod-eks"}}, + Resources: []string{"pods", "nodes"}, + MainResources: []string{"pods"}, + IdentityLabels: defaultIdentityLabels, + PolicyLabels: map[string]string{"team": "platform"}, + PolicyInput: map[string]interface{}{"min_replicas": float64(3)}, }, collector: collector, evaluator: evaluator, @@ -90,21 +147,142 @@ func TestEvalLoopBehavior(t *testing.T) { if resp.GetStatus() != proto.ExecutionStatus_SUCCESS { t.Fatalf("expected success, got %s", resp.GetStatus().String()) } - if len(evaluator.calls) != 2 { - t.Fatalf("expected 2 evaluator calls, got %d", len(evaluator.calls)) + // 2 pods × 2 policies + if len(evaluator.calls) != 4 { + t.Fatalf("expected 4 evaluator calls, got %d", len(evaluator.calls)) + } + if apiHelper.createCalls != 1 { + t.Fatalf("expected 1 batched CreateEvidence call per cluster, got %d", apiHelper.createCalls) + } + if len(apiHelper.evidence) != 4 { + t.Fatalf("expected 4 evidences, got %d", len(apiHelper.evidence)) + } + + // Verify labels and input shape for the first call. + first := evaluator.calls[0] + if first.labels["cluster_name"] != "prod" { + t.Fatalf("expected cluster_name=prod, got %q", first.labels["cluster_name"]) + } + if first.labels["resource_type"] != "pods" { + t.Fatalf("expected resource_type=pods, got %q", first.labels["resource_type"]) + } + if first.labels["namespace"] != "app" { + t.Fatalf("expected namespace=app, got %q", first.labels["namespace"]) + } + if first.labels["app_name"] == "" { + t.Fatalf("expected app_name resolved from label") + } + input, ok := first.data.(map[string]interface{}) + if !ok { + t.Fatalf("expected map input, got %T", first.data) + } + if input["schema_version"] != schemaVersionV2 { + t.Fatalf("expected schema_version v2") + } + if _, ok := input["main"].(map[string]interface{}); !ok { + t.Fatalf("expected input.main to be a map") + } + subjectPayload, ok := input["subject"].(map[string]interface{}) + if !ok { + t.Fatalf("expected input.subject to be a map") + } + if subjectPayload["cluster_name"] != "prod" { + t.Fatalf("expected subject.cluster_name=prod, got %v", subjectPayload["cluster_name"]) } - if apiHelper.calls != 1 { - t.Fatalf("expected 1 CreateEvidence call, got %d", apiHelper.calls) + if subjectPayload["resource_type"] != "pods" { + t.Fatalf("expected subject.resource_type=pods, got %v", subjectPayload["resource_type"]) } - if len(apiHelper.evidence) != 2 { - t.Fatalf("expected 2 evidences, got %d", len(apiHelper.evidence)) + ctxPayload, ok := input["context"].(map[string]interface{}) + if !ok { + t.Fatalf("expected input.context to be a map") + } + if _, ok := ctxPayload["cluster"].(map[string]interface{}); !ok { + t.Fatalf("expected context.cluster map") + } + resources, ok := ctxPayload["resources"].(map[string][]map[string]interface{}) + if !ok { + t.Fatalf("expected context.resources map") + } + if len(resources["pods"]) != 2 || len(resources["nodes"]) != 1 { + t.Fatalf("expected full cluster snapshot in context, got pods=%d nodes=%d", len(resources["pods"]), len(resources["nodes"])) + } + fleetPayload, ok := input["fleet"].(map[string]interface{}) + if !ok { + t.Fatalf("expected input.fleet to be a map") + } + fleetClusters, ok := fleetPayload["clusters"].(map[string]interface{}) + if !ok { + t.Fatalf("expected fleet.clusters map") + } + prodCluster, ok := fleetClusters["prod"].(map[string]interface{}) + if !ok { + t.Fatalf("expected fleet.clusters.prod map") + } + prodResources, ok := prodCluster["resources"].(map[string][]map[string]interface{}) + if !ok { + t.Fatalf("expected fleet cluster resources map") + } + if len(prodResources["pods"]) != 2 || len(prodResources["nodes"]) != 1 { + t.Fatalf("expected fleet cluster snapshot, got pods=%d nodes=%d", len(prodResources["pods"]), len(prodResources["nodes"])) + } + if input["min_replicas"].(float64) != 3 { + t.Fatalf("expected policy_input merged") + } + + // Each call should have 2 subjects: the resource and the cluster. + if len(first.subjects) != 2 { + t.Fatalf("expected 2 subjects per evidence, got %d", len(first.subjects)) + } + if first.subjects[0].GetType() != proto.SubjectType_SUBJECT_TYPE_COMPONENT { + t.Fatalf("expected per-resource subject type component, got %v", first.subjects[0].GetType()) + } + }) + + t.Run("cluster-scoped resource has empty namespace label", func(t *testing.T) { + collector := &fakeCollector{ + results: map[string]*ClusterResources{ + "prod": { + Name: "prod", + Region: "us-east-1", + Resources: map[string][]map[string]interface{}{"nodes": {newNodeItem("n1")}}, + }, + }, + } + evaluator := &fakePolicyEvaluator{} + plugin := &Plugin{ + Logger: hclog.NewNullLogger(), + parsedConfig: &ParsedConfig{ + Clusters: []auth.ClusterConfig{{Name: "prod", Region: "us-east-1", ClusterName: "prod-eks"}}, + Resources: []string{"nodes"}, + MainResources: []string{"nodes"}, + IdentityLabels: defaultIdentityLabels, + }, + collector: collector, + evaluator: evaluator, + } + _, err := plugin.Eval(&proto.EvalRequest{PolicyPaths: []string{"bundle-a"}}, &fakeAPIHelper{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(evaluator.calls) != 1 { + t.Fatalf("expected 1 call, got %d", len(evaluator.calls)) + } + if evaluator.calls[0].labels["namespace"] != "" { + t.Fatalf("cluster-scoped resource should have empty namespace label, got %q", evaluator.calls[0].labels["namespace"]) + } + // Fallback: app_name should equal the node's name when no label matches. + if evaluator.calls[0].labels["app_name"] != "n1" { + t.Fatalf("expected app_name fallback to name 'n1', got %q", evaluator.calls[0].labels["app_name"]) } }) t.Run("fails when all policy evaluations fail", func(t *testing.T) { collector := &fakeCollector{ results: map[string]*ClusterResources{ - "prod": {Name: "prod", Region: "us-east-1", Resources: map[string][]map[string]interface{}{"nodes": {}}}, + "prod": { + Name: "prod", Region: "us-east-1", + Resources: map[string][]map[string]interface{}{"pods": {newPodItem("p1", "app", "api")}}, + }, }, } evaluator := &fakePolicyEvaluator{failPaths: map[string]bool{"bundle-a": true}} @@ -113,10 +291,10 @@ func TestEvalLoopBehavior(t *testing.T) { plugin := &Plugin{ Logger: hclog.NewNullLogger(), parsedConfig: &ParsedConfig{ - Clusters: []auth.ClusterConfig{{Name: "prod", Region: "us-east-1", ClusterName: "prod-eks"}}, - Resources: []string{"nodes"}, - PolicyLabels: map[string]string{}, - PolicyInput: map[string]interface{}{}, + Clusters: []auth.ClusterConfig{{Name: "prod", Region: "us-east-1", ClusterName: "prod-eks"}}, + Resources: []string{"pods"}, + MainResources: []string{"pods"}, + IdentityLabels: defaultIdentityLabels, }, collector: collector, evaluator: evaluator, @@ -127,25 +305,35 @@ func TestEvalLoopBehavior(t *testing.T) { t.Fatalf("expected eval failure") } if resp.GetStatus() != proto.ExecutionStatus_FAILURE { - t.Fatalf("expected failure status, got %s", resp.GetStatus().String()) + t.Fatalf("expected failure status") } - if apiHelper.calls != 0 { - t.Fatalf("expected no CreateEvidence calls, got %d", apiHelper.calls) + if apiHelper.createCalls != 0 { + t.Fatalf("expected no CreateEvidence calls, got %d", apiHelper.createCalls) + } + if strings.Contains(err.Error(), "prod//") { + t.Fatalf("expected cluster-scoped resource location without empty namespace, got: %v", err) } }) - t.Run("collection failure returns error", func(t *testing.T) { - collector := &fakeCollector{err: errors.New("auth failure")} - evaluator := &fakePolicyEvaluator{} + t.Run("does not publish evidence returned alongside evaluator errors", func(t *testing.T) { + collector := &fakeCollector{ + results: map[string]*ClusterResources{ + "prod": { + Name: "prod", Region: "us-east-1", + Resources: map[string][]map[string]interface{}{"pods": {newPodItem("p1", "app", "api")}}, + }, + }, + } + evaluator := &fakePolicyEvaluator{failPathsWithEvidence: map[string]bool{"bundle-a": true}} apiHelper := &fakeAPIHelper{} plugin := &Plugin{ Logger: hclog.NewNullLogger(), parsedConfig: &ParsedConfig{ - Clusters: []auth.ClusterConfig{{Name: "prod", Region: "us-east-1", ClusterName: "prod-eks"}}, - Resources: []string{"nodes"}, - PolicyLabels: map[string]string{}, - PolicyInput: map[string]interface{}{}, + Clusters: []auth.ClusterConfig{{Name: "prod", Region: "us-east-1", ClusterName: "prod-eks"}}, + Resources: []string{"pods"}, + MainResources: []string{"pods"}, + IdentityLabels: defaultIdentityLabels, }, collector: collector, evaluator: evaluator, @@ -153,29 +341,41 @@ func TestEvalLoopBehavior(t *testing.T) { resp, err := plugin.Eval(&proto.EvalRequest{PolicyPaths: []string{"bundle-a"}}, apiHelper) if err == nil { - t.Fatalf("expected collection error") + t.Fatalf("expected eval failure") } if resp.GetStatus() != proto.ExecutionStatus_FAILURE { t.Fatalf("expected failure status") } + if apiHelper.createCalls != 0 { + t.Fatalf("expected no CreateEvidence calls, got %d", apiHelper.createCalls) + } + if len(apiHelper.evidence) != 0 { + t.Fatalf("expected no published evidence, got %d", len(apiHelper.evidence)) + } }) - t.Run("preserves user provider label", func(t *testing.T) { + t.Run("flushes evidence incrementally when batch size is exceeded", func(t *testing.T) { + pods := make([]map[string]interface{}, 0, evidenceBatchSize+5) + for i := range evidenceBatchSize + 5 { + pods = append(pods, newPodItem(fmt.Sprintf("pod-%d", i), "app", fmt.Sprintf("app-%d", i))) + } collector := &fakeCollector{ results: map[string]*ClusterResources{ - "prod": {Name: "prod", Region: "us-east-1", Resources: map[string][]map[string]interface{}{"nodes": {}}}, + "prod": { + Name: "prod", Region: "us-east-1", + Resources: map[string][]map[string]interface{}{"pods": pods}, + }, }, } evaluator := &fakePolicyEvaluator{} apiHelper := &fakeAPIHelper{} - plugin := &Plugin{ Logger: hclog.NewNullLogger(), parsedConfig: &ParsedConfig{ - Clusters: []auth.ClusterConfig{{Name: "prod", Region: "us-east-1", ClusterName: "prod-eks"}}, - Resources: []string{"nodes"}, - PolicyLabels: map[string]string{"provider": "custom"}, - PolicyInput: map[string]interface{}{}, + Clusters: []auth.ClusterConfig{{Name: "prod", Region: "us-east-1", ClusterName: "prod-eks"}}, + Resources: []string{"pods"}, + MainResources: []string{"pods"}, + IdentityLabels: defaultIdentityLabels, }, collector: collector, evaluator: evaluator, @@ -183,22 +383,110 @@ func TestEvalLoopBehavior(t *testing.T) { resp, err := plugin.Eval(&proto.EvalRequest{PolicyPaths: []string{"bundle-a"}}, apiHelper) if err != nil { - t.Fatalf("unexpected error: %v", err) + t.Fatalf("unexpected eval error: %v", err) } if resp.GetStatus() != proto.ExecutionStatus_SUCCESS { - t.Fatalf("expected success") + t.Fatalf("expected success, got %s", resp.GetStatus().String()) + } + if apiHelper.createCalls < 2 { + t.Fatalf("expected multiple CreateEvidence flushes, got %d", apiHelper.createCalls) + } + if len(apiHelper.evidence) != evidenceBatchSize+5 { + t.Fatalf("expected %d evidences, got %d", evidenceBatchSize+5, len(apiHelper.evidence)) + } + }) + + t.Run("collection failure returns error", func(t *testing.T) { + collector := &fakeCollector{err: errors.New("auth failure")} + plugin := &Plugin{ + Logger: hclog.NewNullLogger(), + parsedConfig: &ParsedConfig{ + Clusters: []auth.ClusterConfig{{Name: "prod", Region: "us-east-1", ClusterName: "prod-eks"}}, + Resources: []string{"pods"}, + MainResources: []string{"pods"}, + IdentityLabels: defaultIdentityLabels, + }, + collector: collector, + evaluator: &fakePolicyEvaluator{}, } - if len(evaluator.labelsSeen) == 0 { - t.Fatalf("expected labels to be captured") + + resp, err := plugin.Eval(&proto.EvalRequest{PolicyPaths: []string{"bundle-a"}}, &fakeAPIHelper{}) + if err == nil { + t.Fatalf("expected collection error") } - if evaluator.labelsSeen[0]["provider"] != "custom" { - t.Fatalf("expected provider=custom, got: %s", evaluator.labelsSeen[0]["provider"]) + if resp.GetStatus() != proto.ExecutionStatus_FAILURE { + t.Fatalf("expected failure status") } - if evaluator.labelsSeen[0]["source"] != sourcePluginK8s { + }) + + t.Run("preserves user provider label", func(t *testing.T) { + collector := &fakeCollector{ + results: map[string]*ClusterResources{ + "prod": { + Name: "prod", Region: "us-east-1", + Resources: map[string][]map[string]interface{}{"pods": {newPodItem("p1", "app", "svc")}}, + }, + }, + } + evaluator := &fakePolicyEvaluator{} + plugin := &Plugin{ + Logger: hclog.NewNullLogger(), + parsedConfig: &ParsedConfig{ + Clusters: []auth.ClusterConfig{{Name: "prod", Region: "us-east-1", ClusterName: "prod-eks"}}, + Resources: []string{"pods"}, + MainResources: []string{"pods"}, + IdentityLabels: defaultIdentityLabels, + PolicyLabels: map[string]string{"provider": "custom"}, + }, + collector: collector, + evaluator: evaluator, + } + + _, err := plugin.Eval(&proto.EvalRequest{PolicyPaths: []string{"bundle-a"}}, &fakeAPIHelper{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if evaluator.calls[0].labels["provider"] != "custom" { + t.Fatalf("expected provider=custom, got %q", evaluator.calls[0].labels["provider"]) + } + if evaluator.calls[0].labels["source"] != sourcePluginK8s { t.Fatalf("expected source label") } }) + t.Run("skips main resource types with no collected items", func(t *testing.T) { + collector := &fakeCollector{ + results: map[string]*ClusterResources{ + "prod": { + Name: "prod", Region: "us-east-1", + Resources: map[string][]map[string]interface{}{"pods": {}}, + }, + }, + } + evaluator := &fakePolicyEvaluator{} + plugin := &Plugin{ + Logger: hclog.NewNullLogger(), + parsedConfig: &ParsedConfig{ + Clusters: []auth.ClusterConfig{{Name: "prod", Region: "us-east-1", ClusterName: "prod-eks"}}, + Resources: []string{"pods"}, + MainResources: []string{"pods"}, + IdentityLabels: defaultIdentityLabels, + }, + collector: collector, + evaluator: evaluator, + } + resp, err := plugin.Eval(&proto.EvalRequest{PolicyPaths: []string{"bundle-a"}}, &fakeAPIHelper{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.GetStatus() != proto.ExecutionStatus_SUCCESS { + t.Fatalf("expected success when no main resources exist") + } + if len(evaluator.calls) != 0 { + t.Fatalf("expected 0 evaluator calls, got %d", len(evaluator.calls)) + } + }) + t.Run("not configured returns error", func(t *testing.T) { plugin := &Plugin{Logger: hclog.NewNullLogger()} resp, err := plugin.Eval(&proto.EvalRequest{PolicyPaths: []string{"bundle-a"}}, &fakeAPIHelper{}) @@ -225,32 +513,237 @@ func TestEvalLoopBehavior(t *testing.T) { }) } +func TestResolveIdentityLabels(t *testing.T) { + config := map[string][]string{ + "app_name": {"app.kubernetes.io/name", "app"}, + "team": {"team"}, + } + + t.Run("picks first candidate present", func(t *testing.T) { + res := map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "pod-1", + "labels": map[string]interface{}{"app.kubernetes.io/name": "api", "app": "legacy"}, + }, + } + got := resolveIdentityLabels(res, config) + if got["app_name"] != "api" { + t.Fatalf("expected app_name=api, got %q", got["app_name"]) + } + if got["team"] != "pod-1" { + t.Fatalf("expected team fallback to name, got %q", got["team"]) + } + }) + + t.Run("falls back to second candidate", func(t *testing.T) { + res := map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "pod-1", + "labels": map[string]interface{}{"app": "legacy"}, + }, + } + got := resolveIdentityLabels(res, config) + if got["app_name"] != "legacy" { + t.Fatalf("expected app_name=legacy, got %q", got["app_name"]) + } + }) + + t.Run("falls back to metadata.name when no labels", func(t *testing.T) { + res := map[string]interface{}{ + "metadata": map[string]interface{}{"name": "pod-1"}, + } + got := resolveIdentityLabels(res, config) + if got["app_name"] != "pod-1" { + t.Fatalf("expected app_name fallback to name, got %q", got["app_name"]) + } + }) + + t.Run("uses pod template labels for workload resources", func(t *testing.T) { + res := map[string]interface{}{ + "metadata": map[string]interface{}{"name": "deploy-1"}, + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{"app.kubernetes.io/name": "api"}, + }, + }, + }, + } + got := resolveIdentityLabels(res, config) + if got["app_name"] != "api" { + t.Fatalf("expected app_name from pod template labels, got %q", got["app_name"]) + } + }) + + t.Run("handles missing metadata", func(t *testing.T) { + got := resolveIdentityLabels(map[string]interface{}{}, config) + if got["app_name"] != "" { + t.Fatalf("expected empty app_name when no metadata, got %q", got["app_name"]) + } + }) +} + +func TestResourceInstanceIdentifier(t *testing.T) { + tests := []struct { + name string + instance *resourceInstance + want string + }{ + { + "namespaced", + &resourceInstance{ClusterName: "prod", ResourceType: "pods", Namespace: "app-ns", Name: "api-1", IdentityLabels: map[string]string{"app_name": "api"}}, + "k8s-pods/prod/app-ns/api/api-1", + }, + { + "cluster-scoped omits namespace segment", + &resourceInstance{ClusterName: "prod", ResourceType: "nodes", Name: "node-1", IdentityLabels: map[string]string{"app_name": "node-1"}}, + "k8s-nodes/prod/node-1/node-1", + }, + { + "sanitizes noisy cluster name", + &resourceInstance{ClusterName: "Prod East", ResourceType: "pods", Namespace: "Default", Name: "API/1", IdentityLabels: map[string]string{"app_name": "Platform API"}}, + "k8s-pods/prod-east/default/platform-api/api-1", + }, + { + "falls back to resource name when app_name is empty", + &resourceInstance{ClusterName: "prod", ResourceType: "pods", Namespace: "app", Name: "api-1", IdentityLabels: map[string]string{"app_name": ""}}, + "k8s-pods/prod/app/api-1/api-1", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := resourceInstanceIdentifier(tc.instance); got != tc.want { + t.Fatalf("got %q want %q", got, tc.want) + } + }) + } +} + +func TestBuildClusterComponent(t *testing.T) { + t.Run("kubeconfig cluster omits empty region and cluster_name", func(t *testing.T) { + component := buildClusterComponent(auth.ClusterConfig{Name: "local", Provider: "kubeconfig"}) + if component.GetDescription() != `Kubernetes cluster "local" using provider kubeconfig` { + t.Fatalf("unexpected description: %q", component.GetDescription()) + } + }) + + t.Run("eks cluster includes provider cluster_name and region", func(t *testing.T) { + component := buildClusterComponent(auth.ClusterConfig{Name: "prod", Provider: "eks", ClusterName: "prod-eks", Region: "us-east-1"}) + want := `Kubernetes cluster "prod" using provider eks (cluster name "prod-eks") in region us-east-1` + if component.GetDescription() != want { + t.Fatalf("unexpected description: %q", component.GetDescription()) + } + }) +} + +func TestBuildSubjectTemplates(t *testing.T) { + templates := buildSubjectTemplates([]string{"pods", "nodes"}) + if len(templates) != 2 { + t.Fatalf("expected 2 templates, got %d", len(templates)) + } + byName := map[string]*proto.SubjectTemplate{} + for _, tpl := range templates { + byName[tpl.GetName()] = tpl + } + if _, ok := byName["k8s-pods"]; !ok { + t.Fatalf("missing k8s-pods template") + } + if _, ok := byName["k8s-nodes"]; !ok { + t.Fatalf("missing k8s-nodes template") + } + if byName["k8s-pods"].GetType() != proto.SubjectType_SUBJECT_TYPE_COMPONENT { + t.Fatalf("expected k8s-pods template type component, got %v", byName["k8s-pods"].GetType()) + } + keys := byName["k8s-pods"].GetIdentityLabelKeys() + wantKeys := []string{"cluster_name", "namespace", "app_name", "name"} + if len(keys) != len(wantKeys) { + t.Fatalf("expected identity keys %v, got %v", wantKeys, keys) + } + for i := range wantKeys { + if keys[i] != wantKeys[i] { + t.Fatalf("expected pod identity keys %v, got %v", wantKeys, keys) + } + } + nodeKeys := byName["k8s-nodes"].GetIdentityLabelKeys() + wantNodeKeys := []string{"cluster_name", "namespace", "app_name", "name"} + if len(nodeKeys) != len(wantNodeKeys) { + t.Fatalf("expected node identity keys %v, got %v", wantNodeKeys, nodeKeys) + } + for i := range wantNodeKeys { + if nodeKeys[i] != wantNodeKeys[i] { + t.Fatalf("expected node identity keys %v, got %v", wantNodeKeys, nodeKeys) + } + } + if byName["k8s-nodes"].GetTitleTemplate() != "Kubernetes nodes {{ if .namespace }}{{ .namespace }}/{{ end }}{{ .name }} in {{ .cluster_name }}" { + t.Fatalf("unexpected node title template: %q", byName["k8s-nodes"].GetTitleTemplate()) + } + if byName["k8s-nodes"].GetDescriptionTemplate() != "Kubernetes nodes {{ .name }} in cluster {{ .cluster_name }}{{ if .namespace }} under namespace {{ .namespace }}{{ end }}" { + t.Fatalf("unexpected node description template: %q", byName["k8s-nodes"].GetDescriptionTemplate()) + } +} + +func TestInitUpsertsSubjectTemplates(t *testing.T) { + plugin := &Plugin{ + Logger: hclog.NewNullLogger(), + parsedConfig: &ParsedConfig{MainResources: []string{"pods", "nodes"}}, + } + api := &fakeAPIHelper{} + _, err := plugin.Init(&proto.InitRequest{}, api) + if err != nil { + t.Fatalf("unexpected Init error: %v", err) + } + if len(api.subjectTemplates) != 2 { + t.Fatalf("expected 2 subject templates upserted, got %d", len(api.subjectTemplates)) + } +} + +func TestInitBeforeConfigureReturnsError(t *testing.T) { + plugin := &Plugin{Logger: hclog.NewNullLogger()} + _, err := plugin.Init(&proto.InitRequest{}, &fakeAPIHelper{}) + if err == nil || !strings.Contains(err.Error(), "not configured") { + t.Fatalf("expected not configured error, got: %v", err) + } +} + func TestBuildRegoInput(t *testing.T) { - clusters := map[string]*ClusterResources{ - "prod": { - Name: "prod", - Region: "us-east-1", - Resources: map[string][]map[string]interface{}{ - "nodes": {{"metadata": map[string]interface{}{"name": "n1"}}}, + main := map[string]interface{}{"metadata": map[string]interface{}{"name": "pod-1", "namespace": "app"}} + subject := map[string]interface{}{"cluster_name": "prod", "resource_type": "pods", "name": "pod-1"} + ctxPayload := map[string]interface{}{ + "cluster": map[string]interface{}{"name": "prod"}, + "resources": map[string][]map[string]interface{}{"nodes": {{"metadata": map[string]interface{}{"name": "n1"}}}}, + } + fleetPayload := map[string]interface{}{ + "clusters": map[string]interface{}{ + "prod": map[string]interface{}{ + "cluster": map[string]interface{}{"name": "prod"}, + "resources": map[string][]map[string]interface{}{"nodes": {{"metadata": map[string]interface{}{"name": "n1"}}}}, }, }, } - policyInput := map[string]interface{}{"expected_azs": []interface{}{"us-east-1a", "us-east-1b", "us-east-1c"}} + userInput := map[string]interface{}{"min_replicas": 3} - input := buildRegoInput(clusters, policyInput) + input := buildRegoInput(main, subject, ctxPayload, fleetPayload, userInput) - if input["schema_version"] != schemaVersionV1 { - t.Fatalf("expected schema_version %s", schemaVersionV1) + if input["schema_version"] != schemaVersionV2 { + t.Fatalf("expected schema_version v2") } if input["source"] != sourcePluginK8s { - t.Fatalf("expected source %s", sourcePluginK8s) + t.Fatalf("expected source") + } + if input["main"] == nil { + t.Fatalf("expected main populated") + } + if input["subject"] == nil { + t.Fatalf("expected subject populated") + } + if input["context"] == nil { + t.Fatalf("expected context populated") } - azs, ok := input["expected_azs"].([]interface{}) - if !ok || len(azs) != 3 { - t.Fatalf("expected expected_azs merged from policy_input") + if input["fleet"] == nil { + t.Fatalf("expected fleet populated") } - if _, ok := input["clusters"]; !ok { - t.Fatalf("expected clusters key") + if input["min_replicas"] != 3 { + t.Fatalf("expected user policy_input merged") } }