Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 5 additions & 1 deletion cmd/kosli/evaluate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
33 changes: 31 additions & 2 deletions cmd/kosli/evaluateHelpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -20,6 +21,7 @@ type commonEvaluateOptions struct {
output string
showInput bool
attestations []string
params string
}

func (o *commonEvaluateOptions) addFlags(cmd *cobra.Command, policyDesc string) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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, &params); 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
}
Expand All @@ -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 {
Expand Down
26 changes: 23 additions & 3 deletions cmd/kosli/evaluateInput.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
25 changes: 25 additions & 0 deletions cmd/kosli/evaluateInput_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
23 changes: 22 additions & 1 deletion cmd/kosli/evaluateTrail.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
15 changes: 14 additions & 1 deletion cmd/kosli/evaluateTrails.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
1 change: 1 addition & 0 deletions cmd/kosli/testdata/evaluate/params-low-threshold.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"threshold": 3}
1 change: 1 addition & 0 deletions cmd/kosli/testdata/evaluate/score-input.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"score": 5}
16 changes: 16 additions & 0 deletions cmd/kosli/testdata/policies/check-params-threshold.rego
Original file line number Diff line number Diff line change
@@ -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])
}
28 changes: 21 additions & 7 deletions internal/evaluate/rego.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading