From b64c3ea2c18373b85b0a38216baa1dafd5f16a77 Mon Sep 17 00:00:00 2001 From: Artem Mironov Date: Fri, 14 Jun 2024 23:20:12 +0300 Subject: [PATCH] feat: match_json_field in remote_json authorizer to check response body values --- pipeline/authz/remote_json.go | 48 ++++++++++++--- pipeline/authz/remote_json_test.go | 93 +++++++++++++++++++++++++++--- spec/config.schema.json | 24 ++++++++ 3 files changed, 150 insertions(+), 15 deletions(-) diff --git a/pipeline/authz/remote_json.go b/pipeline/authz/remote_json.go index 37f846c8a5..432c69fba3 100644 --- a/pipeline/authz/remote_json.go +++ b/pipeline/authz/remote_json.go @@ -8,11 +8,13 @@ import ( "crypto/sha256" "encoding/json" "fmt" + "io" "net/http" "text/template" "time" "github.com/pkg/errors" + "github.com/tidwall/gjson" "github.com/ory/x/httpx" "github.com/ory/x/otelx" @@ -28,11 +30,12 @@ import ( // AuthorizerRemoteJSONConfiguration represents a configuration for the remote_json authorizer. type AuthorizerRemoteJSONConfiguration struct { - Remote string `json:"remote"` - Headers map[string]string `json:"headers"` - Payload string `json:"payload"` - ForwardResponseHeadersToUpstream []string `json:"forward_response_headers_to_upstream"` - Retry *AuthorizerRemoteJSONRetryConfiguration `json:"retry"` + Remote string `json:"remote"` + Headers map[string]string `json:"headers"` + Payload string `json:"payload"` + ForwardResponseHeadersToUpstream []string `json:"forward_response_headers_to_upstream"` + Retry *AuthorizerRemoteJSONRetryConfiguration `json:"retry"` + MatchJsonField *AuthorizerRemoteJSONMatchJsonFieldConfig `json:"match_json_field"` } type AuthorizerRemoteJSONRetryConfiguration struct { @@ -40,6 +43,12 @@ type AuthorizerRemoteJSONRetryConfiguration struct { MaxWait string `json:"give_up_after"` } +type AuthorizerRemoteJSONMatchJsonFieldConfig struct { + Field string `json:"field"` + StrVal string `json:"str_val"` + BoolVal *bool `json:"bool_val"` +} + // PayloadTemplateID returns a string with which to associate the payload template. func (c *AuthorizerRemoteJSONConfiguration) PayloadTemplateID() string { return fmt.Sprintf("%x", sha256.Sum256([]byte(c.Payload))) @@ -146,6 +155,25 @@ func (a *AuthorizerRemoteJSON) Authorize(r *http.Request, session *authn.Authent return errors.WithStack(helper.ErrForbidden) } else if res.StatusCode != http.StatusOK { return errors.Errorf("expected status code %d but got %d", http.StatusOK, res.StatusCode) + } else if c.MatchJsonField != nil { + var body []byte + if body, err = io.ReadAll(res.Body); err != nil { + return errors.WithStack(err) + } + fieldValue := gjson.GetBytes(body, c.MatchJsonField.Field) + if !fieldValue.Exists() { + return errors.Errorf("field %s to match not found in response", c.MatchJsonField.Field) + } + switch fieldValue.Type { + case gjson.String: + if fieldValue.String() != c.MatchJsonField.StrVal { + return errors.WithStack(helper.ErrForbidden) + } + case gjson.True, gjson.False: + if c.MatchJsonField.BoolVal == nil || fieldValue.Bool() != *c.MatchJsonField.BoolVal { + return errors.WithStack(helper.ErrForbidden) + } + } } for _, allowedHeader := range c.ForwardResponseHeadersToUpstream { @@ -161,8 +189,14 @@ func (a *AuthorizerRemoteJSON) Validate(config json.RawMessage) error { return NewErrAuthorizerNotEnabled(a) } - _, err := a.Config(config) - return err + c, err := a.Config(config) + if err != nil { + return err + } + if c.MatchJsonField != nil && c.MatchJsonField.BoolVal == nil && c.MatchJsonField.StrVal == "" { + return errors.New("either bool_val or str_val must be set") + } + return nil } // Config merges config and the authorizer's configuration and validates the diff --git a/pipeline/authz/remote_json_test.go b/pipeline/authz/remote_json_test.go index f48f283477..ddde3ae3b2 100644 --- a/pipeline/authz/remote_json_test.go +++ b/pipeline/authz/remote_json_test.go @@ -26,16 +26,35 @@ import ( "github.com/ory/x/otelx" ) +type authorizeTestStruct struct { + name string + setup func(t *testing.T) *httptest.Server + session *authn.AuthenticationSession + sessionHeaderMatch *http.Header + config json.RawMessage + wantErr bool +} + +func matchJsonFieldTest(name string, response string, config string, wantErr bool) authorizeTestStruct { + return authorizeTestStruct{ + name: name, + setup: func(t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := io.ReadAll(r.Body) + require.NoError(t, err) + w.WriteHeader(http.StatusOK) + w.Write([]byte(response)) + })) + }, + session: &authn.AuthenticationSession{}, + config: json.RawMessage(config), + wantErr: wantErr, + } +} + func TestAuthorizerRemoteJSONAuthorize(t *testing.T) { t.Parallel() - tests := []struct { - name string - setup func(t *testing.T) *httptest.Server - session *authn.AuthenticationSession - sessionHeaderMatch *http.Header - config json.RawMessage - wantErr bool - }{ + tests := []authorizeTestStruct{ { name: "invalid configuration", session: &authn.AuthenticationSession{}, @@ -106,6 +125,30 @@ func TestAuthorizerRemoteJSONAuthorize(t *testing.T) { config: json.RawMessage(`{"payload":"{}"}`), wantErr: true, }, + matchJsonFieldTest("match_json_field denied empty body", + "", + `{"payload":"{}","match_json_field": {"field":"result", "str_val": "allowed"}}`, + true), + matchJsonFieldTest("match_json_field denied bool", + `{"allowed": false}`, + `{"payload":"{}","match_json_field": {"field":"allowed", "bool_val": true}}`, + true), + matchJsonFieldTest("match_json_field denied bool false", + `{"denied": true}`, + `{"payload":"{}","match_json_field": {"field":"denied", "bool_val": false}}`, + true), + matchJsonFieldTest("match_json_field denied str", + `{"result": "denied"}`, + `{"payload":"{}","match_json_field": {"field":"result", "str_val": "allowed"}}`, + true), + matchJsonFieldTest("match_json_field denied wrong type", + `{"result": "denied"}`, + `{"payload":"{}","match_json_field": {"field":"result", "bool_val": true}}`, + true), + matchJsonFieldTest("match_json_field denied true as string", + `{"allowed": "true"}`, + `{"payload":"{}","match_json_field": {"field":"allowed", "bool_val": true}}`, + true), { name: "ok", setup: func(t *testing.T) *httptest.Server { @@ -196,6 +239,18 @@ func TestAuthorizerRemoteJSONAuthorize(t *testing.T) { session: &authn.AuthenticationSession{}, config: json.RawMessage(`{"payload":"[\"foo\",\"bar\"]"}`), }, + matchJsonFieldTest("match_json_field bool field", + `{"allowed": true}`, + `{"payload":"{}","match_json_field": {"field":"allowed", "bool_val": true}}`, + false), + matchJsonFieldTest("match_json_field str field", + `{"result": "allowed"}`, + `{"payload":"{}","match_json_field": {"field":"result", "str_val": "allowed"}}`, + false), + matchJsonFieldTest("match_json_field nested", + `{"deeply": {"nested": {"result": "allowed"}}}`, + `{"payload":"{}","match_json_field": {"field":"deeply.nested.result", "str_val": "allowed"}}`, + false), } for _, tt := range tests { tt := tt @@ -266,6 +321,18 @@ func TestAuthorizerRemoteJSONValidate(t *testing.T) { config: json.RawMessage(`{"remote":"invalid-url","payload":"{}"}`), wantErr: true, }, + { + name: "match response empty", + enabled: true, + config: json.RawMessage(`{"remote":"http://host/path", "payload":"{}", "match_json_field":{}}`), + wantErr: true, + }, + { + name: "match response no value", + enabled: true, + config: json.RawMessage(`{"remote":"http://host/path", "payload":"{}", "match_json_field":{"field":"foo"}}`), + wantErr: true, + }, { name: "valid configuration", enabled: true, @@ -291,6 +358,16 @@ func TestAuthorizerRemoteJSONValidate(t *testing.T) { enabled: true, config: json.RawMessage(`{"remote":"http://host/path","payload":"{}","retry":{"give_up_after":"3s", "max_delay":"100ms"}}`), }, + { + name: "valid configuration with match bool field", + enabled: true, + config: json.RawMessage(`{"remote":"http://host/path","payload":"{}","match_json_field":{"field":"foo","bool_val":true}}`), + }, + { + name: "valid configuration with match str field", + enabled: true, + config: json.RawMessage(`{"remote":"http://host/path","payload":"{}","match_json_field":{"field":"foo","str_val":"bar"}}`), + }, } for _, tt := range tests { tt := tt diff --git a/spec/config.schema.json b/spec/config.schema.json index ce6ef286d5..fd761db301 100644 --- a/spec/config.schema.json +++ b/spec/config.schema.json @@ -1013,6 +1013,30 @@ "uniqueItems": true, "default": [] }, + "match_json_field": { + "description": "Match the JSON field in response from the remote authorizer to allow/deny access. Only applicable if it respond with HTTP 200", + "title": "Match JSON Response", + "type": "object", + "required": ["field"], + "properties": { + "field": { + "type": "string", + "description": "Path to field in [gjson path](https://github.com/tidwall/gjson/blob/v1.14.3/SYNTAX.md) format. If the field is not found, the request will be denied.", + "examples": ["allowed"] + }, + "str_val": { + "type": "string", + "description": "String value to match the field against.", + "examples": ["true"] + }, + "bool_val": { + "type": "boolean", + "description": "Boolean value to match the field against.", + "examples": ["true"] + } + }, + "additionalProperties": false + }, "retry": { "$ref": "#/definitions/retry" }