Skip to content

Commit d4cc444

Browse files
authored
feat(policies): allow custom builtin functions in Rego policies (#2552)
Signed-off-by: Jose I. Paris <[email protected]>
1 parent d4f4f23 commit d4cc444

File tree

8 files changed

+374
-3
lines changed

8 files changed

+374
-3
lines changed
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
---
2+
name: custom-builtin-functions
3+
description: Create a custom builtin function to be used in the Rego policy engine
4+
---
5+
6+
### Policy Engine Extension
7+
8+
The OPA/Rego policy engine supports custom built-in functions written in Go.
9+
10+
**Adding Custom Built-ins**:
11+
12+
1. **Create Built-in Implementation** (e.g., `pkg/policies/engine/rego/builtins/myfeature.go`):
13+
```go
14+
package builtins
15+
16+
import (
17+
"github.com/open-policy-agent/opa/ast"
18+
"github.com/open-policy-agent/opa/topdown"
19+
"github.com/open-policy-agent/opa/types"
20+
)
21+
22+
const myFuncName = "chainloop.my_function"
23+
24+
func RegisterMyBuiltins() error {
25+
return Register(&ast.Builtin{
26+
Name: myFuncName,
27+
Description: "Description of what this function does",
28+
Decl: types.NewFunction(
29+
types.Args(types.Named("input", types.S).Description("this is the input")),
30+
types.Named("result", types.S).Description("this is the result"),
31+
),
32+
}, myFunctionImpl)
33+
}
34+
35+
func myFunctionImpl(bctx topdown.BuiltinContext, operands []*ast.Term, iter func(*ast.Term) error) error {
36+
// Extract arguments
37+
input, ok := operands[0].Value.(ast.String)
38+
if !ok {
39+
return fmt.Errorf("input must be a string")
40+
}
41+
42+
// Implement logic
43+
result := processInput(string(input))
44+
45+
// Return result
46+
return iter(ast.StringTerm(result))
47+
}
48+
49+
// Autoregisters on package load
50+
func init() {
51+
if err := RegisterMyBuiltins(); err != nil {
52+
panic(fmt.Sprintf("failed to register built-ins: %v", err))
53+
}
54+
}
55+
```
56+
57+
2. **Use in Policies** (`*.rego`):
58+
```rego
59+
package example
60+
import rego.v1
61+
62+
result := {
63+
"violations": violations,
64+
"skipped": false
65+
}
66+
67+
violations contains msg if {
68+
output := chainloop.my_function(input.value)
69+
output != "expected"
70+
msg := "Function returned unexpected value"
71+
}
72+
```
73+
74+
**Guidelines**:
75+
- Use `chainloop.*` namespace for all custom built-ins
76+
- Functions that call third party services should be marked as non-restrictive by adding the `NonRestrictiveBuiltin` category to the builtin definition
77+
- Always implement proper error handling and return meaningful error messages
78+
- Use context from `BuiltinContext` for timeout/cancellation support
79+
- Document function signatures and behavior in the `Description` field and parameter definitions

app/cli/internal/policydevel/eval.go

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,9 +165,6 @@ func verifyMaterial(pol *v1.Policies, material *v12.Attestation_Material, materi
165165
}
166166

167167
func craftMaterial(materialPath, materialKind string, logger *zerolog.Logger) (*v12.Attestation_Material, error) {
168-
if fileNotExists(materialPath) {
169-
return nil, fmt.Errorf("%s: does not exists", materialPath)
170-
}
171168
backend := &casclient.CASBackend{
172169
Name: "backend",
173170
MaxSize: 0,
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
//
2+
// Copyright 2025 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package builtins
17+
18+
import (
19+
"errors"
20+
"fmt"
21+
22+
"github.com/open-policy-agent/opa/v1/ast"
23+
"github.com/open-policy-agent/opa/v1/topdown"
24+
"github.com/open-policy-agent/opa/v1/types"
25+
)
26+
27+
const helloBuiltinName = "chainloop.hello"
28+
29+
func RegisterHelloBuiltin() error {
30+
return Register(&ast.Builtin{
31+
Name: helloBuiltinName,
32+
Description: "Example builtin",
33+
Decl: types.NewFunction(
34+
types.Args(
35+
types.Named("name", types.S).Description("Name of the person to greet"), // Digest to fetch
36+
),
37+
types.Named("response", types.A).Description("the hello world message"), // Response as object
38+
),
39+
}, getHelloImpl)
40+
}
41+
42+
type helloResponse struct {
43+
Message string `json:"message"`
44+
}
45+
46+
func getHelloImpl(_ topdown.BuiltinContext, operands []*ast.Term, iter func(*ast.Term) error) error {
47+
if len(operands) < 1 {
48+
return errors.New("need one operand")
49+
}
50+
51+
name, ok := operands[0].Value.(ast.String)
52+
if !ok {
53+
return errors.New("digest must be a string")
54+
}
55+
56+
message := fmt.Sprintf("Hello, %s!", string(name))
57+
58+
// call the iterator with the output value
59+
return iter(ast.NewTerm(ast.MustInterfaceToValue(helloResponse{message})))
60+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
//
2+
// Copyright 2025 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package builtins
17+
18+
import (
19+
"context"
20+
"testing"
21+
22+
"github.com/open-policy-agent/opa/v1/rego"
23+
"github.com/stretchr/testify/assert"
24+
"github.com/stretchr/testify/require"
25+
)
26+
27+
func TestHelloBuiltin(t *testing.T) {
28+
tests := []struct {
29+
name string
30+
policy string
31+
mockErr error
32+
expectedMessage string
33+
expectError bool
34+
}{
35+
{
36+
name: "successful render",
37+
policy: `package test
38+
import rego.v1
39+
40+
result := chainloop.hello("world")`,
41+
expectedMessage: "Hello, world!",
42+
expectError: false,
43+
},
44+
}
45+
46+
for _, tt := range tests {
47+
t.Run(tt.name, func(t *testing.T) {
48+
require.NoError(t, RegisterHelloBuiltin())
49+
// Prepare rego evaluation
50+
ctx := context.Background()
51+
r := rego.New(
52+
rego.Query("data.test.result"),
53+
rego.Module("test.rego", tt.policy),
54+
)
55+
rs, err := r.Eval(ctx)
56+
57+
if tt.expectError {
58+
assert.Error(t, err)
59+
return
60+
}
61+
62+
require.NoError(t, err)
63+
require.Len(t, rs, 1)
64+
require.Len(t, rs[0].Expressions, 1)
65+
66+
result, ok := rs[0].Expressions[0].Value.(map[string]interface{})
67+
require.True(t, ok)
68+
69+
// The status is returned as a number, convert it appropriately
70+
msgVal := result["message"]
71+
assert.Equal(t, tt.expectedMessage, msgVal)
72+
})
73+
}
74+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Copyright 2025 The Chainloop Authors.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package builtins
16+
17+
import (
18+
"github.com/open-policy-agent/opa/v1/ast"
19+
"github.com/open-policy-agent/opa/v1/topdown"
20+
)
21+
22+
const (
23+
// NonRestrictiveBuiltin is used in builtin definition categories to mark a builtin as non-suitable for Chainloop's restrictive mode
24+
NonRestrictiveBuiltin = "non-restrictive"
25+
)
26+
27+
// Register registers built-ins globally with OPA
28+
// This should be called once during initialization
29+
func Register(def *ast.Builtin, builtinFunc topdown.BuiltinFunc) error {
30+
// Register the built-in declaration with AST
31+
ast.RegisterBuiltin(def)
32+
33+
// Register the implementation with topdown
34+
topdown.RegisterBuiltinFunc(def.Name, builtinFunc)
35+
return nil
36+
}

pkg/policies/engine/rego/rego.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ import (
2020
"context"
2121
"encoding/json"
2222
"fmt"
23+
"slices"
2324

2425
"github.com/chainloop-dev/chainloop/pkg/policies/engine"
26+
"github.com/chainloop-dev/chainloop/pkg/policies/engine/rego/builtins"
2527
"github.com/open-policy-agent/opa/ast"
2628
"github.com/open-policy-agent/opa/rego"
2729
"github.com/open-policy-agent/opa/v1/topdown/print"
@@ -301,6 +303,13 @@ func (r *Engine) Capabilities() *ast.Capabilities {
301303
localBuiltIns := make(map[string]*ast.Builtin, len(ast.BuiltinMap))
302304
maps.Copy(localBuiltIns, ast.BuiltinMap)
303305

306+
// remove custom builtins self-declared non-restrictive
307+
for k, builtin := range localBuiltIns {
308+
if slices.Contains(builtin.Categories, builtins.NonRestrictiveBuiltin) {
309+
delete(localBuiltIns, k)
310+
}
311+
}
312+
304313
// Remove not allowed builtins
305314
for _, notAllowed := range builtinFuncNotAllowed {
306315
delete(localBuiltIns, notAllowed.Name)

0 commit comments

Comments
 (0)