diff --git a/TODO.md b/TODO.md index fd7e269a9..b8fd42d6f 100644 --- a/TODO.md +++ b/TODO.md @@ -47,3 +47,10 @@ - [x] Refactor: use `cmd.InOrStdin()` for testable stdin - [x] Refactor: embed `commonEvaluateOptions` to remove flag duplication - [x] Slice 5: Detect terminal stdin and error when no input is piped + +## Add `--params` flag to `kosli evaluate` commands + +- [x] Slice 1: `evaluate.Evaluate()` accepts params, passes via OPA data store +- [x] Slice 2: Add `--params` flag across all three commands +- [x] Slice 3: Show params in `--show-input` output +- [x] Slice 4: Update help text and examples diff --git a/cmd/kosli/evaluate.go b/cmd/kosli/evaluate.go index da803937f..08f431ba7 100644 --- a/cmd/kosli/evaluate.go +++ b/cmd/kosli/evaluate.go @@ -18,7 +18,11 @@ Use ` + "`evaluate input`" + ` to evaluate a local JSON file or stdin without an The policy must use ` + "`package policy`" + ` and define an ` + "`allow`" + ` rule. An optional ` + "`violations`" + ` rule (a set of strings) can provide human-readable denial reasons. -The command exits with code 0 when allowed and code 1 when denied.` +The command exits with code 0 when allowed and code 1 when denied. + +Use ` + "`--params`" + ` to pass configuration data (thresholds, expected counts, etc.) +to your policy. Params are available as ` + "`data.params`" + ` in Rego, keeping policy +logic reusable across environments with different tolerances.` func newEvaluateCmd(out io.Writer) *cobra.Command { cmd := &cobra.Command{ diff --git a/cmd/kosli/evaluateHelpers.go b/cmd/kosli/evaluateHelpers.go index b3de27ea9..7a30f6228 100644 --- a/cmd/kosli/evaluateHelpers.go +++ b/cmd/kosli/evaluateHelpers.go @@ -7,6 +7,7 @@ import ( "net/http" "net/url" "os" + "strings" "github.com/kosli-dev/cli/internal/evaluate" "github.com/kosli-dev/cli/internal/output" @@ -20,6 +21,7 @@ type commonEvaluateOptions struct { output string showInput bool attestations []string + params string } func (o *commonEvaluateOptions) addFlags(cmd *cobra.Command, policyDesc string) { @@ -28,6 +30,7 @@ func (o *commonEvaluateOptions) addFlags(cmd *cobra.Command, policyDesc string) cmd.Flags().StringVarP(&o.output, "output", "o", "table", outputFlag) cmd.Flags().BoolVar(&o.showInput, "show-input", false, "[optional] Include the policy input data in the output.") cmd.Flags().StringSliceVar(&o.attestations, "attestations", nil, "[optional] Limit which attestations are included. Plain name for trail-level, dot-qualified (artifact.name) for artifact-level.") + cmd.Flags().StringVar(&o.params, "params", "", "[optional] Policy parameters as inline JSON or @file.json. Available in policies as data.params.") } func fetchAndEnrichTrail(flowName, trailName string, attestations []string) (interface{}, error) { @@ -90,13 +93,36 @@ func fetchAndEnrichTrail(flowName, trailName string, attestations []string) (int return trailData, nil } -func evaluateAndPrintResult(out io.Writer, policyFile string, input map[string]interface{}, outputFormat string, showInput bool) error { +func parseParams(raw string) (map[string]interface{}, error) { + if raw == "" { + return nil, nil + } + + var jsonBytes []byte + if strings.HasPrefix(raw, "@") { + var err error + jsonBytes, err = os.ReadFile(raw[1:]) + if err != nil { + return nil, fmt.Errorf("failed to read --params file: %w", err) + } + } else { + jsonBytes = []byte(raw) + } + + var params map[string]interface{} + if err := json.Unmarshal(jsonBytes, ¶ms); err != nil { + return nil, fmt.Errorf("failed to parse --params: %w", err) + } + return params, nil +} + +func evaluateAndPrintResult(out io.Writer, policyFile string, input map[string]interface{}, outputFormat string, showInput bool, params map[string]interface{}) error { policySource, err := os.ReadFile(policyFile) if err != nil { return fmt.Errorf("failed to read policy file: %w", err) } - result, err := evaluate.Evaluate(string(policySource), input) + result, err := evaluate.Evaluate(string(policySource), input, params) if err != nil { return err } @@ -108,6 +134,9 @@ func evaluateAndPrintResult(out io.Writer, policyFile string, input map[string]i if showInput { auditResult["input"] = input } + if showInput && params != nil { + auditResult["params"] = params + } raw, err := json.Marshal(auditResult) if err != nil { diff --git a/cmd/kosli/evaluateInput.go b/cmd/kosli/evaluateInput.go index 55f1e3ec7..1fa4b8de5 100644 --- a/cmd/kosli/evaluateInput.go +++ b/cmd/kosli/evaluateInput.go @@ -27,7 +27,10 @@ The policy must use ` + "`package policy`" + ` and define an ` + "`allow`" + ` r An optional ` + "`violations`" + ` rule (a set of strings) can provide human-readable denial reasons. The command exits with code 0 when allowed and code 1 when denied. -When ` + "`--input-file`" + ` is omitted, JSON is read from stdin.` +When ` + "`--input-file`" + ` is omitted, JSON is read from stdin. + +Use ` + "`--params`" + ` to pass configuration data to the policy as ` + "`data.params`" + `. +This accepts inline JSON or a file reference (` + "`@file.json`" + `).` const evaluateInputExample = ` # capture trail data for local policy iteration: @@ -49,7 +52,19 @@ kosli evaluate input \ # read input from stdin: cat trail-data.json | kosli evaluate input \ - --policy policy.rego` + --policy policy.rego + +# evaluate with policy parameters (inline JSON): +kosli evaluate input \ + --input-file trail-data.json \ + --policy policy.rego \ + --params '{"threshold": 3}' + +# evaluate with policy parameters from a file: +kosli evaluate input \ + --input-file trail-data.json \ + --policy policy.rego \ + --params @params.json` func newEvaluateInputCmd(out io.Writer) *cobra.Command { o := new(evaluateInputOptions) @@ -94,7 +109,12 @@ func (o *evaluateInputOptions) run(out io.Writer, in io.Reader) error { return err } - return evaluateAndPrintResult(out, o.policyFile, input, o.output, o.showInput) + params, err := parseParams(o.params) + if err != nil { + return err + } + + return evaluateAndPrintResult(out, o.policyFile, input, o.output, o.showInput, params) } func loadInputFromFile(filePath string) (result map[string]interface{}, err error) { diff --git a/cmd/kosli/evaluateInput_test.go b/cmd/kosli/evaluateInput_test.go index edd64d131..cdb651062 100644 --- a/cmd/kosli/evaluateInput_test.go +++ b/cmd/kosli/evaluateInput_test.go @@ -82,6 +82,31 @@ func (suite *EvaluateInputCommandTestSuite) TestEvaluateInputCmd() { {"input.trail.name", "test-trail"}, }, }, + { + name: "inline --params overrides policy default threshold", + cmd: `evaluate input --input-file testdata/evaluate/score-input.json --policy testdata/policies/check-params-threshold.rego --params '{"threshold":3}'`, + goldenRegex: `RESULT:\s+ALLOWED`, + }, + { + name: "--params from file overrides policy default threshold", + cmd: "evaluate input --input-file testdata/evaluate/score-input.json --policy testdata/policies/check-params-threshold.rego --params @testdata/evaluate/params-low-threshold.json", + goldenRegex: `RESULT:\s+ALLOWED`, + }, + { + wantError: true, + name: "--params with invalid JSON returns error", + cmd: "evaluate input --input-file testdata/evaluate/score-input.json --policy testdata/policies/allow-all.rego --params not-json", + goldenRegex: `failed to parse --params`, + }, + { + name: "show-input with params includes params in JSON output", + cmd: `evaluate input --input-file testdata/evaluate/score-input.json --policy testdata/policies/check-params-threshold.rego --params '{"threshold":3}' --output json --show-input`, + goldenJson: []jsonCheck{ + {"allow", true}, + {"input.score", float64(5)}, + {"params.threshold", float64(3)}, + }, + }, } runTestCmd(suite.T(), tests) } diff --git a/cmd/kosli/evaluateTrail.go b/cmd/kosli/evaluateTrail.go index c2bd171d4..5a7c0fddd 100644 --- a/cmd/kosli/evaluateTrail.go +++ b/cmd/kosli/evaluateTrail.go @@ -39,6 +39,22 @@ kosli evaluate trail yourTrailName \ --show-input \ --output json \ --api-token yourAPIToken \ + --org yourOrgName + +# evaluate a trail with policy parameters (inline JSON): +kosli evaluate trail yourTrailName \ + --policy yourPolicyFile.rego \ + --flow yourFlowName \ + --params '{"min_approvers": 2}' \ + --api-token yourAPIToken \ + --org yourOrgName + +# evaluate a trail with policy parameters from a file: +kosli evaluate trail yourTrailName \ + --policy yourPolicyFile.rego \ + --flow yourFlowName \ + --params @params.json \ + --api-token yourAPIToken \ --org yourOrgName` type evaluateTrailOptions struct { @@ -81,9 +97,14 @@ func (o *evaluateTrailOptions) run(out io.Writer, args []string) error { return err } + params, err := parseParams(o.params) + if err != nil { + return err + } + input := map[string]interface{}{ "trail": trailData, } - return evaluateAndPrintResult(out, o.policyFile, input, o.output, o.showInput) + return evaluateAndPrintResult(out, o.policyFile, input, o.output, o.showInput, params) } diff --git a/cmd/kosli/evaluateTrails.go b/cmd/kosli/evaluateTrails.go index 11331b025..cfafb9020 100644 --- a/cmd/kosli/evaluateTrails.go +++ b/cmd/kosli/evaluateTrails.go @@ -40,6 +40,14 @@ kosli evaluate trails yourTrailName1 yourTrailName2 \ --show-input \ --output json \ --api-token yourAPIToken \ + --org yourOrgName + +# evaluate trails with policy parameters: +kosli evaluate trails yourTrailName1 yourTrailName2 \ + --policy yourPolicyFile.rego \ + --flow yourFlowName \ + --params '{"min_approvers": 2}' \ + --api-token yourAPIToken \ --org yourOrgName` type evaluateTrailsOptions struct { @@ -86,9 +94,14 @@ func (o *evaluateTrailsOptions) run(out io.Writer, args []string) error { trails = append(trails, trailData) } + params, err := parseParams(o.params) + if err != nil { + return err + } + input := map[string]interface{}{ "trails": trails, } - return evaluateAndPrintResult(out, o.policyFile, input, o.output, o.showInput) + return evaluateAndPrintResult(out, o.policyFile, input, o.output, o.showInput, params) } diff --git a/cmd/kosli/testdata/evaluate/params-low-threshold.json b/cmd/kosli/testdata/evaluate/params-low-threshold.json new file mode 100644 index 000000000..e08bb40eb --- /dev/null +++ b/cmd/kosli/testdata/evaluate/params-low-threshold.json @@ -0,0 +1 @@ +{"threshold": 3} diff --git a/cmd/kosli/testdata/evaluate/score-input.json b/cmd/kosli/testdata/evaluate/score-input.json new file mode 100644 index 000000000..2fa49cc3d --- /dev/null +++ b/cmd/kosli/testdata/evaluate/score-input.json @@ -0,0 +1 @@ +{"score": 5} diff --git a/cmd/kosli/testdata/policies/check-params-threshold.rego b/cmd/kosli/testdata/policies/check-params-threshold.rego new file mode 100644 index 000000000..58b8a1976 --- /dev/null +++ b/cmd/kosli/testdata/policies/check-params-threshold.rego @@ -0,0 +1,16 @@ +package policy + +import rego.v1 + +default allow := false + +default threshold := 10 + +threshold := data.params.threshold if { data.params.threshold } + +allow if { input.score >= threshold } + +violations contains msg if { + input.score < threshold + msg := sprintf("score %d is below threshold %d", [input.score, threshold]) +} diff --git a/internal/evaluate/rego.go b/internal/evaluate/rego.go index 6e89e43c6..82d7f12c5 100644 --- a/internal/evaluate/rego.go +++ b/internal/evaluate/rego.go @@ -6,6 +6,7 @@ import ( "github.com/open-policy-agent/opa/v1/ast" "github.com/open-policy-agent/opa/v1/rego" + "github.com/open-policy-agent/opa/v1/storage/inmem" ) // Result holds the outcome of a policy evaluation. @@ -16,18 +17,25 @@ type Result struct { // Evaluate evaluates a Rego policy against the given input. // The policy must use `package policy` and declare an `allow` rule. -func Evaluate(policySource string, input interface{}) (*Result, error) { +// An optional params map can be provided to populate data.params in the policy. +func Evaluate(policySource string, input interface{}, params map[string]interface{}) (*Result, error) { if err := validatePolicy(policySource); err != nil { return nil, err } ctx := context.Background() - r := rego.New( + opts := []func(*rego.Rego){ rego.Query("data.policy.allow"), rego.Module("policy.rego", policySource), rego.Input(input), - ) + } + if params != nil { + store := inmem.NewFromObject(map[string]interface{}{"params": params}) + opts = append(opts, rego.Store(store)) + } + + r := rego.New(opts...) rs, err := r.Eval(ctx) if err != nil { @@ -46,7 +54,7 @@ func Evaluate(policySource string, input interface{}) (*Result, error) { result := &Result{Allow: allow} if !result.Allow { - violations, err := collectViolations(ctx, policySource, input) + violations, err := collectViolations(ctx, policySource, input, params) if err != nil { return nil, err } @@ -81,12 +89,18 @@ func validatePolicy(policySource string) error { return nil } -func collectViolations(ctx context.Context, policySource string, input interface{}) ([]string, error) { - r := rego.New( +func collectViolations(ctx context.Context, policySource string, input interface{}, params map[string]interface{}) ([]string, error) { + opts := []func(*rego.Rego){ rego.Query("data.policy.violations"), rego.Module("policy.rego", policySource), rego.Input(input), - ) + } + if params != nil { + store := inmem.NewFromObject(map[string]interface{}{"params": params}) + opts = append(opts, rego.Store(store)) + } + + r := rego.New(opts...) rs, err := r.Eval(ctx) if err != nil { diff --git a/internal/evaluate/rego_test.go b/internal/evaluate/rego_test.go index c3953710d..ec19ff196 100644 --- a/internal/evaluate/rego_test.go +++ b/internal/evaluate/rego_test.go @@ -17,7 +17,7 @@ allow = true }, } - result, err := Evaluate(policy, input) + result, err := Evaluate(policy, input, nil) require.NoError(t, err) require.True(t, result.Allow) require.Empty(t, result.Violations) @@ -38,7 +38,7 @@ violations contains msg if { }, } - result, err := Evaluate(policy, input) + result, err := Evaluate(policy, input, nil) require.NoError(t, err) require.False(t, result.Allow) require.Contains(t, result.Violations, "always denied") @@ -51,7 +51,7 @@ allow = true ` input := map[string]interface{}{} - _, err := Evaluate(policy, input) + _, err := Evaluate(policy, input, nil) require.Error(t, err) require.Contains(t, err.Error(), "package policy") } @@ -65,7 +65,7 @@ violations contains msg if { ` input := map[string]interface{}{} - _, err := Evaluate(policy, input) + _, err := Evaluate(policy, input, nil) require.Error(t, err) require.Contains(t, err.Error(), "allow") } @@ -77,7 +77,7 @@ allow = false ` input := map[string]interface{}{} - result, err := Evaluate(policy, input) + result, err := Evaluate(policy, input, nil) require.NoError(t, err) require.False(t, result.Allow) require.Empty(t, result.Violations) @@ -90,7 +90,7 @@ allow = "yes" ` input := map[string]interface{}{} - _, err := Evaluate(policy, input) + _, err := Evaluate(policy, input, nil) require.Error(t, err) require.Contains(t, err.Error(), "boolean") } @@ -102,7 +102,82 @@ allow = {{{ ` input := map[string]interface{}{} - _, err := Evaluate(policy, input) + _, err := Evaluate(policy, input, nil) require.Error(t, err) require.Contains(t, err.Error(), "parse") } + +func TestEvaluate_ParamsProvided(t *testing.T) { + policy := `package policy + +import rego.v1 + +default allow := false + +default threshold := 10 + +threshold := data.params.threshold if { data.params.threshold } + +allow if { input.score >= threshold } + +violations contains msg if { + input.score < threshold + msg := sprintf("score %d is below threshold %d", [input.score, threshold]) +} +` + input := map[string]interface{}{ + "score": 5, + } + params := map[string]interface{}{ + "threshold": 3, + } + + result, err := Evaluate(policy, input, params) + require.NoError(t, err) + require.True(t, result.Allow, "score 5 should pass threshold 3") +} + +func TestEvaluate_ParamsDefault(t *testing.T) { + policy := `package policy + +import rego.v1 + +default allow := false + +default threshold := 10 + +threshold := data.params.threshold if { data.params.threshold } + +allow if { input.score >= threshold } + +violations contains msg if { + input.score < threshold + msg := sprintf("score %d is below threshold %d", [input.score, threshold]) +} +` + input := map[string]interface{}{ + "score": 5, + } + + // No params — policy uses default threshold of 10 + result, err := Evaluate(policy, input, nil) + require.NoError(t, err) + require.False(t, result.Allow, "score 5 should fail default threshold 10") + require.Len(t, result.Violations, 1) + require.Contains(t, result.Violations[0], "below threshold 10") +} + +func TestEvaluate_ParamsIgnoredByPolicy(t *testing.T) { + policy := `package policy + +allow = true +` + input := map[string]interface{}{} + params := map[string]interface{}{ + "unused_key": "unused_value", + } + + result, err := Evaluate(policy, input, params) + require.NoError(t, err) + require.True(t, result.Allow, "params not referenced by policy should have no effect") +}