diff --git a/pkg/policies/engine/engine.go b/pkg/policies/engine/engine.go index 0bda2936e..adda578d2 100644 --- a/pkg/policies/engine/engine.go +++ b/pkg/policies/engine/engine.go @@ -38,11 +38,12 @@ type CommonEngineOptions struct { EnablePrint bool ControlPlaneConnection *grpc.ClientConn // ProjectName / ProjectVersionName carry the project + version this engine - // instance is evaluating policies for. They are surfaced to chainloop.* built-ins - // via the per-evaluation context.Context (see builtins.WithProjectContext) so a - // built-in like chainloop.findings can scope its query without the rego author - // having to pass the values explicitly. Either may be empty (e.g. local dev - // eval without flags) — built-ins must degrade gracefully in that case. + // instance is evaluating policies for. The rego engine merges them into + // input.chainloop_metadata.project_name / .project_version_name at evaluation + // time so policy authors can read them from input and forward them as operands + // to chainloop.* built-ins (e.g. chainloop.effective_assessments). Either may + // be empty (e.g. local dev eval without flags); rego authors should treat them + // as optional. ProjectName string ProjectVersionName string } @@ -115,9 +116,10 @@ func WithGRPCConn(conn *grpc.ClientConn) Option { } // WithProjectContext sets the project name and version that this engine -// instance is evaluating policies for. The values are propagated to chainloop.* -// built-ins through the per-evaluation context so they can scope queries -// (e.g. chainloop.findings) without the rego author passing them explicitly. +// instance is evaluating policies for. The rego engine exposes them on the +// per-evaluation input as input.chainloop_metadata.project_name / +// input.chainloop_metadata.project_version_name so policy authors can pass them +// as operands to chainloop.* built-ins. Either value may be empty. func WithProjectContext(name, version string) Option { return func(opts *Options) { opts.ProjectName = name diff --git a/pkg/policies/engine/rego/builtins/context.go b/pkg/policies/engine/rego/builtins/context.go deleted file mode 100644 index 65d7f1d26..000000000 --- a/pkg/policies/engine/rego/builtins/context.go +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright 2026 The Chainloop Authors. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package builtins - -import "context" - -// ProjectContext carries the project + version a policy is being evaluated against. -// It is attached to the per-evaluation context.Context by the rego engine so that -// chainloop.* built-ins can scope their requests (e.g. chainloop.findings) without -// requiring the rego author to pass project_name / project_version_name explicitly. -// -// Values may be empty when the engine has no project context (e.g. a local -// `chainloop policy develop eval` without --project flags). Built-ins must -// degrade gracefully in that case rather than erroring. -type ProjectContext struct { - Name string - Version string -} - -type projectContextKey struct{} - -// WithProjectContext returns a derived context carrying the given project context. -func WithProjectContext(ctx context.Context, pc ProjectContext) context.Context { - return context.WithValue(ctx, projectContextKey{}, pc) -} - -// ProjectContextFromContext returns the project context attached to ctx, or the -// zero value if none was set. The bool reports whether a value was present. -func ProjectContextFromContext(ctx context.Context) (ProjectContext, bool) { - pc, ok := ctx.Value(projectContextKey{}).(ProjectContext) - return pc, ok -} diff --git a/pkg/policies/engine/rego/builtins/context_test.go b/pkg/policies/engine/rego/builtins/context_test.go deleted file mode 100644 index dad6b351f..000000000 --- a/pkg/policies/engine/rego/builtins/context_test.go +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright 2026 The Chainloop Authors. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package builtins - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestProjectContext(t *testing.T) { - tests := []struct { - name string - setup func() context.Context - wantName string - wantVersion string - wantOK bool - }{ - { - name: "no project context attached", - setup: context.Background, - wantOK: false, - }, - { - name: "context with project + version", - setup: func() context.Context { - return WithProjectContext(context.Background(), ProjectContext{Name: "my-app", Version: "v1.2.3"}) - }, - wantName: "my-app", - wantVersion: "v1.2.3", - wantOK: true, - }, - { - name: "context with only project name", - setup: func() context.Context { - return WithProjectContext(context.Background(), ProjectContext{Name: "my-app"}) - }, - wantName: "my-app", - wantOK: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - pc, ok := ProjectContextFromContext(tt.setup()) - assert.Equal(t, tt.wantOK, ok) - assert.Equal(t, tt.wantName, pc.Name) - assert.Equal(t, tt.wantVersion, pc.Version) - }) - } -} diff --git a/pkg/policies/engine/rego/rego.go b/pkg/policies/engine/rego/rego.go index e29ca82bb..e83dc8912 100644 --- a/pkg/policies/engine/rego/rego.go +++ b/pkg/policies/engine/rego/rego.go @@ -88,25 +88,42 @@ func (p *regoOutputHook) Print(_ print.Context, msg string) error { //nolint:for // Force interface var _ engine.PolicyEngine = (*Engine)(nil) -// withProjectContext attaches the engine's per-evaluation project name / version -// to ctx so chainloop.* built-ins can read them from bctx.Context. Skipped when -// the engine was created without project context (e.g. local dev eval). -func (r *Engine) withProjectContext(ctx context.Context) context.Context { +// chainloop_metadata input keys. These form a contract with rego authors who +// read e.g. `input.chainloop_metadata.project_name` and forward it as an +// operand to chainloop.* built-ins; treat them as stable. +const ( + chainloopMetadataKey = "chainloop_metadata" + chainloopProjectNameKey = "project_name" + chainloopProjectVersionNameKey = "project_version_name" +) + +// injectProjectMetadata merges the engine's project name / version (when set) +// into input.chainloop_metadata. Existing keys (e.g. the intoto descriptor +// populated by Attestation_Material.GetEvaluableContent) are preserved. +func (r *Engine) injectProjectMetadata(inputMap map[string]interface{}) map[string]interface{} { if r.CommonEngineOptions == nil { - return ctx + return inputMap } if r.ProjectName == "" && r.ProjectVersionName == "" { - return ctx + return inputMap + } + + cm, _ := inputMap[chainloopMetadataKey].(map[string]interface{}) + if cm == nil { + cm = make(map[string]interface{}) + } + if r.ProjectName != "" { + cm[chainloopProjectNameKey] = r.ProjectName + } + if r.ProjectVersionName != "" { + cm[chainloopProjectVersionNameKey] = r.ProjectVersionName } - return builtins.WithProjectContext(ctx, builtins.ProjectContext{ - Name: r.ProjectName, - Version: r.ProjectVersionName, - }) + inputMap[chainloopMetadataKey] = cm + + return inputMap } func (r *Engine) Verify(ctx context.Context, policy *engine.Policy, input []byte, args map[string]any) (*engine.EvaluationResult, error) { - ctx = r.withProjectContext(ctx) - policyString := string(policy.Source) parsedModule, err := ast.ParseModule(policy.Name, policyString) if err != nil { @@ -128,6 +145,10 @@ func (r *Engine) Verify(ctx context.Context, policy *engine.Policy, input []byte decodedInput = inputMap } + if inputMap, ok := decodedInput.(map[string]interface{}); ok { + decodedInput = r.injectProjectMetadata(inputMap) + } + // put arguments embedded in the input object if args != nil { inputMap, ok := decodedInput.(map[string]interface{}) @@ -341,8 +362,6 @@ func getRuleName(packagePath ast.Ref, rule string) string { // MatchesParameters evaluates the matches_parameters rule in a rego policy. // The function creates an input object with policy parameters and expected parameters. func (r *Engine) MatchesParameters(ctx context.Context, policy *engine.Policy, evaluationParams, expectedParams map[string]string) (bool, error) { - ctx = r.withProjectContext(ctx) - policyString := string(policy.Source) parsedModule, err := ast.ParseModule(policy.Name, policyString) if err != nil { @@ -361,9 +380,10 @@ func (r *Engine) MatchesParameters(ctx context.Context, policy *engine.Policy, e } else { inputMap[expectedArgs] = expectedParams } + decodedInput := r.injectProjectMetadata(inputMap) // Evaluate matches_parameters rule - matchesParameters, found, err := r.evaluateMatchingRule(ctx, getRuleName(parsedModule.Package.Path, matchesParametersRule), parsedModule, inputMap) + matchesParameters, found, err := r.evaluateMatchingRule(ctx, getRuleName(parsedModule.Package.Path, matchesParametersRule), parsedModule, decodedInput) if err != nil { return false, err } @@ -378,8 +398,6 @@ func (r *Engine) MatchesParameters(ctx context.Context, policy *engine.Policy, e // MatchesEvaluation evaluates the matches_evaluation rule in a rego policy. // Creates an input object with expected parameters and policy violations. func (r *Engine) MatchesEvaluation(ctx context.Context, policy *engine.Policy, violations []string, expectedParams map[string]string) (bool, error) { - ctx = r.withProjectContext(ctx) - policyString := string(policy.Source) parsedModule, err := ast.ParseModule(policy.Name, policyString) if err != nil { @@ -398,9 +416,10 @@ func (r *Engine) MatchesEvaluation(ctx context.Context, policy *engine.Policy, v } else { inputMap[violationsResult] = violations } + decodedInput := r.injectProjectMetadata(inputMap) // Evaluate matches_evaluation rule - matchesEvaluation, found, err := r.evaluateMatchingRule(ctx, getRuleName(parsedModule.Package.Path, matchesEvaluationRule), parsedModule, inputMap) + matchesEvaluation, found, err := r.evaluateMatchingRule(ctx, getRuleName(parsedModule.Package.Path, matchesEvaluationRule), parsedModule, decodedInput) if err != nil { return false, err } diff --git a/pkg/policies/engine/rego/rego_test.go b/pkg/policies/engine/rego/rego_test.go index a977e465a..967d2166b 100644 --- a/pkg/policies/engine/rego/rego_test.go +++ b/pkg/policies/engine/rego/rego_test.go @@ -17,6 +17,7 @@ package rego import ( "context" + "encoding/json" "os" "testing" @@ -534,20 +535,10 @@ violations contains msg if { }) } -func TestRego_ProjectContextPlumbing(t *testing.T) { - // Custom built-in that captures the project context attached to bctx.Context - // and exposes the result so the policy can pivot on it. - var capturedCtx builtins.ProjectContext - var capturedOK bool - - require.NoError(t, builtins.Register(&ast.Builtin{ - Name: "test.capture_project_ctx", - Decl: types.NewFunction(types.Args(types.S), types.S), - }, func(bctx topdown.BuiltinContext, _ []*ast.Term, iter func(*ast.Term) error) error { - capturedCtx, capturedOK = builtins.ProjectContextFromContext(bctx.Context) - return iter(ast.StringTerm("ok")) - })) - +func TestRego_InjectProjectMetadataIntoInput(t *testing.T) { + // Policy raises a violation per project field that is missing or unequal to + // the expected value. RawData is requested so the test can also assert on + // the post-injection input shape. regoContent := []byte(`package test import rego.v1 @@ -557,36 +548,102 @@ result := { } violations contains msg if { - val := test.capture_project_ctx("noop") - val != "ok" - msg := "Capture failed" + want_name := input.args.want_name + want_name != "" + got_name := object.get(input.chainloop_metadata, "project_name", "") + got_name != want_name + msg := sprintf("project_name mismatch: got %q want %q", [got_name, want_name]) +} + +violations contains msg if { + want_version := input.args.want_version + want_version != "" + got_version := object.get(input.chainloop_metadata, "project_version_name", "") + got_version != want_version + msg := sprintf("project_version_name mismatch: got %q want %q", [got_version, want_version]) +} + +violations contains msg if { + input.args.expect_existing == "true" + got_existing := object.get(input.chainloop_metadata, "digest", {}) + got_existing.sha256 != "deadbeef" + msg := "existing chainloop_metadata.digest was overwritten" }`) - policy := &engine.Policy{Name: "ctx-test", Source: regoContent} + policy := &engine.Policy{Name: "inject-metadata-test", Source: regoContent} - t.Run("engine without project context leaves ctx empty", func(t *testing.T) { - capturedCtx, capturedOK = builtins.ProjectContext{}, false + tests := []struct { + name string + opts []engine.Option + input string + args map[string]any + wantInputCM map[string]any + }{ + { + name: "engine without project context does not touch input", + opts: nil, + input: `{"kind": "test"}`, + args: map[string]any{"want_name": "", "want_version": "", "expect_existing": "false"}, + // chainloop_metadata key not added when engine has no project context + wantInputCM: nil, + }, + { + name: "engine with both fields injects them", + opts: []engine.Option{engine.WithProjectContext("my-app", "v1.2.3")}, + input: `{"kind": "test"}`, + args: map[string]any{"want_name": "my-app", "want_version": "v1.2.3", "expect_existing": "false"}, + wantInputCM: map[string]any{ + "project_name": "my-app", + "project_version_name": "v1.2.3", + }, + }, + { + name: "engine with only project name injects only project_name", + opts: []engine.Option{engine.WithProjectContext("my-app", "")}, + input: `{"kind": "test"}`, + args: map[string]any{"want_name": "my-app", "want_version": "", "expect_existing": "false"}, + wantInputCM: map[string]any{ + "project_name": "my-app", + }, + }, + { + name: "merge preserves existing chainloop_metadata keys", + opts: []engine.Option{engine.WithProjectContext("my-app", "v1.2.3")}, + input: `{"kind": "test", "chainloop_metadata": {"digest": {"sha256": "deadbeef"}, "name": "subject"}}`, + args: map[string]any{"want_name": "my-app", "want_version": "v1.2.3", "expect_existing": "true"}, + wantInputCM: map[string]any{ + "project_name": "my-app", + "project_version_name": "v1.2.3", + "digest": map[string]any{"sha256": "deadbeef"}, + "name": "subject", + }, + }, + } - r := NewEngine() - _, err := r.Verify(context.TODO(), policy, []byte(`{"kind": "test"}`), nil) - require.NoError(t, err) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := append([]engine.Option{engine.WithIncludeRawData(true)}, tt.opts...) + r := NewEngine(opts...) - assert.False(t, capturedOK, "no project context should be attached") - assert.Empty(t, capturedCtx.Name) - assert.Empty(t, capturedCtx.Version) - }) + result, err := r.Verify(context.TODO(), policy, []byte(tt.input), tt.args) + require.NoError(t, err) + assert.Empty(t, result.Violations, "policy reported violations: %v", result.Violations) - t.Run("engine with project context propagates to builtin", func(t *testing.T) { - capturedCtx, capturedOK = builtins.ProjectContext{}, false + require.NotNil(t, result.RawData) + var rawInput map[string]any + require.NoError(t, json.Unmarshal(result.RawData.Input, &rawInput)) - r := NewEngine(engine.WithProjectContext("my-app", "v1.2.3")) - _, err := r.Verify(context.TODO(), policy, []byte(`{"kind": "test"}`), nil) - require.NoError(t, err) + if tt.wantInputCM == nil { + _, has := rawInput["chainloop_metadata"] + assert.False(t, has, "chainloop_metadata should not be present when engine has no project context") + return + } - require.True(t, capturedOK, "project context should be attached") - assert.Equal(t, "my-app", capturedCtx.Name) - assert.Equal(t, "v1.2.3", capturedCtx.Version) - }) + cm, ok := rawInput["chainloop_metadata"].(map[string]any) + require.True(t, ok, "chainloop_metadata missing or wrong type in raw input: %v", rawInput["chainloop_metadata"]) + assert.Equal(t, tt.wantInputCM, cm) + }) + } } func TestRego_StructuredViolations(t *testing.T) { diff --git a/pkg/policies/policies.go b/pkg/policies/policies.go index ffa33f86e..6237265be 100644 --- a/pkg/policies/policies.go +++ b/pkg/policies/policies.go @@ -175,8 +175,9 @@ func WithGroupCache(c cache.Cache[*groupWithReference]) PolicyVerifierOption { // WithProjectContext sets the project name and version that this verifier is // evaluating policies for. The values are forwarded to the underlying policy -// engine so chainloop.* built-ins can scope their queries automatically. -// Either may be empty, in which case built-ins must degrade gracefully. +// engine, which exposes them on input.chainloop_metadata.project_name / +// input.chainloop_metadata.project_version_name so policy authors can forward +// them as operands to chainloop.* built-ins. Either value may be empty. func WithProjectContext(name, version string) PolicyVerifierOption { return func(o *PolicyVerifierOptions) { o.ProjectName = name