Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: match_json_field in remote_json authorizer (#1164) #1170

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading