diff --git a/envoyauth/response.go b/envoyauth/response.go index 10650ad7c..3eb5b75c5 100644 --- a/envoyauth/response.go +++ b/envoyauth/response.go @@ -8,10 +8,12 @@ import ( ext_core_v3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" ext_type_v3 "github.com/envoyproxy/go-control-plane/envoy/type/v3" + _structpb "github.com/golang/protobuf/ptypes/struct" "github.com/open-policy-agent/opa-envoy-plugin/internal/util" "github.com/open-policy-agent/opa/metrics" "github.com/open-policy-agent/opa/storage" "github.com/open-policy-agent/opa/topdown/builtins" + "google.golang.org/protobuf/types/known/structpb" ) // EvalResult - Captures the result from evaluating a query against an input @@ -280,6 +282,33 @@ func (result *EvalResult) GetResponseHTTPStatus() (int, error) { return http.StatusForbidden, result.invalidDecisionErr() } +// GetDynamicMetadata returns the dynamic metadata to return if part of the decision +func (result *EvalResult) GetDynamicMetadata() (*_structpb.Struct, error) { + var ( + val interface{} + ok bool + ) + switch decision := result.Decision.(type) { + case bool: + if decision { + return nil, fmt.Errorf("dynamic metadata undefined for boolean decision") + } + case map[string]interface{}: + if val, ok = decision["dynamic_metadata"]; !ok { + return nil, nil + } + + metadata, ok := val.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("type assertion error") + } + + return structpb.NewStruct(metadata) + } + + return nil, nil +} + // GetResponseEnvoyHTTPStatus returns the http status to return if they are part of the decision func (result *EvalResult) GetResponseEnvoyHTTPStatus() (*ext_type_v3.HttpStatus, error) { status := &ext_type_v3.HttpStatus{ diff --git a/envoyauth/response_test.go b/envoyauth/response_test.go index 28a8819c8..1631aa1d8 100644 --- a/envoyauth/response_test.go +++ b/envoyauth/response_test.go @@ -4,6 +4,9 @@ import ( "encoding/json" "reflect" "testing" + + _structpb "github.com/golang/protobuf/ptypes/struct" + "google.golang.org/protobuf/proto" ) func TestIsAllowed(t *testing.T) { @@ -341,3 +344,55 @@ func TestGetResponseHttpStatus(t *testing.T) { t.Fatalf("Expected http status code \"BadRequest\" but got %v", result.GetCode().String()) } } + +func TestGetDynamicMetadata(t *testing.T) { + input := make(map[string]interface{}) + er := EvalResult{ + Decision: input, + } + + result, err := er.GetDynamicMetadata() + if err != nil { + t.Fatalf("Expected no error but got %v", err) + } + + if result != nil { + t.Fatalf("Expected no dynamic metadata but got %v", result) + } + + input["dynamic_metadata"] = map[string]interface{}{ + "foo": "bar", + } + result, err = er.GetDynamicMetadata() + if err != nil { + t.Fatalf("Expected no error but got %v", err) + } + + expectedDynamicMetadata := &_structpb.Struct{ + Fields: map[string]*_structpb.Value{ + "foo": { + Kind: &_structpb.Value_StringValue{ + StringValue: "bar", + }, + }, + }, + } + if !proto.Equal(result, expectedDynamicMetadata) { + t.Fatalf("Expected result %v but got %v", expectedDynamicMetadata, result) + } +} + +func TestGetDynamicMetadataWithBooleanDecision(t *testing.T) { + er := EvalResult{ + Decision: true, + } + + result, err := er.GetDynamicMetadata() + if err == nil { + t.Fatal("Expected error error but got none") + } + + if result != nil { + t.Fatalf("Expected no result but got %v", result) + } +} diff --git a/internal/internal.go b/internal/internal.go index 4aff1f123..53bb264ed 100644 --- a/internal/internal.go +++ b/internal/internal.go @@ -7,7 +7,6 @@ package internal import ( "context" "fmt" - "github.com/open-policy-agent/opa/topdown" "math" "net" "net/url" @@ -41,12 +40,15 @@ import ( "github.com/open-policy-agent/opa/rego" "github.com/open-policy-agent/opa/server" "github.com/open-policy-agent/opa/storage" + "github.com/open-policy-agent/opa/topdown" iCache "github.com/open-policy-agent/opa/topdown/cache" "github.com/open-policy-agent/opa/tracing" "github.com/open-policy-agent/opa/util" "go.opentelemetry.io/otel/trace" + _structpb "github.com/golang/protobuf/ptypes/struct" + "github.com/open-policy-agent/opa-envoy-plugin/envoyauth" internal_util "github.com/open-policy-agent/opa-envoy-plugin/internal/util" "github.com/open-policy-agent/opa-envoy-plugin/opa/decisionlog" @@ -464,8 +466,16 @@ func (p *envoyExtAuthzGrpcServer) check(ctx context.Context, req interface{}) (* return nil, stop, &internalErr } - if status == int32(code.Code_OK) { + var dynamicMetadata *_structpb.Struct + dynamicMetadata, err = result.GetDynamicMetadata() + if err != nil { + err = errors.Wrap(err, "failed to get dynamic metadata") + internalErr = internalError(EnvoyAuthResultErr, err) + return nil, stop, &internalErr + } + resp.DynamicMetadata = dynamicMetadata + if status == int32(code.Code_OK) { var headersToRemove []string headersToRemove, err = result.GetRequestHTTPHeadersToRemove() if err != nil { diff --git a/internal/internal_test.go b/internal/internal_test.go index e2d85ff84..4d7074a5b 100644 --- a/internal/internal_test.go +++ b/internal/internal_test.go @@ -19,8 +19,10 @@ import ( ext_core "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" ext_authz_v2 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v2" ext_authz "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3" + _structpb "github.com/golang/protobuf/ptypes/struct" "github.com/prometheus/client_golang/prometheus" "google.golang.org/genproto/googleapis/rpc/code" + "google.golang.org/protobuf/proto" "github.com/open-policy-agent/opa-envoy-plugin/envoyauth" "github.com/open-policy-agent/opa/ast" @@ -1261,6 +1263,92 @@ func TestConfigWithProtoDescriptor(t *testing.T) { } } +func TestCheckAllowObjectDecisionDynamicMetadata(t *testing.T) { + var req ext_authz.CheckRequest + if err := util.Unmarshal([]byte(exampleAllowedRequestParsedPath), &req); err != nil { + panic(err) + } + + module := ` + package envoy.authz + + default allow = false + + allow { + input.parsed_path = ["my", "test", "path"] + } + + dynamic_metadata["foo"] = "bar" + dynamic_metadata["bar"] = "baz" + + result["allowed"] = allow + result["dynamic_metadata"] = dynamic_metadata + ` + + server := testAuthzServerWithModule(module, "envoy/authz/result", nil, withCustomLogger(&testPlugin{})) + ctx := context.Background() + output, err := server.Check(ctx, &req) + if err != nil { + t.Fatal(err) + } + + if output.Status.Code != int32(code.Code_OK) { + t.Fatalf("Expected request to be allowed but got: %v", output) + } + + response := output.GetOkResponse() + if response == nil { + t.Fatal("Expected OkHttpResponse struct but got nil") + } + + assertDynamicMetadata(t, &_structpb.Struct{ + Fields: map[string]*_structpb.Value{ + "foo": { + Kind: &_structpb.Value_StringValue{ + StringValue: "bar", + }, + }, + "bar": { + Kind: &_structpb.Value_StringValue{ + StringValue: "baz", + }, + }, + }, + }, output.GetDynamicMetadata()) +} + +func TestCheckAllowBooleanDecisionDynamicMetadata(t *testing.T) { + var req ext_authz.CheckRequest + if err := util.Unmarshal([]byte(exampleAllowedRequestParsedPath), &req); err != nil { + panic(err) + } + + module := ` + package envoy.authz + + default allow = false + + allow { + input.parsed_path = ["my", "test", "path"] + } + ` + + server := testAuthzServerWithModule(module, "envoy/authz/allow", nil, withCustomLogger(&testPlugin{})) + ctx := context.Background() + output, err := server.Check(ctx, &req) + if err != nil { + t.Fatal(err) + } + + if output.Status.Code != int32(code.Code_OK) { + t.Fatalf("Expected request to be allowed but got: %v", output) + } + + if output.GetDynamicMetadata() != nil { + t.Fatal("Expected nil dynamic metadata when using boolean decision") + } +} + func TestCheckAllowObjectDecisionReqHeadersToRemove(t *testing.T) { var req ext_authz.CheckRequest if err := util.Unmarshal([]byte(exampleAllowedRequestParsedPath), &req); err != nil { @@ -1463,6 +1551,11 @@ func TestCheckAllowObjectDecision(t *testing.T) { expectedHeaders[http.CanonicalHeaderKey("y")] = "world" assertHeaders(t, headers, expectedHeaders) + + dynamicMetadata := output.GetDynamicMetadata() + if dynamicMetadata == nil { + t.Fatal("Expected DynamicMetadata struct but got nil") + } } func TestCheckDenyObjectDecision(t *testing.T) { @@ -1563,6 +1656,21 @@ func TestCheckAllowWithDryRunObjectDecision(t *testing.T) { expectedHeaders[http.CanonicalHeaderKey("y")] = "world" assertHeaders(t, headers, expectedHeaders) + + assertDynamicMetadata(t, &_structpb.Struct{ + Fields: map[string]*_structpb.Value{ + "test": { + Kind: &_structpb.Value_StringValue{ + StringValue: "foo", + }, + }, + "bar": { + Kind: &_structpb.Value_StringValue{ + StringValue: "baz", + }, + }, + }, + }, output.GetDynamicMetadata()) } func TestPluginStatusLifeCycle(t *testing.T) { @@ -1741,14 +1849,16 @@ func testAuthzServerWithObjectDecision(customConfig *Config, customPluginFuncs . "allowed": false, "headers": {"foo": "bar", "baz": "taz"}, "body": "Unauthorized Request", - "http_status": 301 + "http_status": 301, + "dynamic_metadata": {"test": "foo", "bar": "baz"} } allow = response { input.parsed_path = ["my", "test", "path"] response := { "allowed": true, - "headers": {"x": "hello", "y": "world"} + "headers": {"x": "hello", "y": "world"}, + "dynamic_metadata": {"test": "foo", "bar": "baz"} } }` @@ -2016,6 +2126,13 @@ func assertErrorCounterMetric(t *testing.T, server *envoyExtAuthzGrpcServer, lab } } +func assertDynamicMetadata(t *testing.T, expectedMetadata, actualMetadata *_structpb.Struct) { + t.Helper() + if !proto.Equal(expectedMetadata, actualMetadata) { + t.Fatalf("Expected metadata %v but got %v", expectedMetadata, actualMetadata) + } +} + type testPlugin struct { events []logs.EventV1 }