Skip to content

Commit

Permalink
feat: match_json_field in remote_json authorizer to check response bo…
Browse files Browse the repository at this point in the history
…dy values
  • Loading branch information
jaspeen committed Jun 14, 2024
1 parent 2373057 commit b64c3ea
Show file tree
Hide file tree
Showing 3 changed files with 150 additions and 15 deletions.
48 changes: 41 additions & 7 deletions pipeline/authz/remote_json.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -28,18 +30,25 @@ 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 {
Timeout string `json:"max_delay"`
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)))
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
93 changes: 85 additions & 8 deletions pipeline/authz/remote_json_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{},
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
24 changes: 24 additions & 0 deletions spec/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down

0 comments on commit b64c3ea

Please sign in to comment.