Skip to content

Commit e604ca1

Browse files
Add support for embedding custom Rego rules (library and CLI) (#355)
* Add support for embedding custom Rego rules in Poutine library This enhancement allows library consumers (like pkg-supply and spicy-poutine) to embed their own custom Rego rules directly into their binaries alongside Poutine's built-in rules, creating fully self-contained deployments without filesystem dependencies. Changes: - Add NewOpaWithEmbeddedRules() constructor that accepts embed.FS containing custom rules - Add AddEmbeddedRules() method for adding rules to existing Opa instances - Modify Compile() to load custom embedded rules alongside built-in rules - Custom rules respect skip and allowed filters like filesystem-based rules - Fully backward compatible with existing NewOpa() usage Usage example: //go:embed rules/*.rego var CustomRules embed.FS opa, err := poutineOpa.NewOpaWithEmbeddedRules(ctx, config, CustomRules, "rules") 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Fix wrapcheck lint issues in custom embedded rules loading Wrap errors from embed.FS.ReadFile() and fs.WalkDir() with context to satisfy wrapcheck linter for new code. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Add CLI support for custom embedded rules and remove AddEmbeddedRules - Remove AddEmbeddedRules() method (no clear use case) - Add CustomEmbeddedRules and CustomEmbeddedRulesRoot exported variables to cmd package - Update newOpa() and newOpaWithConfig() to use NewOpaWithEmbeddedRules when set - CLI extensions can now set poutineCmd.CustomEmbeddedRules before Execute() 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Remove customRoot parameter - always use "." as root The customRoot parameter was unnecessary implementation detail. Custom embedded rules are now always loaded from "." root. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Simplify custom embedded rules prefix to "custom/" Remove unnecessary index from prefix - just use "custom/" like "poutine/opa/" for built-in and "include/" for filesystem rules. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> --------- Co-authored-by: Claude <[email protected]>
1 parent 8ba91de commit e604ca1

File tree

5 files changed

+206
-5
lines changed

5 files changed

+206
-5
lines changed

cmd/root.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cmd
22

33
import (
44
"context"
5+
"embed"
56
"fmt"
67
"os"
78
"os/signal"
@@ -36,6 +37,7 @@ var (
3637
Date string
3738
)
3839
var Token string
40+
var CustomEmbeddedRules *embed.FS
3941
var cfgFile string
4042
var config *models.Config = models.DefaultConfig()
4143
var skipRules []string
@@ -220,7 +222,16 @@ func newOpa(ctx context.Context) (*opa.Opa, error) {
220222
if len(allowedRules) > 0 {
221223
config.AllowedRules = allowedRules
222224
}
223-
opaClient, err := opa.NewOpa(ctx, config)
225+
226+
var opaClient *opa.Opa
227+
var err error
228+
229+
if CustomEmbeddedRules != nil {
230+
opaClient, err = opa.NewOpaWithEmbeddedRules(ctx, config, *CustomEmbeddedRules)
231+
} else {
232+
opaClient, err = opa.NewOpa(ctx, config)
233+
}
234+
224235
if err != nil {
225236
log.Error().Err(err).Msg("Failed to create OPA client")
226237
return nil, err
@@ -231,7 +242,15 @@ func newOpa(ctx context.Context) (*opa.Opa, error) {
231242

232243
// newOpaWithConfig creates an OPA client with request-scoped configuration
233244
func newOpaWithConfig(ctx context.Context, cfg *models.Config) (*opa.Opa, error) {
234-
opaClient, err := opa.NewOpa(ctx, cfg)
245+
var opaClient *opa.Opa
246+
var err error
247+
248+
if CustomEmbeddedRules != nil {
249+
opaClient, err = opa.NewOpaWithEmbeddedRules(ctx, cfg, *CustomEmbeddedRules)
250+
} else {
251+
opaClient, err = opa.NewOpa(ctx, cfg)
252+
}
253+
235254
if err != nil {
236255
log.Error().Err(err).Msg("Failed to create OPA client")
237256
return nil, fmt.Errorf("failed to create OPA client: %w", err)

opa/opa.go

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,15 @@ var regoFs embed.FS
2727
//go:embed capabilities.json
2828
var capabilitiesJson []byte
2929

30+
type embeddedSource struct {
31+
fs embed.FS
32+
}
33+
3034
type Opa struct {
31-
Compiler *ast.Compiler
32-
Store storage.Store
33-
LoadPaths []string
35+
Compiler *ast.Compiler
36+
Store storage.Store
37+
LoadPaths []string
38+
customEmbeddedRules []embeddedSource
3439
}
3540

3641
func NewOpa(ctx context.Context, config *models.Config) (*Opa, error) {
@@ -61,6 +66,45 @@ func NewOpa(ctx context.Context, config *models.Config) (*Opa, error) {
6166
return newOpa, nil
6267
}
6368

69+
// NewOpaWithEmbeddedRules creates a new Opa instance with custom embedded Rego rules.
70+
// This allows library consumers to embed their own Rego rules directly in their binaries
71+
// alongside Poutine's built-in rules, creating fully self-contained deployments.
72+
//
73+
// Example usage:
74+
//
75+
// //go:embed rules
76+
// var CustomRules embed.FS
77+
//
78+
// opa, err := poutineOpa.NewOpaWithEmbeddedRules(ctx, config, CustomRules)
79+
func NewOpaWithEmbeddedRules(ctx context.Context, config *models.Config, customFS embed.FS) (*Opa, error) {
80+
registerBuiltinFunctions()
81+
82+
newOpa := &Opa{
83+
Store: inmem.NewFromObject(map[string]interface {
84+
}{
85+
"config": models.DefaultConfig(),
86+
}),
87+
customEmbeddedRules: []embeddedSource{{fs: customFS}},
88+
}
89+
90+
if err := newOpa.WithConfig(ctx, config); err != nil {
91+
return nil, fmt.Errorf("failed to set opa with config: %w", err)
92+
}
93+
94+
subset := []string{}
95+
for _, skip := range config.Skip {
96+
if skip.HasOnlyRule() {
97+
subset = append(subset, skip.Rule...)
98+
}
99+
}
100+
101+
if err := newOpa.Compile(ctx, subset, config.AllowedRules); err != nil {
102+
return nil, fmt.Errorf("failed to initialize opa compiler: %w", err)
103+
}
104+
105+
return newOpa, nil
106+
}
107+
64108
func (o *Opa) Print(ctx print.Context, s string) error {
65109
log.Debug().Ctx(ctx.Context).Str("location", ctx.Location.String()).Msg(s)
66110
return nil
@@ -103,6 +147,8 @@ func skipRule(path string, skip []string, allowed []string) bool {
103147

104148
func (o *Opa) Compile(ctx context.Context, skip []string, allowed []string) error {
105149
modules := make(map[string]string)
150+
151+
// Load Poutine's built-in rules
106152
err := fs.WalkDir(regoFs, "rego", func(path string, d fs.DirEntry, err error) error {
107153
if d.IsDir() {
108154
return err
@@ -124,6 +170,35 @@ func (o *Opa) Compile(ctx context.Context, skip []string, allowed []string) erro
124170
return err
125171
}
126172

173+
// Load custom embedded rules
174+
for _, source := range o.customEmbeddedRules {
175+
err := fs.WalkDir(source.fs, ".", func(path string, d fs.DirEntry, err error) error {
176+
if err != nil {
177+
return err
178+
}
179+
180+
if d.IsDir() {
181+
return nil
182+
}
183+
184+
if skipRule(path, skip, allowed) {
185+
return nil
186+
}
187+
188+
content, err := source.fs.ReadFile(path)
189+
if err != nil {
190+
return fmt.Errorf("failed to read custom embedded rule %s: %w", path, err)
191+
}
192+
193+
modules["custom/"+path] = string(content)
194+
return nil
195+
})
196+
if err != nil {
197+
return fmt.Errorf("failed to load custom embedded rules: %w", err)
198+
}
199+
}
200+
201+
// Load rules from filesystem paths
127202
result, err := loader.NewFileLoader().
128203
WithProcessAnnotation(true).
129204
WithRegoVersion(ast.RegoV0CompatV1).

opa/opa_test.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package opa
22

33
import (
44
"context"
5+
"embed"
56
"github.com/boostsecurityio/poutine/models"
67
"github.com/boostsecurityio/poutine/results"
78
"github.com/open-policy-agent/opa/v1/ast"
@@ -11,6 +12,9 @@ import (
1112
"testing"
1213
)
1314

15+
//go:embed testdata/embedded
16+
var testEmbeddedRules embed.FS
17+
1418
func noOpaErrors(t *testing.T, err error) {
1519
if err == nil {
1620
return
@@ -250,3 +254,73 @@ func TestWithRulesConfig(t *testing.T) {
250254
assert.Equal(t, []interface{}{}, rule.Config["allowed_runners"].Default)
251255
assert.Equal(t, []interface{}{"self-hosted"}, rule.Config["allowed_runners"].Value)
252256
}
257+
258+
func TestNewOpaWithEmbeddedRules(t *testing.T) {
259+
ctx := context.TODO()
260+
261+
// Test NewOpaWithEmbeddedRules constructor
262+
opa, err := NewOpaWithEmbeddedRules(ctx, &models.Config{
263+
Include: []models.ConfigInclude{},
264+
}, testEmbeddedRules)
265+
noOpaErrors(t, err)
266+
assert.NotNil(t, opa)
267+
268+
// Verify that the custom rule was loaded and can be evaluated
269+
var customRule map[string]interface{}
270+
err = opa.Eval(ctx, "data.custom.rule", nil, &customRule)
271+
noOpaErrors(t, err)
272+
assert.Equal(t, "Custom Test Rule", customRule["title"])
273+
assert.Equal(t, "warning", customRule["level"])
274+
275+
// Test that the custom rule logic works
276+
var results []map[string]interface{}
277+
input := map[string]interface{}{
278+
"test_value": "test data",
279+
}
280+
err = opa.Eval(ctx, "data.custom.results", input, &results)
281+
noOpaErrors(t, err)
282+
assert.Len(t, results, 1)
283+
assert.Equal(t, "Custom rule executed successfully", results[0]["message"])
284+
assert.Equal(t, "test data", results[0]["details"])
285+
286+
// Verify built-in Poutine rules are still loaded
287+
var builtinRule interface{}
288+
err = opa.Eval(ctx, "data.rules.pr_runs_on_self_hosted.rule", nil, &builtinRule)
289+
noOpaErrors(t, err)
290+
assert.NotNil(t, builtinRule)
291+
}
292+
293+
func TestEmbeddedRulesWithSkipAndAllowed(t *testing.T) {
294+
ctx := context.TODO()
295+
296+
// Test that skip rules work with embedded custom rules
297+
opa, err := NewOpaWithEmbeddedRules(ctx, &models.Config{
298+
Include: []models.ConfigInclude{},
299+
}, testEmbeddedRules)
300+
noOpaErrors(t, err)
301+
302+
// Verify both rules are loaded initially
303+
var customRule map[string]interface{}
304+
err = opa.Eval(ctx, "data.custom.rule", nil, &customRule)
305+
noOpaErrors(t, err)
306+
assert.Equal(t, "Custom Test Rule", customRule["title"])
307+
308+
var skippableRule map[string]interface{}
309+
err = opa.Eval(ctx, "data.custom.rules.skippable_rule", nil, &skippableRule)
310+
noOpaErrors(t, err)
311+
assert.NotNil(t, skippableRule)
312+
assert.Equal(t, "Skippable Test Rule", skippableRule["title"])
313+
314+
// Now recompile with skip rule
315+
err = opa.Compile(ctx, []string{"skippable_rule"}, []string{})
316+
noOpaErrors(t, err)
317+
318+
// The non-skipped rule should still be available
319+
err = opa.Eval(ctx, "data.custom.rule", nil, &customRule)
320+
noOpaErrors(t, err)
321+
assert.Equal(t, "Custom Test Rule", customRule["title"])
322+
323+
// The skipped rule should not be available
324+
err = opa.Eval(ctx, "data.custom.rules.skippable_rule", nil, &skippableRule)
325+
assert.Error(t, err)
326+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package custom
2+
3+
# METADATA
4+
# title: Custom Test Rule
5+
# description: A custom rule for testing embedded rules functionality
6+
# custom:
7+
# level: warning
8+
9+
rule := {
10+
"title": "Custom Test Rule",
11+
"description": "This is a custom embedded rule for testing",
12+
"level": "warning",
13+
}
14+
15+
results contains {
16+
"message": "Custom rule executed successfully",
17+
"details": input.test_value,
18+
} if {
19+
input.test_value != ""
20+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package custom.rules
2+
3+
# METADATA
4+
# title: Skippable Test Rule
5+
# description: A rule for testing skip functionality
6+
# custom:
7+
# level: warning
8+
9+
skippable_rule := {
10+
"title": "Skippable Test Rule",
11+
"description": "This rule should be skippable",
12+
"level": "warning",
13+
}

0 commit comments

Comments
 (0)