316 lines
8.5 KiB
Go
316 lines
8.5 KiB
Go
package policy
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"context"
|
||
|
|
"errors"
|
||
|
|
"log/slog"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/open-policy-agent/opa/v1/rego"
|
||
|
|
)
|
||
|
|
|
||
|
|
func TestLockedCapabilitiesRejectHTTP(t *testing.T) {
|
||
|
|
source := `package silo_custom.scope
|
||
|
|
|
||
|
|
import rego.v1
|
||
|
|
|
||
|
|
override(base, _) := base if {
|
||
|
|
http.send({"method": "get", "url": "https://example.test"})
|
||
|
|
}`
|
||
|
|
_, err := rego.New(
|
||
|
|
rego.Query("data.silo_custom.scope.override({}, {})"),
|
||
|
|
rego.Module("bad.rego", source),
|
||
|
|
rego.Capabilities(LockedCapabilities()),
|
||
|
|
).PrepareForEval(context.Background())
|
||
|
|
if err == nil {
|
||
|
|
t.Fatal("expected http.send compile failure")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestCompileCheckRejectsForbiddenBuiltins(t *testing.T) {
|
||
|
|
tests := []struct {
|
||
|
|
name string
|
||
|
|
source string
|
||
|
|
}{
|
||
|
|
{
|
||
|
|
name: "http_send",
|
||
|
|
source: `package silo_custom.scope
|
||
|
|
|
||
|
|
import rego.v1
|
||
|
|
|
||
|
|
override(base, _) := base if {
|
||
|
|
resp := http.send({"method": "get", "url": "https://example.test"})
|
||
|
|
resp.status_code >= 100
|
||
|
|
}`,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "net_lookup",
|
||
|
|
source: `package silo_custom.scope
|
||
|
|
|
||
|
|
import rego.v1
|
||
|
|
|
||
|
|
override(base, _) := base if {
|
||
|
|
ips := net.lookup_ip_addr("localhost")
|
||
|
|
count(ips) >= 0
|
||
|
|
}`,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "opa_runtime",
|
||
|
|
source: `package silo_custom.scope
|
||
|
|
|
||
|
|
import rego.v1
|
||
|
|
|
||
|
|
override(base, _) := base if {
|
||
|
|
runtime := opa.runtime()
|
||
|
|
object.get(runtime, "version", "") == ""
|
||
|
|
}`,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "time_now",
|
||
|
|
source: `package silo_custom.scope
|
||
|
|
|
||
|
|
import rego.v1
|
||
|
|
|
||
|
|
override(base, _) := base if {
|
||
|
|
time.now_ns() > 0
|
||
|
|
}`,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, test := range tests {
|
||
|
|
t.Run(test.name, func(t *testing.T) {
|
||
|
|
err := CompileCheck(context.Background(), "scope", test.source)
|
||
|
|
if !errors.Is(err, ErrCompileFailed) {
|
||
|
|
t.Fatalf("CompileCheck() error = %v, want ErrCompileFailed", err)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestCompileCheckRejectsWrongPackagePath(t *testing.T) {
|
||
|
|
err := CompileCheck(context.Background(), "scope", `package silo_custom.permission
|
||
|
|
|
||
|
|
import rego.v1
|
||
|
|
|
||
|
|
override(base, _) := base`)
|
||
|
|
if !errors.Is(err, ErrCompileFailed) {
|
||
|
|
t.Fatalf("CompileCheck() error = %v, want ErrCompileFailed", err)
|
||
|
|
}
|
||
|
|
if !strings.Contains(err.Error(), "policy package must be silo_custom.scope") {
|
||
|
|
t.Fatalf("CompileCheck() error = %v, want package mismatch", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestCompileCheckAllowsPureNetHelpers(t *testing.T) {
|
||
|
|
// net.cidr_contains is deterministic; only impure builtins are locked.
|
||
|
|
err := CompileCheck(context.Background(), "scope", `package silo_custom.scope
|
||
|
|
|
||
|
|
import rego.v1
|
||
|
|
|
||
|
|
override(base, i) := base if {
|
||
|
|
net.cidr_contains("10.0.0.0/8", object.get(i, "client_ip", "10.1.2.3"))
|
||
|
|
}`)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("CompileCheck() error = %v, want pure net helper allowed", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestCompileCheckRejectsOversizedSource(t *testing.T) {
|
||
|
|
source := "package silo_custom.scope\n\nimport rego.v1\n\n# " +
|
||
|
|
strings.Repeat("x", maxPolicySourceBytes)
|
||
|
|
err := CompileCheck(context.Background(), "scope", source)
|
||
|
|
if !errors.Is(err, ErrCompileFailed) {
|
||
|
|
t.Fatalf("CompileCheck() error = %v, want ErrCompileFailed", err)
|
||
|
|
}
|
||
|
|
if !strings.Contains(err.Error(), "byte limit") {
|
||
|
|
t.Fatalf("CompileCheck() error = %v, want size limit message", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestCompileCheckAcceptsValidScopeOverride(t *testing.T) {
|
||
|
|
if err := CompileCheck(context.Background(), "scope", tighteningScopeOverrideSource()); err != nil {
|
||
|
|
t.Fatalf("CompileCheck() error: %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestCustomStubAndScopeOverrideCoexist(t *testing.T) {
|
||
|
|
engine, err := NewEngineWithCustom(context.Background(), map[string]ActiveSource{
|
||
|
|
"scope": {DocumentID: 1, VersionID: 1, Source: tighteningScopeOverrideSource()},
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("NewEngineWithCustom() error: %v", err)
|
||
|
|
}
|
||
|
|
pdp := NewPDP(engine)
|
||
|
|
|
||
|
|
decision, _, err := pdp.ResolveViewerScope(context.Background(), ScopeInput{
|
||
|
|
SchemaVersion: 1,
|
||
|
|
UserID: 42,
|
||
|
|
SessionID: "sess-1",
|
||
|
|
AccountRestricted: false,
|
||
|
|
AccessPolicyRevision: 9,
|
||
|
|
ProfileVerified: true,
|
||
|
|
RequestTime: "2026-07-02T12:00:00Z",
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("ResolveViewerScope() error: %v", err)
|
||
|
|
}
|
||
|
|
if decision.Unrestricted {
|
||
|
|
t.Fatalf("Unrestricted = true, want tightened restricted decision: %#v", decision)
|
||
|
|
}
|
||
|
|
if got, want := decision.AllowedLibraryIDs, []int{2}; len(got) != 1 || got[0] != want[0] {
|
||
|
|
t.Fatalf("AllowedLibraryIDs = %#v, want %#v", got, want)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestNewEngineWithCustomSkipsInvalidSource(t *testing.T) {
|
||
|
|
var logs bytes.Buffer
|
||
|
|
logger := slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||
|
|
|
||
|
|
engine, err := NewEngineWithCustom(
|
||
|
|
context.Background(),
|
||
|
|
map[string]ActiveSource{
|
||
|
|
"scope": {
|
||
|
|
DocumentID: 123,
|
||
|
|
VersionID: 456,
|
||
|
|
Source: `package silo_custom.scope
|
||
|
|
|
||
|
|
import rego.v1
|
||
|
|
|
||
|
|
override(base, _) := base if {`,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
WithLogger(logger),
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("NewEngineWithCustom() error: %v", err)
|
||
|
|
}
|
||
|
|
pdp := NewPDP(engine)
|
||
|
|
decision, _, err := pdp.ResolveViewerScope(context.Background(), ScopeInput{
|
||
|
|
SchemaVersion: 1,
|
||
|
|
UserID: 42,
|
||
|
|
SessionID: "sess-1",
|
||
|
|
AccountRestricted: false,
|
||
|
|
AccessPolicyRevision: 9,
|
||
|
|
ProfileVerified: true,
|
||
|
|
RequestTime: "2026-07-02T12:00:00Z",
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("ResolveViewerScope() error: %v", err)
|
||
|
|
}
|
||
|
|
if !decision.Unrestricted {
|
||
|
|
t.Fatalf("Unrestricted = false, want vendor-only unrestricted decision: %#v", decision)
|
||
|
|
}
|
||
|
|
|
||
|
|
logOutput := logs.String()
|
||
|
|
if !strings.Contains(logOutput, "skipping invalid custom policy source") ||
|
||
|
|
!strings.Contains(logOutput, "domain=scope") ||
|
||
|
|
!strings.Contains(logOutput, "document_id=123") {
|
||
|
|
t.Fatalf("error log did not include skip fields: %s", logOutput)
|
||
|
|
}
|
||
|
|
|
||
|
|
skipped := engine.SkippedSources()
|
||
|
|
if len(skipped) != 1 || skipped[0].Domain != "scope" || skipped[0].DocumentID != 123 || skipped[0].Err == nil {
|
||
|
|
t.Fatalf("SkippedSources() = %#v, want the dropped scope source recorded", skipped)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestEngineReloadRejectsInvalidCustomSource(t *testing.T) {
|
||
|
|
ctx := context.Background()
|
||
|
|
engine, err := NewEngineWithCustom(ctx, map[string]ActiveSource{
|
||
|
|
"scope": {DocumentID: 1, VersionID: 1, Source: tighteningScopeOverrideSource()},
|
||
|
|
}, WithRevision(7))
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("NewEngineWithCustom() error: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
err = engine.Reload(ctx, map[string]ActiveSource{
|
||
|
|
"scope": {
|
||
|
|
DocumentID: 1,
|
||
|
|
VersionID: 2,
|
||
|
|
Source: `package silo_custom.scope
|
||
|
|
|
||
|
|
import rego.v1
|
||
|
|
|
||
|
|
override(base, _) := base if {`,
|
||
|
|
},
|
||
|
|
}, 8)
|
||
|
|
if err == nil {
|
||
|
|
t.Fatal("Reload() error = nil, want compile failure for the invalid custom source")
|
||
|
|
}
|
||
|
|
if !strings.Contains(err.Error(), `domain "scope"`) || !strings.Contains(err.Error(), "document 1") {
|
||
|
|
t.Fatalf("Reload() error = %v, want domain and document identified", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// The last known-good bundle must keep serving: revision unchanged and the
|
||
|
|
// previous tightening override still applied.
|
||
|
|
if got := engine.Revision(); got != 7 {
|
||
|
|
t.Fatalf("Revision() after failed reload = %d, want 7", got)
|
||
|
|
}
|
||
|
|
pdp := NewPDP(engine)
|
||
|
|
decision, _, err := pdp.ResolveViewerScope(ctx, ScopeInput{
|
||
|
|
SchemaVersion: 1,
|
||
|
|
UserID: 42,
|
||
|
|
SessionID: "sess-1",
|
||
|
|
AccountRestricted: false,
|
||
|
|
AccessPolicyRevision: 9,
|
||
|
|
ProfileVerified: true,
|
||
|
|
RequestTime: "2026-07-02T12:00:00Z",
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("ResolveViewerScope() error: %v", err)
|
||
|
|
}
|
||
|
|
if decision.Unrestricted {
|
||
|
|
t.Fatalf("Unrestricted = true after failed reload, want last known-good tightened decision: %#v", decision)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestEvaluateTimeoutFailsClosed(t *testing.T) {
|
||
|
|
engine, err := newEngineFromModules(context.Background(), time.Nanosecond, []ModuleSource{{
|
||
|
|
Path: "slow.rego",
|
||
|
|
Source: `package silo.slow
|
||
|
|
|
||
|
|
import rego.v1
|
||
|
|
|
||
|
|
decision := count([x |
|
||
|
|
some i in numbers.range(1, 10000)
|
||
|
|
some j in numbers.range(1, 10000)
|
||
|
|
x := i + j
|
||
|
|
])`,
|
||
|
|
}}, map[DecisionName]string{
|
||
|
|
"slow.decision": "data.silo.slow.decision",
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("compile slow policy: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
var out int
|
||
|
|
_, err = engine.Evaluate(context.Background(), "slow.decision", map[string]any{}, &out)
|
||
|
|
if !errors.Is(err, ErrPolicyEvalFailed) {
|
||
|
|
t.Fatalf("Evaluate() error = %v, want ErrPolicyEvalFailed", err)
|
||
|
|
}
|
||
|
|
// Timeouts stay fail-closed but carry the distinct sentinel and counter so
|
||
|
|
// a slow policy is attributable, not just a generic eval failure.
|
||
|
|
if !errors.Is(err, ErrPolicyEvalTimeout) {
|
||
|
|
t.Fatalf("Evaluate() error = %v, want ErrPolicyEvalTimeout to match", err)
|
||
|
|
}
|
||
|
|
if got := engine.EvalTimeouts(); got != 1 {
|
||
|
|
t.Fatalf("EvalTimeouts() = %d, want 1", got)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func tighteningScopeOverrideSource() string {
|
||
|
|
return `package silo_custom.scope
|
||
|
|
|
||
|
|
import rego.v1
|
||
|
|
|
||
|
|
override(_, _) := result if {
|
||
|
|
result := {
|
||
|
|
"unrestricted": false,
|
||
|
|
"allowed_library_ids": [2],
|
||
|
|
}
|
||
|
|
}`
|
||
|
|
}
|