Skip to content

Commit

Permalink
OPA: authorization based on request body
Browse files Browse the repository at this point in the history
Signed-off-by: Magnus Jungsbluth <[email protected]>
  • Loading branch information
mjungsbluth committed Aug 16, 2023
1 parent eb1137d commit 119ddc2
Show file tree
Hide file tree
Showing 12 changed files with 177 additions and 13 deletions.
3 changes: 3 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ type Config struct {
OpenPolicyAgentConfigTemplate string `yaml:"open-policy-agent-config-template"`
OpenPolicyAgentEnvoyMetadata string `yaml:"open-policy-agent-envoy-metadata"`
OpenPolicyAgentCleanerInterval time.Duration `yaml:"open-policy-agent-cleaner-interval"`
OpenPolicyAgentMaxBodySize int `yaml:"open-policy-agent-max-body-size"`
}

const (
Expand Down Expand Up @@ -493,6 +494,7 @@ func NewConfig() *Config {
flag.StringVar(&cfg.OpenPolicyAgentConfigTemplate, "open-policy-agent-config-template", "", "file containing a template for an Open Policy Agent configuration file that is interpolated for each OPA filter instance")
flag.StringVar(&cfg.OpenPolicyAgentEnvoyMetadata, "open-policy-agent-envoy-metadata", "", "JSON file containing meta-data passed as input for compatibility with Envoy policies in the format")
flag.DurationVar(&cfg.OpenPolicyAgentCleanerInterval, "open-policy-agent-cleaner-interval", openpolicyagent.DefaultCleanIdlePeriod, "JSON file containing meta-data passed as input for compatibility with Envoy policies in the format")
flag.IntVar(&cfg.OpenPolicyAgentMaxBodySize, "open-policy-agent-max-body-size", openpolicyagent.DefaultMaxBodySize, "Maximum number of bytes from the body that are passed as input to the policy")

// TLS client certs
flag.StringVar(&cfg.ClientKeyFile, "client-tls-key", "", "TLS Key file for backend connections, multiple keys may be given comma separated - the order must match the certs")
Expand Down Expand Up @@ -894,6 +896,7 @@ func (c *Config) ToOptions() skipper.Options {
OpenPolicyAgentConfigTemplate: c.OpenPolicyAgentConfigTemplate,
OpenPolicyAgentEnvoyMetadata: c.OpenPolicyAgentEnvoyMetadata,
OpenPolicyAgentCleanerInterval: c.OpenPolicyAgentCleanerInterval,
OpenPolicyAgentMaxBodySize: c.OpenPolicyAgentMaxBodySize,
}
for _, rcci := range c.CloneRoute {
eskipClone := eskip.NewClone(rcci.Reg, rcci.Repl)
Expand Down
1 change: 1 addition & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ func defaultConfig() *Config {
LuaModules: commaListFlag(),
LuaSources: commaListFlag(),
OpenPolicyAgentCleanerInterval: 10 * time.Second,
OpenPolicyAgentMaxBodySize: 134217728,
}
}

Expand Down
8 changes: 7 additions & 1 deletion docs/reference/filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -1796,7 +1796,13 @@ The difference is that if the decision in (3) is equivalent to false, the respon

Headers both to the upstream and the downstream service can be manipulated the same way this works for [Envoy external authorization](https://www.openpolicyagent.org/docs/latest/envoy-primer/#example-policy-with-additional-controls)

This allows both to add and remove unwanted headers in allow/deny cases.
This allows both to add and remove unwanted headers in allow/deny cases.

*Authorize based on request body*

Requests can also be authorized based on the request body the same way that is supported with the [Open Policy Agent Envoy plugin](https://www.openpolicyagent.org/docs/latest/envoy-primer/#example-input), look for the input attribute `parsed_body` in the upstream documentation.

The body is parsed up to a maximum size that can be configured via the `-open-policy-agent-max-body-size` command line argument.

#### opaServeResponse

Expand Down
2 changes: 1 addition & 1 deletion filters/openpolicyagent/evaluation.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func (opa *OpenPolicyAgentInstance) Eval(ctx context.Context, req *ext_authz_v3.
}

logger := opa.manager.Logger().WithFields(map[string]interface{}{"decision-id": result.DecisionID})
input, err = envoyauth.RequestToInput(req, logger, nil, true)
input, err = envoyauth.RequestToInput(req, logger, nil, opa.EnvoyPluginConfig().SkipRequestBodyParse)
if err != nil {
return nil, fmt.Errorf("failed to convert request to input: %w", err)
}
Expand Down
6 changes: 4 additions & 2 deletions filters/openpolicyagent/internal/envoy/envoyplugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,10 @@ func (p *Plugin) Reconfigure(ctx context.Context, config interface{}) {

// PluginConfig represents the plugin configuration.
type PluginConfig struct {
Path string `json:"path"`
DryRun bool `json:"dry-run"`
Path string `json:"path"`
DryRun bool `json:"dry-run"`
SkipRequestBodyParse bool `json:"skip-request-body-parse"`

ParsedQuery ast.Body
}

Expand Down
3 changes: 2 additions & 1 deletion filters/openpolicyagent/internal/envoy/skipperadapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import (
ext_authz_v3 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3"
)

func AdaptToExtAuthRequest(req *http.Request, metadata *ext_authz_v3_core.Metadata, contextExtensions map[string]string) *ext_authz_v3.CheckRequest {
func AdaptToExtAuthRequest(req *http.Request, metadata *ext_authz_v3_core.Metadata, contextExtensions map[string]string, rawBody []byte) *ext_authz_v3.CheckRequest {

headers := make(map[string]string, len(req.Header))
for h, vv := range req.Header {
Expand All @@ -25,6 +25,7 @@ func AdaptToExtAuthRequest(req *http.Request, metadata *ext_authz_v3_core.Metada
Method: req.Method,
Path: req.URL.Path,
Headers: headers,
RawBody: rawBody,
},
},
ContextExtensions: contextExtensions,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package opaauthorizerequest

import (
"encoding/json"
"errors"
"net/http"
"time"

Expand Down Expand Up @@ -86,12 +88,25 @@ func (f *opaAuthorizeRequestFilter) Request(fc filters.FilterContext) {
span, ctx := f.opa.StartSpanFromFilterContext(fc)
defer span.Finish()

authzreq := envoy.AdaptToExtAuthRequest(req, f.opa.InstanceConfig().GetEnvoyMetadata(), f.envoyContextExtensions)
body, rawBodyBytes, err := f.opa.ExtractHttpBodyOptionally(req)
if err != nil {
f.opa.HandleInvalidDecisionError(fc, span, nil, err, !f.opa.EnvoyPluginConfig().DryRun)
return
}
req.Body = body

authzreq := envoy.AdaptToExtAuthRequest(req, f.opa.InstanceConfig().GetEnvoyMetadata(), f.envoyContextExtensions, rawBodyBytes)

start := time.Now()
result, err := f.opa.Eval(ctx, authzreq)
fc.Metrics().MeasureSince(f.opa.MetricsKey("eval_time"), start)

var jsonErr *json.SyntaxError
if errors.As(err, &jsonErr) {
f.opa.HandleEvaluationError(fc, span, result, err, !f.opa.EnvoyPluginConfig().DryRun, http.StatusBadRequest)
return
}

if err != nil {
f.opa.HandleInvalidDecisionError(fc, span, result, err, !f.opa.EnvoyPluginConfig().DryRun)
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"

opasdktest "github.com/open-policy-agent/opa/sdk/test"
Expand All @@ -22,6 +23,9 @@ func TestAuthorizeRequestFilter(t *testing.T) {
bundleName string
regoQuery string
requestPath string
requestMethod string
requestHeaders http.Header
body string
contextExtensions string
expectedBody string
expectedHeaders http.Header
Expand All @@ -34,6 +38,7 @@ func TestAuthorizeRequestFilter(t *testing.T) {
bundleName: "somebundle.tar.gz",
regoQuery: "envoy/authz/allow",
requestPath: "/allow",
requestMethod: "GET",
contextExtensions: "",
expectedStatus: http.StatusOK,
expectedBody: "Welcome!",
Expand All @@ -46,6 +51,7 @@ func TestAuthorizeRequestFilter(t *testing.T) {
bundleName: "somebundle.tar.gz",
regoQuery: "envoy/authz/allow_context_extensions",
requestPath: "/allow",
requestMethod: "GET",
contextExtensions: "com.mycompany.myprop: myvalue",
expectedStatus: http.StatusOK,
expectedBody: "Welcome!",
Expand All @@ -58,6 +64,7 @@ func TestAuthorizeRequestFilter(t *testing.T) {
bundleName: "somebundle.tar.gz",
regoQuery: "envoy/authz/allow",
requestPath: "/forbidden",
requestMethod: "GET",
contextExtensions: "",
expectedStatus: http.StatusForbidden,
expectedHeaders: make(http.Header),
Expand All @@ -69,6 +76,7 @@ func TestAuthorizeRequestFilter(t *testing.T) {
bundleName: "somebundle.tar.gz",
regoQuery: "envoy/authz/allow_object",
requestPath: "/allow/structured",
requestMethod: "GET",
contextExtensions: "",
expectedStatus: http.StatusOK,
expectedBody: "Welcome!",
Expand All @@ -81,6 +89,7 @@ func TestAuthorizeRequestFilter(t *testing.T) {
bundleName: "somebundle.tar.gz",
regoQuery: "envoy/authz/allow_object",
requestPath: "/forbidden",
requestMethod: "GET",
contextExtensions: "",
expectedStatus: http.StatusUnauthorized,
expectedHeaders: map[string][]string{"X-Ext-Auth-Allow": {"no"}},
Expand All @@ -93,6 +102,7 @@ func TestAuthorizeRequestFilter(t *testing.T) {
bundleName: "somebundle.tar.gz",
regoQuery: "envoy/authz/invalid_path",
requestPath: "/allow",
requestMethod: "GET",
contextExtensions: "",
expectedStatus: http.StatusInternalServerError,
expectedBody: "",
Expand All @@ -105,6 +115,7 @@ func TestAuthorizeRequestFilter(t *testing.T) {
bundleName: "somebundle.tar.gz",
regoQuery: "envoy/authz/allow_wrong_type",
requestPath: "/allow",
requestMethod: "GET",
contextExtensions: "",
expectedStatus: http.StatusInternalServerError,
expectedBody: "",
Expand All @@ -117,6 +128,7 @@ func TestAuthorizeRequestFilter(t *testing.T) {
bundleName: "somebundle.tar.gz",
regoQuery: "envoy/authz/allow_object_invalid_headers_to_remove",
requestPath: "/allow",
requestMethod: "GET",
contextExtensions: "",
expectedStatus: http.StatusInternalServerError,
expectedBody: "",
Expand All @@ -129,20 +141,69 @@ func TestAuthorizeRequestFilter(t *testing.T) {
bundleName: "somebundle.tar.gz",
regoQuery: "envoy/authz/allow_object_invalid_headers",
requestPath: "/allow",
requestMethod: "GET",
contextExtensions: "",
expectedStatus: http.StatusInternalServerError,
expectedBody: "",
expectedHeaders: make(http.Header),
backendHeaders: make(http.Header),
removeHeaders: make(http.Header),
},
{
msg: "Allow With Body",
bundleName: "somebundle.tar.gz",
regoQuery: "envoy/authz/allow_body",
requestMethod: "POST",
body: `{ "target_id" : "123456" }`,
requestHeaders: map[string][]string{"content-type": {"application/json"}},
requestPath: "/allow_body",
expectedStatus: http.StatusOK,
expectedBody: "Welcome!",
expectedHeaders: make(http.Header),
backendHeaders: make(http.Header),
removeHeaders: make(http.Header),
},
{
msg: "Forbidden With Body",
bundleName: "somebundle.tar.gz",
regoQuery: "envoy/authz/allow_body",
requestMethod: "POST",
body: `{ "target_id" : "wrong id" }`,
requestHeaders: map[string][]string{"content-type": {"application/json"}},
requestPath: "/allow_body",
expectedStatus: http.StatusForbidden,
expectedBody: "",
expectedHeaders: make(http.Header),
backendHeaders: make(http.Header),
removeHeaders: make(http.Header),
},
{
msg: "Broken Body",
bundleName: "somebundle.tar.gz",
regoQuery: "envoy/authz/allow_body",
requestMethod: "POST",
body: `{ "target_id" / "wrong id" }`,
requestHeaders: map[string][]string{"content-type": {"application/json"}},
requestPath: "/allow_body",
expectedStatus: http.StatusBadRequest,
expectedBody: "",
expectedHeaders: make(http.Header),
backendHeaders: make(http.Header),
removeHeaders: make(http.Header),
},
} {
t.Run(ti.msg, func(t *testing.T) {
t.Logf("Running test for %v", ti)
clientServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Welcome!"))
assert.True(t, isHeadersPresent(t, ti.backendHeaders, r.Header), "Enriched request header is absent.")
assert.True(t, isHeadersAbsent(t, ti.removeHeaders, r.Header), "Unwanted HTTP Headers present.")

body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, ti.body, string(body))
}))

opaControlPlane := opasdktest.MustNewServer(
Expand Down Expand Up @@ -192,6 +253,12 @@ func TestAuthorizeRequestFilter(t *testing.T) {
"allowed": true,
"headers": "bogus string instead of object"
}
default allow_body = false
allow_body {
input.parsed_body.target_id == "123456"
}
`,
}),
)
Expand Down Expand Up @@ -225,12 +292,17 @@ func TestAuthorizeRequestFilter(t *testing.T) {

proxy := proxytest.New(fr, r...)

req, err := http.NewRequest("GET", proxy.URL+ti.requestPath, nil)
req, err := http.NewRequest(ti.requestMethod, proxy.URL+ti.requestPath, strings.NewReader(ti.body))
for name, values := range ti.removeHeaders {
for _, value := range values {
req.Header.Add(name, value) //adding the headers to validate removal.
}
}
for name, values := range ti.requestHeaders {
for _, value := range values {
req.Header.Add(name, value)
}
}

assert.NoError(t, err)

Expand Down
10 changes: 9 additions & 1 deletion filters/openpolicyagent/opaserveresponse/opaserveresponse.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,15 @@ func (f *opaServeResponseFilter) Request(fc filters.FilterContext) {
span, ctx := f.opa.StartSpanFromFilterContext(fc)
defer span.Finish()

authzreq := envoy.AdaptToExtAuthRequest(fc.Request(), f.opa.InstanceConfig().GetEnvoyMetadata(), f.envoyContextExtensions)
req := fc.Request()
body, rawBodyBytes, err := f.opa.ExtractHttpBodyOptionally(req)
if err != nil {
f.opa.ServeInvalidDecisionError(fc, span, nil, err)
return
}
req.Body = body

authzreq := envoy.AdaptToExtAuthRequest(fc.Request(), f.opa.InstanceConfig().GetEnvoyMetadata(), f.envoyContextExtensions, rawBodyBytes)

start := time.Now()
result, err := f.opa.Eval(ctx, authzreq)
Expand Down
Loading

0 comments on commit 119ddc2

Please sign in to comment.