Refactor Policy Enforcer to Policy Checker
- Renamed the `PolicyEnforcer` interface and related implementations to `PolicyChecker` for clarity and consistency. - Updated configuration keys in YAML files to reflect the new `checkPolicy` terminology. - Adjusted related code, tests, and documentation to support the new naming convention and ensure compatibility. - Enhanced comments and examples for the `checkPolicy` configuration to improve usability.
This commit is contained in:
194
pkg/plugin/implementation/opapolicychecker/README.md
Normal file
194
pkg/plugin/implementation/opapolicychecker/README.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# OPA Policy Checker Plugin
|
||||
|
||||
Validates incoming Beckn messages against network-defined business rules using [Open Policy Agent (OPA)](https://www.openpolicyagent.org/) and the Rego policy language. Non-compliant messages are rejected with a `BadRequest` error code.
|
||||
|
||||
## Features
|
||||
|
||||
- Evaluates business rules defined in Rego policies
|
||||
- Supports multiple policy sources: remote URL, local file, directory, or OPA bundle (`.tar.gz`)
|
||||
- Structured result format: `{"valid": bool, "violations": []string}`
|
||||
- Fail-closed on empty/undefined query results — misconfigured policies are treated as violations
|
||||
- Runtime config forwarding: adapter config values are accessible in Rego as `data.config.<key>`
|
||||
- Action-based enforcement: apply policies only to specific beckn actions (e.g., `confirm`, `search`)
|
||||
|
||||
## Configuration
|
||||
|
||||
```yaml
|
||||
checkPolicy:
|
||||
id: opapolicychecker
|
||||
config:
|
||||
type: file
|
||||
location: ./pkg/plugin/implementation/opapolicychecker/testdata/example.rego
|
||||
query: "data.policy.result"
|
||||
actions: "confirm,search"
|
||||
steps:
|
||||
- checkPolicy
|
||||
- addRoute
|
||||
```
|
||||
|
||||
### Configuration Parameters
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| `type` | string | Yes | - | Policy source type: `url`, `file`, `dir`, or `bundle` |
|
||||
| `location` | string | Yes | - | Path or URL to the policy source (`.tar.gz` for bundles) |
|
||||
| `query` | string | Yes | - | Rego query path to evaluate (e.g., `data.policy.result`) |
|
||||
| `actions` | string | No | *(all)* | Comma-separated beckn actions to enforce |
|
||||
| `enabled` | string | No | `"true"` | Enable or disable the plugin |
|
||||
| `debugLogging` | string | No | `"false"` | Enable verbose OPA evaluation logging |
|
||||
| `refreshIntervalSeconds` | string | No | - | Reload policies every N seconds (0 or omit = disabled) |
|
||||
| *any other key* | string | No | - | Forwarded to Rego as `data.config.<key>` |
|
||||
|
||||
|
||||
|
||||
## Policy Hot-Reload
|
||||
|
||||
When `refreshIntervalSeconds` is set, a background goroutine periodically re-fetches and recompiles the policy source without restarting the adapter:
|
||||
|
||||
- **Atomic swap**: the old evaluator stays fully active until the new one is compiled — no gap in enforcement
|
||||
- **Non-fatal errors**: if the reload fails (e.g., file temporarily unreachable or parse error), the error is logged and the previous policy stays active
|
||||
- **Goroutine lifecycle**: the reload loop is tied to the adapter context and stops cleanly on shutdown
|
||||
|
||||
```yaml
|
||||
config:
|
||||
type: file
|
||||
location: ./policies/compliance.rego
|
||||
query: "data.policy.result"
|
||||
refreshIntervalSeconds: "300" # reload every 5 minutes
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### Initialization (Load Time)
|
||||
|
||||
1. **Load Policy Source**: Fetches `.rego` files from the configured `location` — URL, file, directory, or OPA bundle
|
||||
2. **Compile Policies**: Compiles all Rego modules into a single optimized `PreparedEvalQuery`
|
||||
3. **Set Query**: Prepares the OPA query from the configured `query` path (e.g., `data.policy.result`)
|
||||
|
||||
### Request Evaluation (Runtime)
|
||||
|
||||
1. **Check Action Match**: If `actions` is configured, skip evaluation for non-matching actions
|
||||
2. **Evaluate OPA Query**: Run the prepared query with the full beckn message as `input`
|
||||
3. **Handle Result**:
|
||||
- If the query returns no result (undefined) → **violation** (fail-closed)
|
||||
- If result is `{"valid": bool, "violations": []string}` → use structured format
|
||||
- If result is a `set` or `[]string` → each string is a violation
|
||||
- If result is a `bool` → `false` = violation
|
||||
- If result is a `string` → non-empty = violation
|
||||
4. **Reject or Allow**: If violations are found, NACK the request with all violation messages
|
||||
|
||||
### Supported Query Output Formats
|
||||
|
||||
| Rego Output | Behavior |
|
||||
|-------------|----------|
|
||||
| `{"valid": bool, "violations": ["string"]}` | Structured result format (recommended) |
|
||||
| `set()` / `[]string` | Each string is a violation message |
|
||||
| `bool` (`true`/`false`) | `false` = denied, `true` = allowed |
|
||||
| `string` | Non-empty = violation |
|
||||
| Empty/undefined | **Violation** (fail-closed) — indicates misconfigured query path |
|
||||
|
||||
## Example Usage
|
||||
|
||||
### Local File
|
||||
|
||||
```yaml
|
||||
checkPolicy:
|
||||
id: opapolicychecker
|
||||
config:
|
||||
type: file
|
||||
location: ./pkg/plugin/implementation/opapolicychecker/testdata/example.rego
|
||||
query: "data.policy.result"
|
||||
```
|
||||
|
||||
### Remote URL
|
||||
|
||||
```yaml
|
||||
checkPolicy:
|
||||
id: opapolicychecker
|
||||
config:
|
||||
type: url
|
||||
location: https://policies.example.com/compliance.rego
|
||||
query: "data.policy.result"
|
||||
```
|
||||
|
||||
### Local Directory (multiple `.rego` files)
|
||||
|
||||
```yaml
|
||||
checkPolicy:
|
||||
id: opapolicychecker
|
||||
config:
|
||||
type: dir
|
||||
location: ./policies
|
||||
query: "data.policy.result"
|
||||
```
|
||||
|
||||
### OPA Bundle (`.tar.gz`)
|
||||
|
||||
```yaml
|
||||
checkPolicy:
|
||||
id: opapolicychecker
|
||||
config:
|
||||
type: bundle
|
||||
location: https://nfo.example.org/policies/bundle.tar.gz
|
||||
query: "data.retail.validation.result"
|
||||
```
|
||||
|
||||
## Writing Policies
|
||||
|
||||
Policies are written in [Rego](https://www.openpolicyagent.org/docs/latest/policy-language/). The plugin passes the full beckn message body as `input` and any adapter config values as `data.config`:
|
||||
|
||||
```rego
|
||||
package policy
|
||||
|
||||
import rego.v1
|
||||
|
||||
# Default result: valid with no violations.
|
||||
default result := {
|
||||
"valid": true,
|
||||
"violations": []
|
||||
}
|
||||
|
||||
# Compute the result from collected violations.
|
||||
result := {
|
||||
"valid": count(violations) == 0,
|
||||
"violations": violations
|
||||
}
|
||||
|
||||
# Require provider on confirm
|
||||
violations contains "confirm: missing provider" if {
|
||||
input.context.action == "confirm"
|
||||
not input.message.order.provider
|
||||
}
|
||||
|
||||
# Configurable threshold from adapter config
|
||||
violations contains "delivery lead time too short" if {
|
||||
input.context.action == "confirm"
|
||||
lead := input.message.order.fulfillments[_].start.time.duration
|
||||
to_number(lead) < to_number(data.config.minDeliveryLeadHours)
|
||||
}
|
||||
```
|
||||
|
||||
See [`testdata/example.rego`](./testdata/example.rego) for a full working example.
|
||||
|
||||
## Relationship with Schema Validator
|
||||
|
||||
`opapolicychecker` and `schemav2validator` serve different purposes:
|
||||
|
||||
- **Schemav2Validator**: Validates message **structure** against OpenAPI/JSON Schema specs
|
||||
- **OPA Policy Checker**: Evaluates **business rules** via OPA/Rego policies
|
||||
|
||||
Configure them side-by-side in your adapter steps as needed.
|
||||
|
||||
## Plugin ID vs Step Name
|
||||
|
||||
- **Plugin ID** (used in `id:`): `opapolicychecker` (lowercase, implementation-specific)
|
||||
- **Step name** (used in `steps:` list and YAML key): `checkPolicy` (camelCase verb)
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `github.com/open-policy-agent/opa` — OPA Go SDK for policy evaluation and bundle loading
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- **No bundle signature verification**: When using `type: bundle`, bundle signature verification is skipped. This is planned for a future enhancement.
|
||||
- **Network-level scoping**: Policies apply to all messages handled by the adapter instance. Per-network policy mapping (by `networkId`) is tracked for follow-up.
|
||||
326
pkg/plugin/implementation/opapolicychecker/benchmark_test.go
Normal file
326
pkg/plugin/implementation/opapolicychecker/benchmark_test.go
Normal file
@@ -0,0 +1,326 @@
|
||||
// Benchmarks for policy enforcer evaluation scaling.
|
||||
// Measures how OPA evaluation time changes with rule count (1 to 500 rules),
|
||||
// covering both realistic (mostly inactive) and worst-case (all active) scenarios.
|
||||
// Also benchmarks compilation time (one-time startup cost).
|
||||
//
|
||||
// Run human-readable report: go test -run TestBenchmarkReport -v -count=1
|
||||
// Run Go benchmarks: go test -bench=. -benchmem -count=1
|
||||
package opapolicychecker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// generateDummyRules creates a .rego policy file with N rules.
|
||||
// Only one rule matches the input (action == "confirm"), the rest have impossible
|
||||
// conditions (action == "foobar1", "foobar2", ...) to simulate realistic rule bloat
|
||||
// where most rules don't fire.
|
||||
func generateDummyRules(n int) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("package policy\nimport rego.v1\n\n")
|
||||
|
||||
// One real rule that actually fires
|
||||
sb.WriteString("violations contains \"real_violation\" if {\n")
|
||||
sb.WriteString(" input.context.action == \"confirm\"\n")
|
||||
sb.WriteString(" input.message.order.value > 10000\n")
|
||||
sb.WriteString("}\n\n")
|
||||
|
||||
// N-1 dummy rules with impossible conditions (simulate rule bloat)
|
||||
for i := 1; i < n; i++ {
|
||||
sb.WriteString(fmt.Sprintf("violations contains \"dummy_violation_%d\" if {\n", i))
|
||||
sb.WriteString(fmt.Sprintf(" input.context.action == \"foobar%d\"\n", i))
|
||||
sb.WriteString(fmt.Sprintf(" input.message.order.value > %d\n", i*100))
|
||||
sb.WriteString("}\n\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// generateActiveRules creates N rules that ALL fire on the test input.
|
||||
// This is the worst case: every rule matches.
|
||||
func generateActiveRules(n int) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("package policy\nimport rego.v1\n\n")
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
sb.WriteString(fmt.Sprintf("violations contains \"active_violation_%d\" if {\n", i))
|
||||
sb.WriteString(" input.context.action == \"confirm\"\n")
|
||||
sb.WriteString("}\n\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// sampleBecknInput is a realistic beckn confirm message for benchmarking.
|
||||
var sampleBecknInput = []byte(`{
|
||||
"context": {
|
||||
"domain": "energy",
|
||||
"action": "confirm",
|
||||
"version": "1.1.0",
|
||||
"bap_id": "buyer-bap.example.com",
|
||||
"bap_uri": "https://buyer-bap.example.com",
|
||||
"bpp_id": "seller-bpp.example.com",
|
||||
"bpp_uri": "https://seller-bpp.example.com",
|
||||
"transaction_id": "txn-12345",
|
||||
"message_id": "msg-67890",
|
||||
"timestamp": "2026-03-04T10:00:00Z"
|
||||
},
|
||||
"message": {
|
||||
"order": {
|
||||
"id": "order-001",
|
||||
"provider": {"id": "seller-1"},
|
||||
"items": [
|
||||
{"id": "item-1", "quantity": {"selected": {"count": 100}}},
|
||||
{"id": "item-2", "quantity": {"selected": {"count": 50}}}
|
||||
],
|
||||
"value": 15000,
|
||||
"fulfillment": {
|
||||
"type": "DELIVERY",
|
||||
"start": {"time": {"timestamp": "2026-03-05T08:00:00Z"}},
|
||||
"end": {"time": {"timestamp": "2026-03-05T18:00:00Z"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
// --- Go Benchmarks (run with: go test -bench=. -benchmem) ---
|
||||
|
||||
// BenchmarkEvaluate_MostlyInactive benchmarks evaluation with N rules where
|
||||
// only 1 rule fires. This simulates a realistic governance ruleset where
|
||||
// most rules are for different actions/conditions.
|
||||
func BenchmarkEvaluate_MostlyInactive(b *testing.B) {
|
||||
sizes := []int{1, 10, 50, 100, 250, 500}
|
||||
for _, n := range sizes {
|
||||
b.Run(fmt.Sprintf("rules=%d", n), func(b *testing.B) {
|
||||
dir := b.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "policy.rego"), []byte(generateDummyRules(n)), 0644)
|
||||
|
||||
eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false)
|
||||
if err != nil {
|
||||
b.Fatalf("NewEvaluator failed: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
violations, err := eval.Evaluate(ctx, sampleBecknInput)
|
||||
if err != nil {
|
||||
b.Fatalf("correctness check failed: %v", err)
|
||||
}
|
||||
if len(violations) != 1 || violations[0] != "real_violation" {
|
||||
b.Fatalf("expected [real_violation], got %v", violations)
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := eval.Evaluate(ctx, sampleBecknInput)
|
||||
if err != nil {
|
||||
b.Fatalf("Evaluate failed: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkEvaluate_AllActive benchmarks the worst case where ALL N rules fire.
|
||||
func BenchmarkEvaluate_AllActive(b *testing.B) {
|
||||
sizes := []int{1, 10, 50, 100, 250, 500}
|
||||
for _, n := range sizes {
|
||||
b.Run(fmt.Sprintf("rules=%d", n), func(b *testing.B) {
|
||||
dir := b.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "policy.rego"), []byte(generateActiveRules(n)), 0644)
|
||||
|
||||
eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false)
|
||||
if err != nil {
|
||||
b.Fatalf("NewEvaluator failed: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
violations, err := eval.Evaluate(ctx, sampleBecknInput)
|
||||
if err != nil {
|
||||
b.Fatalf("correctness check failed: %v", err)
|
||||
}
|
||||
if len(violations) != n {
|
||||
b.Fatalf("expected %d violations, got %d", n, len(violations))
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := eval.Evaluate(ctx, sampleBecknInput)
|
||||
if err != nil {
|
||||
b.Fatalf("Evaluate failed: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkCompilation measures how long it takes to compile policies of various sizes.
|
||||
// This runs once at startup, so it's less critical but good to know.
|
||||
func BenchmarkCompilation(b *testing.B) {
|
||||
sizes := []int{10, 50, 100, 250, 500}
|
||||
for _, n := range sizes {
|
||||
b.Run(fmt.Sprintf("rules=%d", n), func(b *testing.B) {
|
||||
dir := b.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "policy.rego"), []byte(generateDummyRules(n)), 0644)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false)
|
||||
if err != nil {
|
||||
b.Fatalf("NewEvaluator failed: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Human-Readable Report (run with: go test -run TestBenchmarkReport -v) ---
|
||||
|
||||
// TestBenchmarkReport generates a readable table showing how evaluation time
|
||||
// scales with rule count. This is the report to share with the team.
|
||||
func TestBenchmarkReport(t *testing.T) {
|
||||
sizes := []int{1, 10, 50, 100, 250, 500}
|
||||
iterations := 1000
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("╔══════════════════════════════════════════════════════════════════════╗")
|
||||
fmt.Println("║ Policy Enforcer — Performance Benchmark Report ║")
|
||||
fmt.Println("╠══════════════════════════════════════════════════════════════════════╣")
|
||||
fmt.Println()
|
||||
|
||||
// --- Compilation time ---
|
||||
fmt.Println("┌─────────────────────────────────────────────────┐")
|
||||
fmt.Println("│ Compilation Time (one-time startup cost) │")
|
||||
fmt.Println("├──────────┬──────────────────────────────────────┤")
|
||||
fmt.Println("│ Rules │ Compilation Time │")
|
||||
fmt.Println("├──────────┼──────────────────────────────────────┤")
|
||||
for _, n := range sizes {
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "policy.rego"), []byte(generateDummyRules(n)), 0644)
|
||||
|
||||
start := time.Now()
|
||||
_, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false)
|
||||
elapsed := time.Since(start)
|
||||
if err != nil {
|
||||
t.Fatalf("NewEvaluator(%d rules) failed: %v", n, err)
|
||||
}
|
||||
fmt.Printf("│ %-8d │ %-36s │\n", n, elapsed.Round(time.Microsecond))
|
||||
}
|
||||
fmt.Println("└──────────┴──────────────────────────────────────┘")
|
||||
fmt.Println()
|
||||
|
||||
// --- Evaluation time (mostly inactive rules) ---
|
||||
fmt.Println("┌─────────────────────────────────────────────────────────────────┐")
|
||||
fmt.Printf("│ Evaluation Time — Mostly Inactive Rules (%d iterations) │\n", iterations)
|
||||
fmt.Println("│ (1 rule fires, rest have non-matching conditions) │")
|
||||
fmt.Println("├──────────┬──────────────┬──────────────┬────────────────────────┤")
|
||||
fmt.Println("│ Rules │ Avg/eval │ p99 │ Violations │")
|
||||
fmt.Println("├──────────┼──────────────┼──────────────┼────────────────────────┤")
|
||||
for _, n := range sizes {
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "policy.rego"), []byte(generateDummyRules(n)), 0644)
|
||||
|
||||
eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("NewEvaluator(%d rules) failed: %v", n, err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
durations := make([]time.Duration, iterations)
|
||||
var lastViolations []string
|
||||
|
||||
for i := 0; i < iterations; i++ {
|
||||
start := time.Now()
|
||||
v, err := eval.Evaluate(ctx, sampleBecknInput)
|
||||
durations[i] = time.Since(start)
|
||||
if err != nil {
|
||||
t.Fatalf("Evaluate failed: %v", err)
|
||||
}
|
||||
lastViolations = v
|
||||
}
|
||||
|
||||
avg, p99 := calcStats(durations)
|
||||
fmt.Printf("│ %-8d │ %-12s │ %-12s │ %-22d │\n", n, avg.Round(time.Microsecond), p99.Round(time.Microsecond), len(lastViolations))
|
||||
}
|
||||
fmt.Println("└──────────┴──────────────┴──────────────┴────────────────────────┘")
|
||||
fmt.Println()
|
||||
|
||||
// --- Evaluation time (all rules active) ---
|
||||
fmt.Println("┌─────────────────────────────────────────────────────────────────┐")
|
||||
fmt.Printf("│ Evaluation Time — All Rules Active (%d iterations) │\n", iterations)
|
||||
fmt.Println("│ (every rule fires — worst case scenario) │")
|
||||
fmt.Println("├──────────┬──────────────┬──────────────┬────────────────────────┤")
|
||||
fmt.Println("│ Rules │ Avg/eval │ p99 │ Violations │")
|
||||
fmt.Println("├──────────┼──────────────┼──────────────┼────────────────────────┤")
|
||||
for _, n := range sizes {
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "policy.rego"), []byte(generateActiveRules(n)), 0644)
|
||||
|
||||
eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("NewEvaluator(%d rules) failed: %v", n, err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
durations := make([]time.Duration, iterations)
|
||||
var lastViolations []string
|
||||
|
||||
for i := 0; i < iterations; i++ {
|
||||
start := time.Now()
|
||||
v, err := eval.Evaluate(ctx, sampleBecknInput)
|
||||
durations[i] = time.Since(start)
|
||||
if err != nil {
|
||||
t.Fatalf("Evaluate failed: %v", err)
|
||||
}
|
||||
lastViolations = v
|
||||
}
|
||||
|
||||
avg, p99 := calcStats(durations)
|
||||
fmt.Printf("│ %-8d │ %-12s │ %-12s │ %-22d │\n", n, avg.Round(time.Microsecond), p99.Round(time.Microsecond), len(lastViolations))
|
||||
}
|
||||
fmt.Println("└──────────┴──────────────┴──────────────┴────────────────────────┘")
|
||||
fmt.Println()
|
||||
fmt.Println("╚══════════════════════════════════════════════════════════════════════╝")
|
||||
}
|
||||
|
||||
// calcStats returns average and p99 durations from a sorted slice.
|
||||
func calcStats(durations []time.Duration) (avg, p99 time.Duration) {
|
||||
n := len(durations)
|
||||
if n == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
var total time.Duration
|
||||
for _, d := range durations {
|
||||
total += d
|
||||
}
|
||||
avg = total / time.Duration(n)
|
||||
|
||||
// Sort for p99
|
||||
sorted := make([]time.Duration, n)
|
||||
copy(sorted, durations)
|
||||
sortDurations(sorted)
|
||||
p99 = sorted[int(float64(n)*0.99)]
|
||||
|
||||
return avg, p99
|
||||
}
|
||||
|
||||
// sortDurations sorts a slice of durations in ascending order (insertion sort, fine for 1000 items).
|
||||
func sortDurations(d []time.Duration) {
|
||||
for i := 1; i < len(d); i++ {
|
||||
key := d[i]
|
||||
j := i - 1
|
||||
for j >= 0 && d[j] > key {
|
||||
d[j+1] = d[j]
|
||||
j--
|
||||
}
|
||||
d[j+1] = key
|
||||
}
|
||||
}
|
||||
26
pkg/plugin/implementation/opapolicychecker/cmd/plugin.go
Normal file
26
pkg/plugin/implementation/opapolicychecker/cmd/plugin.go
Normal file
@@ -0,0 +1,26 @@
|
||||
// Package main provides the plugin entry point for the OPA Policy Checker plugin.
|
||||
// This file is compiled as a Go plugin (.so) and loaded by beckn-onix at runtime.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/beckn-one/beckn-onix/pkg/plugin/definition"
|
||||
"github.com/beckn-one/beckn-onix/pkg/plugin/implementation/opapolicychecker"
|
||||
)
|
||||
|
||||
// provider implements the PolicyCheckerProvider interface for plugin loading.
|
||||
type provider struct{}
|
||||
|
||||
// New creates a new PolicyChecker instance.
|
||||
func (p provider) New(ctx context.Context, cfg map[string]string) (definition.PolicyChecker, func(), error) {
|
||||
checker, err := opapolicychecker.New(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return checker, checker.Close, nil
|
||||
}
|
||||
|
||||
// Provider is the exported symbol that beckn-onix plugin manager looks up.
|
||||
var Provider = provider{}
|
||||
281
pkg/plugin/implementation/opapolicychecker/enforcer.go
Normal file
281
pkg/plugin/implementation/opapolicychecker/enforcer.go
Normal file
@@ -0,0 +1,281 @@
|
||||
package opapolicychecker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/beckn-one/beckn-onix/pkg/log"
|
||||
"github.com/beckn-one/beckn-onix/pkg/model"
|
||||
)
|
||||
|
||||
// Config holds the configuration for the OPA Policy Checker plugin.
|
||||
type Config struct {
|
||||
Type string
|
||||
Location string
|
||||
PolicyPaths []string
|
||||
Query string
|
||||
Actions []string
|
||||
Enabled bool
|
||||
DebugLogging bool
|
||||
IsBundle bool
|
||||
RefreshInterval time.Duration // 0 = disabled
|
||||
RuntimeConfig map[string]string
|
||||
}
|
||||
|
||||
var knownKeys = map[string]bool{
|
||||
"type": true,
|
||||
"location": true,
|
||||
"query": true,
|
||||
"actions": true,
|
||||
"enabled": true,
|
||||
"debugLogging": true,
|
||||
"refreshIntervalSeconds": true,
|
||||
}
|
||||
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
Enabled: true,
|
||||
RuntimeConfig: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// ParseConfig parses the plugin configuration map into a Config struct.
|
||||
// Uses type + location pattern (matches schemav2validator).
|
||||
func ParseConfig(cfg map[string]string) (*Config, error) {
|
||||
config := DefaultConfig()
|
||||
|
||||
typ, hasType := cfg["type"]
|
||||
if !hasType || typ == "" {
|
||||
return nil, fmt.Errorf("'type' is required (url, file, dir, or bundle)")
|
||||
}
|
||||
config.Type = typ
|
||||
|
||||
location, hasLoc := cfg["location"]
|
||||
if !hasLoc || location == "" {
|
||||
return nil, fmt.Errorf("'location' is required")
|
||||
}
|
||||
config.Location = location
|
||||
|
||||
switch typ {
|
||||
case "url":
|
||||
for _, u := range strings.Split(location, ",") {
|
||||
u = strings.TrimSpace(u)
|
||||
if u != "" {
|
||||
config.PolicyPaths = append(config.PolicyPaths, u)
|
||||
}
|
||||
}
|
||||
case "file":
|
||||
config.PolicyPaths = append(config.PolicyPaths, location)
|
||||
case "dir":
|
||||
config.PolicyPaths = append(config.PolicyPaths, location)
|
||||
case "bundle":
|
||||
config.IsBundle = true
|
||||
config.PolicyPaths = append(config.PolicyPaths, location)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported type %q (expected: url, file, dir, or bundle)", typ)
|
||||
}
|
||||
|
||||
query, hasQuery := cfg["query"]
|
||||
if !hasQuery || query == "" {
|
||||
return nil, fmt.Errorf("'query' is required (e.g., data.policy.violations)")
|
||||
}
|
||||
config.Query = query
|
||||
|
||||
if actions, ok := cfg["actions"]; ok && actions != "" {
|
||||
actionList := strings.Split(actions, ",")
|
||||
config.Actions = make([]string, 0, len(actionList))
|
||||
for _, action := range actionList {
|
||||
action = strings.TrimSpace(action)
|
||||
if action != "" {
|
||||
config.Actions = append(config.Actions, action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if enabled, ok := cfg["enabled"]; ok {
|
||||
config.Enabled = enabled == "true" || enabled == "1"
|
||||
}
|
||||
|
||||
if debug, ok := cfg["debugLogging"]; ok {
|
||||
config.DebugLogging = debug == "true" || debug == "1"
|
||||
}
|
||||
|
||||
if ris, ok := cfg["refreshIntervalSeconds"]; ok && ris != "" {
|
||||
secs, err := strconv.Atoi(ris)
|
||||
if err != nil || secs < 0 {
|
||||
return nil, fmt.Errorf("'refreshIntervalSeconds' must be a non-negative integer, got %q", ris)
|
||||
}
|
||||
config.RefreshInterval = time.Duration(secs) * time.Second
|
||||
}
|
||||
|
||||
for k, v := range cfg {
|
||||
if !knownKeys[k] {
|
||||
config.RuntimeConfig[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (c *Config) IsActionEnabled(action string) bool {
|
||||
if len(c.Actions) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, a := range c.Actions {
|
||||
if a == action {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PolicyEnforcer evaluates beckn messages against OPA policies and NACKs non-compliant messages.
|
||||
type PolicyEnforcer struct {
|
||||
config *Config
|
||||
evaluator *Evaluator
|
||||
evaluatorMu sync.RWMutex
|
||||
}
|
||||
|
||||
// getEvaluator safely returns the current evaluator under a read lock.
|
||||
func (e *PolicyEnforcer) getEvaluator() *Evaluator {
|
||||
e.evaluatorMu.RLock()
|
||||
ev := e.evaluator
|
||||
e.evaluatorMu.RUnlock()
|
||||
return ev
|
||||
}
|
||||
|
||||
// setEvaluator safely swaps the evaluator under a write lock.
|
||||
func (e *PolicyEnforcer) setEvaluator(ev *Evaluator) {
|
||||
e.evaluatorMu.Lock()
|
||||
e.evaluator = ev
|
||||
e.evaluatorMu.Unlock()
|
||||
}
|
||||
|
||||
func New(ctx context.Context, cfg map[string]string) (*PolicyEnforcer, error) {
|
||||
config, err := ParseConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opapolicychecker: config error: %w", err)
|
||||
}
|
||||
|
||||
evaluator, err := NewEvaluator(config.PolicyPaths, config.Query, config.RuntimeConfig, config.IsBundle)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opapolicychecker: failed to initialize OPA evaluator: %w", err)
|
||||
}
|
||||
|
||||
log.Infof(ctx, "OPAPolicyChecker initialized (actions=%v, query=%s, policies=%v, isBundle=%v, debugLogging=%v, refreshInterval=%s)",
|
||||
config.Actions, config.Query, evaluator.ModuleNames(), config.IsBundle, config.DebugLogging, config.RefreshInterval)
|
||||
|
||||
enforcer := &PolicyEnforcer{
|
||||
config: config,
|
||||
evaluator: evaluator,
|
||||
}
|
||||
|
||||
if config.RefreshInterval > 0 {
|
||||
go enforcer.refreshLoop(ctx)
|
||||
}
|
||||
|
||||
return enforcer, nil
|
||||
}
|
||||
|
||||
// refreshLoop periodically reloads and recompiles OPA policies.
|
||||
// Follows the schemav2validator pattern: driven by context cancellation.
|
||||
func (e *PolicyEnforcer) refreshLoop(ctx context.Context) {
|
||||
ticker := time.NewTicker(e.config.RefreshInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Infof(ctx, "OPAPolicyChecker: refresh loop stopped")
|
||||
return
|
||||
case <-ticker.C:
|
||||
e.reloadPolicies(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reloadPolicies reloads and recompiles all policies, atomically swapping the evaluator.
|
||||
// Reload failures are non-fatal; the old evaluator stays active.
|
||||
func (e *PolicyEnforcer) reloadPolicies(ctx context.Context) {
|
||||
start := time.Now()
|
||||
newEvaluator, err := NewEvaluator(
|
||||
e.config.PolicyPaths,
|
||||
e.config.Query,
|
||||
e.config.RuntimeConfig,
|
||||
e.config.IsBundle,
|
||||
)
|
||||
if err != nil {
|
||||
log.Errorf(ctx, err, "OPAPolicyChecker: policy reload failed (keeping previous policies): %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
e.setEvaluator(newEvaluator)
|
||||
log.Infof(ctx, "OPAPolicyChecker: policies reloaded in %s (modules=%v)", time.Since(start), newEvaluator.ModuleNames())
|
||||
}
|
||||
|
||||
// CheckPolicy evaluates the message body against loaded OPA policies.
|
||||
// Returns a BadReqErr (causing NACK) if violations are found.
|
||||
// Returns an error on evaluation failure (fail closed).
|
||||
func (e *PolicyEnforcer) CheckPolicy(ctx *model.StepContext) error {
|
||||
if !e.config.Enabled {
|
||||
log.Debug(ctx, "OPAPolicyChecker: plugin disabled, skipping")
|
||||
return nil
|
||||
}
|
||||
|
||||
action := extractAction(ctx.Request.URL.Path, ctx.Body)
|
||||
|
||||
if !e.config.IsActionEnabled(action) {
|
||||
if e.config.DebugLogging {
|
||||
log.Debugf(ctx, "OPAPolicyChecker: action %q not in configured actions %v, skipping", action, e.config.Actions)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
ev := e.getEvaluator()
|
||||
|
||||
if e.config.DebugLogging {
|
||||
log.Debugf(ctx, "OPAPolicyChecker: evaluating policies for action %q (modules=%v)", action, ev.ModuleNames())
|
||||
}
|
||||
|
||||
violations, err := ev.Evaluate(ctx, ctx.Body)
|
||||
if err != nil {
|
||||
log.Errorf(ctx, err, "OPAPolicyChecker: policy evaluation failed: %v", err)
|
||||
return model.NewBadReqErr(fmt.Errorf("policy evaluation error: %w", err))
|
||||
}
|
||||
|
||||
if len(violations) == 0 {
|
||||
if e.config.DebugLogging {
|
||||
log.Debugf(ctx, "OPAPolicyChecker: message compliant for action %q", action)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("policy violation(s): %s", strings.Join(violations, "; "))
|
||||
log.Warnf(ctx, "OPAPolicyChecker: %s", msg)
|
||||
return model.NewBadReqErr(fmt.Errorf("%s", msg))
|
||||
}
|
||||
|
||||
func (e *PolicyEnforcer) Close() {}
|
||||
|
||||
func extractAction(urlPath string, body []byte) string {
|
||||
parts := strings.Split(strings.Trim(urlPath, "/"), "/")
|
||||
if len(parts) >= 3 {
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Context struct {
|
||||
Action string `json:"action"`
|
||||
} `json:"context"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err == nil && payload.Context.Action != "" {
|
||||
return payload.Context.Action
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
1098
pkg/plugin/implementation/opapolicychecker/enforcer_test.go
Normal file
1098
pkg/plugin/implementation/opapolicychecker/enforcer_test.go
Normal file
File diff suppressed because it is too large
Load Diff
395
pkg/plugin/implementation/opapolicychecker/evaluator.go
Normal file
395
pkg/plugin/implementation/opapolicychecker/evaluator.go
Normal file
@@ -0,0 +1,395 @@
|
||||
package opapolicychecker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/open-policy-agent/opa/v1/ast"
|
||||
"github.com/open-policy-agent/opa/v1/bundle"
|
||||
"github.com/open-policy-agent/opa/v1/rego"
|
||||
"github.com/open-policy-agent/opa/v1/storage/inmem"
|
||||
)
|
||||
|
||||
// Evaluator wraps the OPA engine: loads and compiles .rego files at startup,
|
||||
// then evaluates messages against the compiled policy set.
|
||||
type Evaluator struct {
|
||||
preparedQuery rego.PreparedEvalQuery
|
||||
query string
|
||||
runtimeConfig map[string]string
|
||||
moduleNames []string // names of loaded .rego modules
|
||||
failOnUndefined bool // if true, empty/undefined results are treated as violations
|
||||
}
|
||||
|
||||
// ModuleNames returns the names of the loaded .rego policy modules.
|
||||
func (e *Evaluator) ModuleNames() []string {
|
||||
return e.moduleNames
|
||||
}
|
||||
|
||||
// policyFetchTimeout is the HTTP timeout for fetching remote .rego files.
|
||||
const policyFetchTimeout = 30 * time.Second
|
||||
|
||||
// maxPolicySize is the maximum size of a single .rego file fetched from a URL (1 MB).
|
||||
const maxPolicySize = 1 << 20
|
||||
|
||||
// maxBundleSize is the maximum size of a bundle archive (10 MB).
|
||||
const maxBundleSize = 10 << 20
|
||||
|
||||
// NewEvaluator creates an Evaluator by loading .rego files from local paths
|
||||
// and/or URLs, then compiling them. runtimeConfig is passed to Rego as data.config.
|
||||
// When isBundle is true, the first policyPath is treated as a URL to an OPA bundle (.tar.gz).
|
||||
func NewEvaluator(policyPaths []string, query string, runtimeConfig map[string]string, isBundle bool) (*Evaluator, error) {
|
||||
if isBundle {
|
||||
return newBundleEvaluator(policyPaths, query, runtimeConfig)
|
||||
}
|
||||
return newRegoEvaluator(policyPaths, query, runtimeConfig)
|
||||
}
|
||||
|
||||
// newRegoEvaluator loads raw .rego files from local paths and/or URLs.
|
||||
func newRegoEvaluator(policyPaths []string, query string, runtimeConfig map[string]string) (*Evaluator, error) {
|
||||
modules := make(map[string]string)
|
||||
|
||||
// Load from policyPaths (resolved locations based on config Type)
|
||||
for _, source := range policyPaths {
|
||||
if isURL(source) {
|
||||
name, content, err := fetchPolicy(source)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch policy from %s: %w", source, err)
|
||||
}
|
||||
modules[name] = content
|
||||
} else if info, err := os.Stat(source); err == nil && info.IsDir() {
|
||||
// Directory — load all .rego files inside
|
||||
entries, err := os.ReadDir(source)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read policy directory %s: %w", source, err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".rego") || strings.HasSuffix(entry.Name(), "_test.rego") {
|
||||
continue
|
||||
}
|
||||
fpath := filepath.Join(source, entry.Name())
|
||||
data, err := os.ReadFile(fpath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read policy file %s: %w", fpath, err)
|
||||
}
|
||||
modules[entry.Name()] = string(data)
|
||||
}
|
||||
} else {
|
||||
// Local file path
|
||||
data, err := os.ReadFile(source)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read policy file %s: %w", source, err)
|
||||
}
|
||||
modules[filepath.Base(source)] = string(data)
|
||||
}
|
||||
}
|
||||
|
||||
if len(modules) == 0 {
|
||||
return nil, fmt.Errorf("no .rego policy files found from any configured source")
|
||||
}
|
||||
|
||||
return compileAndPrepare(modules, nil, query, runtimeConfig, true)
|
||||
}
|
||||
|
||||
// newBundleEvaluator loads an OPA bundle (.tar.gz) from a URL and compiles it.
|
||||
func newBundleEvaluator(policyPaths []string, query string, runtimeConfig map[string]string) (*Evaluator, error) {
|
||||
if len(policyPaths) == 0 {
|
||||
return nil, fmt.Errorf("bundle source URL is required")
|
||||
}
|
||||
|
||||
bundleURL := policyPaths[0]
|
||||
modules, bundleData, err := loadBundle(bundleURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load bundle from %s: %w", bundleURL, err)
|
||||
}
|
||||
|
||||
if len(modules) == 0 {
|
||||
return nil, fmt.Errorf("no .rego policy modules found in bundle from %s", bundleURL)
|
||||
}
|
||||
|
||||
return compileAndPrepare(modules, bundleData, query, runtimeConfig, true)
|
||||
}
|
||||
|
||||
// loadBundle downloads a .tar.gz OPA bundle from a URL, parses it using OPA's
|
||||
// bundle reader, and returns the modules and data from the bundle.
|
||||
func loadBundle(bundleURL string) (map[string]string, map[string]interface{}, error) {
|
||||
data, err := fetchBundleArchive(bundleURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return parseBundleArchive(data)
|
||||
}
|
||||
|
||||
// fetchBundleArchive downloads a bundle .tar.gz from a URL.
|
||||
func fetchBundleArchive(rawURL string) ([]byte, error) {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return nil, fmt.Errorf("unsupported URL scheme %q (only http and https are supported)", parsed.Scheme)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: policyFetchTimeout}
|
||||
resp, err := client.Get(rawURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("HTTP request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, rawURL)
|
||||
}
|
||||
|
||||
limited := io.LimitReader(resp.Body, int64(maxBundleSize)+1)
|
||||
body, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
if len(body) > maxBundleSize {
|
||||
return nil, fmt.Errorf("bundle exceeds maximum size of %d bytes", maxBundleSize)
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// parseBundleArchive parses a .tar.gz OPA bundle archive and extracts
|
||||
// rego modules and data. Signature verification is skipped.
|
||||
func parseBundleArchive(data []byte) (map[string]string, map[string]interface{}, error) {
|
||||
loader := bundle.NewTarballLoaderWithBaseURL(bytes.NewReader(data), "")
|
||||
reader := bundle.NewCustomReader(loader).
|
||||
WithSkipBundleVerification(true).
|
||||
WithRegoVersion(ast.RegoV1)
|
||||
|
||||
b, err := reader.Read()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to read bundle: %w", err)
|
||||
}
|
||||
|
||||
modules := make(map[string]string, len(b.Modules))
|
||||
for _, m := range b.Modules {
|
||||
modules[m.Path] = string(m.Raw)
|
||||
}
|
||||
|
||||
return modules, b.Data, nil
|
||||
}
|
||||
|
||||
// compileAndPrepare compiles rego modules and prepares the OPA query for evaluation.
|
||||
func compileAndPrepare(modules map[string]string, bundleData map[string]interface{}, query string, runtimeConfig map[string]string, failOnUndefined bool) (*Evaluator, error) {
|
||||
// Compile modules to catch syntax errors early
|
||||
compiler, err := ast.CompileModulesWithOpt(modules, ast.CompileOpts{ParserOptions: ast.ParserOptions{RegoVersion: ast.RegoV1}})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compile rego modules: %w", err)
|
||||
}
|
||||
|
||||
// Build store data: merge bundle data with runtime config
|
||||
store := make(map[string]interface{})
|
||||
for k, v := range bundleData {
|
||||
store[k] = v
|
||||
}
|
||||
store["config"] = toInterfaceMap(runtimeConfig)
|
||||
|
||||
pq, err := rego.New(
|
||||
rego.Query(query),
|
||||
rego.Compiler(compiler),
|
||||
rego.Store(inmem.NewFromObject(store)),
|
||||
).PrepareForEval(context.Background())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to prepare rego query %q: %w", query, err)
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(modules))
|
||||
for name := range modules {
|
||||
names = append(names, name)
|
||||
}
|
||||
|
||||
return &Evaluator{
|
||||
preparedQuery: pq,
|
||||
query: query,
|
||||
runtimeConfig: runtimeConfig,
|
||||
moduleNames: names,
|
||||
failOnUndefined: failOnUndefined,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// isURL checks if a source string looks like a remote URL.
|
||||
func isURL(source string) bool {
|
||||
return strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://")
|
||||
}
|
||||
|
||||
// fetchPolicy downloads a .rego file from a URL and returns (filename, content, error).
|
||||
func fetchPolicy(rawURL string) (string, string, error) {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return "", "", fmt.Errorf("unsupported URL scheme %q (only http and https are supported)", parsed.Scheme)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: policyFetchTimeout}
|
||||
resp, err := client.Get(rawURL)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("HTTP request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", "", fmt.Errorf("HTTP %d from %s", resp.StatusCode, rawURL)
|
||||
}
|
||||
|
||||
// Read with size limit
|
||||
limited := io.LimitReader(resp.Body, maxPolicySize+1)
|
||||
body, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
if len(body) > maxPolicySize {
|
||||
return "", "", fmt.Errorf("policy file exceeds maximum size of %d bytes", maxPolicySize)
|
||||
}
|
||||
|
||||
// Derive filename from URL path
|
||||
name := path.Base(parsed.Path)
|
||||
if name == "" || name == "." || name == "/" {
|
||||
name = "policy.rego"
|
||||
}
|
||||
if !strings.HasSuffix(name, ".rego") {
|
||||
name += ".rego"
|
||||
}
|
||||
|
||||
return name, string(body), nil
|
||||
}
|
||||
|
||||
// Evaluate runs the compiled policy against a JSON message body.
|
||||
// Returns a list of violation strings (empty = compliant).
|
||||
func (e *Evaluator) Evaluate(ctx context.Context, body []byte) ([]string, error) {
|
||||
var input interface{}
|
||||
if err := json.Unmarshal(body, &input); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse message body as JSON: %w", err)
|
||||
}
|
||||
|
||||
rs, err := e.preparedQuery.Eval(ctx, rego.EvalInput(input))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rego evaluation failed: %w", err)
|
||||
}
|
||||
|
||||
// Fail-closed for bundles: if the query returned no result, the policy_query_path
|
||||
// is likely misconfigured or the rule doesn't exist in the bundle.
|
||||
if e.failOnUndefined && len(rs) == 0 {
|
||||
return []string{fmt.Sprintf("policy query %q returned no result (undefined)", e.query)}, nil
|
||||
}
|
||||
|
||||
return extractViolations(rs)
|
||||
}
|
||||
|
||||
// extractViolations pulls violations from the OPA result set.
|
||||
// Supported query output formats:
|
||||
// - map with {"valid": bool, "violations": []string}: structured policy_query_path result
|
||||
// - []string / set of strings: each string is a violation message
|
||||
// - bool: false = denied ("policy denied the request"), true = allowed
|
||||
// - string: non-empty = violation message
|
||||
// - empty/undefined: allowed (no violations)
|
||||
func extractViolations(rs rego.ResultSet) ([]string, error) {
|
||||
if len(rs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var violations []string
|
||||
for _, result := range rs {
|
||||
for _, expr := range result.Expressions {
|
||||
switch v := expr.Value.(type) {
|
||||
case bool:
|
||||
// allow/deny pattern: false = denied
|
||||
if !v {
|
||||
violations = append(violations, "policy denied the request")
|
||||
}
|
||||
case string:
|
||||
// single violation string
|
||||
if v != "" {
|
||||
violations = append(violations, v)
|
||||
}
|
||||
case []interface{}:
|
||||
// Result is a list (from set)
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok {
|
||||
violations = append(violations, s)
|
||||
}
|
||||
}
|
||||
case map[string]interface{}:
|
||||
// Check for structured result: {"valid": bool, "violations": [...]}
|
||||
if vs := extractStructuredViolations(v); vs != nil {
|
||||
violations = append(violations, vs...)
|
||||
} else {
|
||||
// Fallback: OPA sometimes returns sets as maps with string keys
|
||||
for key := range v {
|
||||
violations = append(violations, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return violations, nil
|
||||
}
|
||||
|
||||
// extractStructuredViolations handles the policy_query_path result format:
|
||||
// {"valid": bool, "violations": []string}
|
||||
// Returns the violation strings if the map matches this format, or nil if it doesn't.
|
||||
func extractStructuredViolations(m map[string]interface{}) []string {
|
||||
validRaw, hasValid := m["valid"]
|
||||
violationsRaw, hasViolations := m["violations"]
|
||||
|
||||
if !hasValid || !hasViolations {
|
||||
return nil
|
||||
}
|
||||
|
||||
valid, ok := validRaw.(bool)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
violationsList, ok := violationsRaw.([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If valid is true and violations is empty, no violations
|
||||
if valid && len(violationsList) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
var violations []string
|
||||
for _, item := range violationsList {
|
||||
if s, ok := item.(string); ok {
|
||||
violations = append(violations, s)
|
||||
}
|
||||
}
|
||||
|
||||
// If valid is false but violations is empty, report a generic violation
|
||||
if !valid && len(violations) == 0 {
|
||||
violations = append(violations, "policy denied the request")
|
||||
}
|
||||
|
||||
return violations
|
||||
}
|
||||
|
||||
// toInterfaceMap converts map[string]string to map[string]interface{} for OPA store.
|
||||
func toInterfaceMap(m map[string]string) map[string]interface{} {
|
||||
result := make(map[string]interface{}, len(m))
|
||||
for k, v := range m {
|
||||
result[k] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
52
pkg/plugin/implementation/opapolicychecker/testdata/example.rego
vendored
Normal file
52
pkg/plugin/implementation/opapolicychecker/testdata/example.rego
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
package policy
|
||||
|
||||
import rego.v1
|
||||
|
||||
# Example policy: validation rules for beckn messages.
|
||||
# This demonstrates the structured result format used with policy_query_path.
|
||||
#
|
||||
# Available inputs:
|
||||
# - input: the full JSON message body (beckn request)
|
||||
# - data.config: runtime config from the adapter YAML (e.g., minDeliveryLeadHours)
|
||||
|
||||
# Default result: valid with no violations.
|
||||
default result := {
|
||||
"valid": true,
|
||||
"violations": []
|
||||
}
|
||||
|
||||
# Compute the result from collected violations.
|
||||
result := {
|
||||
"valid": count(violations) == 0,
|
||||
"violations": violations
|
||||
}
|
||||
|
||||
# Require provider details on confirm
|
||||
violations contains "confirm: missing provider in order" if {
|
||||
input.context.action == "confirm"
|
||||
not input.message.order.provider
|
||||
}
|
||||
|
||||
# Require at least one fulfillment on confirm
|
||||
violations contains "confirm: order has no fulfillments" if {
|
||||
input.context.action == "confirm"
|
||||
not input.message.order.fulfillments
|
||||
}
|
||||
|
||||
# Require billing details on confirm
|
||||
violations contains "confirm: missing billing info" if {
|
||||
input.context.action == "confirm"
|
||||
not input.message.order.billing
|
||||
}
|
||||
|
||||
# Require payment details on confirm
|
||||
violations contains "confirm: missing payment info" if {
|
||||
input.context.action == "confirm"
|
||||
not input.message.order.payment
|
||||
}
|
||||
|
||||
# Require search intent
|
||||
violations contains "search: missing intent" if {
|
||||
input.context.action == "search"
|
||||
not input.message.intent
|
||||
}
|
||||
Reference in New Issue
Block a user