diff --git a/internal/eval.go b/internal/eval.go index 6874abd..9fe74b2 100644 --- a/internal/eval.go +++ b/internal/eval.go @@ -117,10 +117,6 @@ func (pe *PolicyEvaluator) Eval(ctx context.Context, cert CertificateContext, po } for _, policyPath := range policyPaths { - rootData, err := loadBundleRootData(policyPath, policyData) - if err != nil { - return nil, fmt.Errorf("loading bundle data for %s: %w", policyPath, err) - } processor := policyManager.NewPolicyProcessor( pe.logger, labels, @@ -129,10 +125,13 @@ func (pe *PolicyEvaluator) Eval(ctx context.Context, cert CertificateContext, po inventory, actors, pe.stepActivities, - rootData, + policyData, ) evidence, perr := processor.GenerateResults(ctx, policyPath, input) + for _, ev := range evidence { + ev.Title = fmt.Sprintf("%s [%s]", ev.GetTitle(), cert.DomainName) + } evidences = append(evidences, evidence...) if perr != nil { accumulatedErrors = errors.Join(accumulatedErrors, perr) @@ -153,15 +152,16 @@ func certificateBaseLabels() map[string]string { } } -// loadBundleRootData reads data.json from the OPA bundle root and merges it -// with base. When the agent downloads a policy OCI artifact it returns the +// LoadBundleRootData reads data.json from the OPA bundle root and merges it +// with overrides. When the agent downloads a policy OCI artifact it returns the // policies/ subdirectory as policyPath; the bundle's data.json lives one level // up in the bundle root. For local source trees the data.json lives inside the -// policies/ directory itself, so we check both locations. -func loadBundleRootData(policyPath string, base map[string]interface{}) (map[string]interface{}, error) { +// policies/ directory itself, so both locations are checked. overrides win on +// conflict, so operator-supplied policy_data takes precedence over bundle defaults. +func LoadBundleRootData(policyPath string, overrides map[string]interface{}) (map[string]interface{}, error) { candidates := []string{ - filepath.Join(filepath.Dir(policyPath), "data.json"), filepath.Join(policyPath, "data.json"), + filepath.Join(filepath.Dir(policyPath), "data.json"), } for _, p := range candidates { raw, err := os.ReadFile(p) @@ -175,16 +175,16 @@ func loadBundleRootData(policyPath string, base map[string]interface{}) (map[str if err := json.Unmarshal(raw, &bundleData); err != nil { return nil, fmt.Errorf("parsing bundle data %s: %w", p, err) } - merged := make(map[string]interface{}, len(bundleData)+len(base)) + merged := make(map[string]interface{}, len(bundleData)+len(overrides)) for k, v := range bundleData { merged[k] = v } - for k, v := range base { + for k, v := range overrides { merged[k] = v } return merged, nil } - return base, nil + return overrides, nil } // arnCertID extracts the certificate UUID from an ACM ARN. diff --git a/internal/eval_test.go b/internal/eval_test.go new file mode 100644 index 0000000..1edadb8 --- /dev/null +++ b/internal/eval_test.go @@ -0,0 +1,102 @@ +package internal + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadBundleRootData_OverridesWinOverBundleDefaults(t *testing.T) { + dir := t.TempDir() + bundleJSON := `{"expiry_warning_days":30,"required_certificate_tags":["Environment"]}` + if err := os.WriteFile(filepath.Join(dir, "data.json"), []byte(bundleJSON), 0644); err != nil { + t.Fatal(err) + } + + overrides := map[string]interface{}{ + "expiry_warning_days": float64(90), + } + + result, err := LoadBundleRootData(dir, overrides) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Operator override (90) must win over bundle default (30). + if got := result["expiry_warning_days"]; got != float64(90) { + t.Errorf("expiry_warning_days: got %v, want 90", got) + } + + // Bundle key absent from overrides must still be present. + if _, ok := result["required_certificate_tags"]; !ok { + t.Error("required_certificate_tags from bundle missing from merged result") + } +} + +func TestLoadBundleRootData_NoBundleDataJsonReturnsOverrides(t *testing.T) { + dir := t.TempDir() // no data.json written + + overrides := map[string]interface{}{ + "expiry_warning_days": float64(60), + } + + result, err := LoadBundleRootData(dir, overrides) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got := result["expiry_warning_days"]; got != float64(60) { + t.Errorf("expiry_warning_days: got %v, want 60", got) + } +} + +func TestLoadBundleRootData_PolicyPathDataJsonWinsOverParent(t *testing.T) { + root := t.TempDir() + parentJSON := `{"expiry_warning_days":30,"source":"parent"}` + if err := os.WriteFile(filepath.Join(root, "data.json"), []byte(parentJSON), 0644); err != nil { + t.Fatal(err) + } + policiesDir := filepath.Join(root, "policies") + if err := os.Mkdir(policiesDir, 0755); err != nil { + t.Fatal(err) + } + policiesJSON := `{"expiry_warning_days":60,"source":"policyPath"}` + if err := os.WriteFile(filepath.Join(policiesDir, "data.json"), []byte(policiesJSON), 0644); err != nil { + t.Fatal(err) + } + + // When both data.json locations exist, policyPath/data.json must win. + result, err := LoadBundleRootData(policiesDir, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got := result["source"]; got != "policyPath" { + t.Errorf("source: got %v, want policyPath", got) + } + if got := result["expiry_warning_days"]; got != float64(60) { + t.Errorf("expiry_warning_days: got %v, want 60", got) + } +} + +func TestLoadBundleRootData_FindsDataJsonOneDirectoryUp(t *testing.T) { + root := t.TempDir() + bundleJSON := `{"expiry_warning_days":30}` + if err := os.WriteFile(filepath.Join(root, "data.json"), []byte(bundleJSON), 0644); err != nil { + t.Fatal(err) + } + policiesDir := filepath.Join(root, "policies") + if err := os.Mkdir(policiesDir, 0755); err != nil { + t.Fatal(err) + } + + // policyPath is the policies/ subdirectory; data.json is one level up. + result, err := LoadBundleRootData(policiesDir, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got := result["expiry_warning_days"]; got != float64(30) { + t.Errorf("expiry_warning_days: got %v, want 30", got) + } +} diff --git a/main.go b/main.go index 7842485..eda4ddb 100644 --- a/main.go +++ b/main.go @@ -72,12 +72,23 @@ func (l *CompliancePlugin) Eval(request *proto.EvalRequest, apiHelper runner.Api }, fmt.Errorf("failed to fetch data: %w", err) } + // Load bundle data.json defaults and merge with operator overrides once per + // evaluation cycle. All policy paths share the same bundle so one load suffices. + policyData := l.policyData + if paths := request.GetPolicyPaths(); len(paths) > 0 { + merged, err := internal.LoadBundleRootData(paths[0], l.policyData) + if err != nil { + return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, fmt.Errorf("loading bundle data for %s: %w", paths[0], err) + } + policyData = merged + } + policyEvaluator := internal.NewPolicyEvaluator(ctx, l.logger, activities) var allEvidences []*proto.Evidence var evalErrors error for _, cert := range certs { - certEvidences, err := policyEvaluator.Eval(ctx, cert, request.GetPolicyPaths(), l.policyData, l.config.PolicyLabels) + certEvidences, err := policyEvaluator.Eval(ctx, cert, request.GetPolicyPaths(), policyData, l.config.PolicyLabels) allEvidences = append(allEvidences, certEvidences...) if err != nil { evalErrors = errors.Join(evalErrors, fmt.Errorf("evaluating cert %s: %w", cert.CertificateArn, err))