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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,27 @@ opa {

**Input fields:** `input.method`, `input.path` (string array), `input.headers`, `input.remote_addr` (includes port — use `startswith` to match by IP).

### Testing policies

The `test` field holds a Rego test module that is run against the policy when `cachewd` starts. Any rule prefixed with `test_` is executed; if a test fails, `cachewd` exits.

```hcl
opa {
policy = <<EOF
package cachew.authz
default allow := false
allow if input.method == "POST"
EOF
test = <<EOF
package cachew.authz_test
import data.cachew.authz

test_post_allowed if authz.allow with input as {"method": "POST"}
test_get_denied if not authz.allow with input as {"method": "GET"}
EOF
}
```

## GitHub App Authentication

For private Git repositories and GitHub release assets, configure a GitHub App:
Expand Down
9 changes: 9 additions & 0 deletions cachew.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ opa {
allow if startswith(input.remote_addr, "127.0.0.1:")
allow if not input.path[0] in {"api", "admin"}
EOF
test = <<EOF
package cachew.authz_test
import data.cachew.authz

test_localhost_allowed if authz.allow with input as {"remote_addr": "127.0.0.1:1234", "path": ["api"]}
test_remote_strategy_allowed if authz.allow with input as {"remote_addr": "10.0.0.1:1234", "path": ["git"]}
test_remote_api_denied if not authz.allow with input as {"remote_addr": "10.0.0.1:1234", "path": ["api"]}
test_remote_admin_denied if not authz.allow with input as {"remote_addr": "10.0.0.1:1234", "path": ["admin"]}
EOF
}

# github-app {
Expand Down
11 changes: 11 additions & 0 deletions cmd/cachewd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ func main() {
fatalIfError(ctx, logger, err, "Failed to start metrics server")
}

runOPATests(ctx, logger, globalConfig.OPAConfig)

logger.InfoContext(ctx, "Starting cachewd", "bind", globalConfig.Bind)

server, err := newServer(
Expand Down Expand Up @@ -319,6 +321,15 @@ func newMux(ctx context.Context, shuttingDown *atomic.Bool, cr *cache.Registry,
return handler, nil
}

// runOPATests executes the configured OPA policy tests at startup, exiting if any fail.
func runOPATests(ctx context.Context, logger *slog.Logger, cfg opa.Config) {
passed, err := opa.RunTests(ctx, cfg)
fatalIfError(ctx, logger, err, "OPA tests failed")
if passed > 0 {
logger.InfoContext(ctx, "OPA tests passed", "count", passed)
}
}

func fatalIfError(ctx context.Context, logger *slog.Logger, err error, msg string) {
if err == nil {
return
Expand Down
70 changes: 70 additions & 0 deletions internal/opa/opa.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@ package opa
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"

"github.com/alecthomas/errors"
"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"
"github.com/open-policy-agent/opa/v1/tester"

"github.com/block/cachew/internal/logging"
)
Expand All @@ -29,6 +33,7 @@ type Config struct {
PolicyFile string `hcl:"policy-file,optional" help:"Path to a Rego policy file."`
Data string `hcl:"data,optional" help:"Inline JSON object loaded as OPA data.*"`
DataFile string `hcl:"data-file,optional" help:"Path to a JSON file loaded as OPA data.*"`
Test string `hcl:"test,optional" help:"Inline Rego test module run against the policy when cachewd starts."`
}

// Middleware returns an http.Handler that evaluates OPA policy before delegating to next.
Expand Down Expand Up @@ -70,6 +75,71 @@ func Middleware(ctx context.Context, cfg Config, next http.Handler) (http.Handle
}), nil
}

// RunTests compiles the configured policy together with the Rego test module in
// cfg.Test and executes every test_* rule. It returns the number of tests that
// passed and an error enumerating any that failed or errored. When cfg.Test is
// empty it is a no-op. The policy under test is loaded the same way as
// Middleware, so an empty policy config exercises DefaultPolicy.
func RunTests(ctx context.Context, cfg Config) (int, error) {
if cfg.Test == "" {
return 0, nil
}

policy, err := loadPolicy(cfg)
if err != nil {
return 0, err
}
modules, err := parseTestModules(policy, cfg.Test)
if err != nil {
return 0, err
}

runner := tester.NewRunner().SetModules(modules)
if cfg.Data != "" || cfg.DataFile != "" {
opaData, err := loadData(cfg)
if err != nil {
return 0, err
}
runner = runner.SetStore(inmem.NewFromObject(opaData))
}

ch, err := runner.RunTests(ctx, nil)
if err != nil {
return 0, errors.Errorf("run OPA tests: %w", err)
}

passed := 0
var failures []string
for result := range ch {
switch {
case result.Pass():
passed++
case result.Skip:
case result.Error != nil:
failures = append(failures, fmt.Sprintf("%s.%s: %v", result.Package, result.Name, result.Error))
default:
failures = append(failures, fmt.Sprintf("%s.%s: failed", result.Package, result.Name))
}
}
if len(failures) > 0 {
return passed, errors.Errorf("OPA tests failed: %s", strings.Join(failures, "; "))
}
return passed, nil
}

// parseTestModules parses the policy and test Rego sources into modules keyed by filename.
func parseTestModules(policy, test string) (map[string]*ast.Module, error) {
policyModule, err := ast.ParseModule("policy.rego", policy)
if err != nil {
return nil, errors.Errorf("parse OPA policy: %w", err)
}
testModule, err := ast.ParseModule("test.rego", test)
if err != nil {
return nil, errors.Errorf("parse OPA test: %w", err)
}
return map[string]*ast.Module{"policy.rego": policyModule, "test.rego": testModule}, nil
}

// prepareQuery compiles a single Rego query against the given policy and data options.
func prepareQuery(ctx context.Context, query, policy string, dataOpts []func(*rego.Rego)) (rego.PreparedEvalQuery, error) {
opts := make([]func(*rego.Rego), 0, 2+len(dataOpts))
Expand Down
90 changes: 90 additions & 0 deletions internal/opa/opa_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,96 @@ allow if input.headers["authorization"]
assert.Equal(t, http.StatusOK, w.Code)
}

func TestRunTests(t *testing.T) {
tests := []struct {
Name string
Config opa.Config
ExpectError bool
ExpectPass int
}{
{
Name: "NoTestIsNoOp",
Config: opa.Config{},
},
{
Name: "PassingTestsAgainstInlinePolicy",
Config: opa.Config{
Policy: `package cachew.authz
default allow := false
allow if input.method == "POST"
`,
Test: `package cachew.authz_test
import data.cachew.authz

test_post_allowed if authz.allow with input as {"method": "POST"}
test_get_denied if not authz.allow with input as {"method": "GET"}
`,
},
ExpectPass: 2,
},
{
Name: "FailingTest",
Config: opa.Config{
Policy: `package cachew.authz
default allow := false
allow if input.method == "POST"
`,
Test: `package cachew.authz_test
import data.cachew.authz

test_get_allowed if authz.allow with input as {"method": "GET"}
`,
},
ExpectError: true,
},
{
Name: "TestsAgainstDefaultPolicy",
Config: opa.Config{
Test: `package cachew.authz_test
import data.cachew.authz

test_localhost_allowed if authz.allow with input as {"remote_addr": "127.0.0.1:1", "path": ["api"]}
test_remote_admin_denied if not authz.allow with input as {"remote_addr": "10.0.0.1:1", "path": ["admin"]}
`,
},
ExpectPass: 2,
},
{
Name: "TestsWithData",
Config: opa.Config{
Policy: `package cachew.authz
default allow := false
allow if data.allowed_methods[input.method]
`,
Data: `{"allowed_methods": {"DELETE": true}}`,
Test: `package cachew.authz_test
import data.cachew.authz

test_delete_allowed if authz.allow with input as {"method": "DELETE"}
`,
},
ExpectPass: 1,
},
{
Name: "InvalidTestModule",
Config: opa.Config{Test: "not valid rego {"},
ExpectError: true,
},
}

for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
passed, err := opa.RunTests(t.Context(), test.Config)
if test.ExpectError {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, test.ExpectPass, passed)
})
}
}

func TestMiddlewareEmptyPolicyDeniesAll(t *testing.T) {
policy := `package cachew.authz
`
Expand Down