From 45b9f8b981f0227a92ff5c4001061e86afc0701f Mon Sep 17 00:00:00 2001 From: Satoshi Matsumoto Date: Sun, 29 Mar 2020 13:08:40 +0100 Subject: [PATCH] feat(authz): Add remote_json authorizer (#389) This patch adds the `remote_json` authorizer as documented here: https://github.com/ory/docs/commit/07a229701835d75e9c2e4b939badb2d5b96ae6aa#diff-c400219db6c7e4b6abab71839d9d294eR272 Closes #201 --- .schemas/authorizers.remote_json.schema.json | 31 +++ .schemas/config.schema.json | 61 ++++++ .../authorizers.remote_json.schema.json | 5 + docs/.oathkeeper.yaml | 9 + driver/configuration/provider_viper.go | 2 + .../provider_viper_public_test.go | 12 ++ driver/registry_memory.go | 1 + driver/registry_memory_test.go | 39 ++++ pipeline/authz/remote_json.go | 121 +++++++++++ pipeline/authz/remote_json_test.go | 199 ++++++++++++++++++ 10 files changed, 480 insertions(+) create mode 100644 .schemas/authorizers.remote_json.schema.json create mode 100644 .schemas/pipeline/authorizers.remote_json.schema.json create mode 100644 driver/registry_memory_test.go create mode 100644 pipeline/authz/remote_json.go create mode 100644 pipeline/authz/remote_json_test.go diff --git a/.schemas/authorizers.remote_json.schema.json b/.schemas/authorizers.remote_json.schema.json new file mode 100644 index 0000000000..77a1b9d539 --- /dev/null +++ b/.schemas/authorizers.remote_json.schema.json @@ -0,0 +1,31 @@ +{ + "$id": "https://raw.githubusercontent.com/ory/oathkeeper/master/.schemas/authorizers.remote_json.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "Remote JSON Configuration", + "description": "This section is optional when the authorizer is disabled.", + "properties": { + "remote": { + "title": "Remote Authorizer URL", + "type": "string", + "format": "uri", + "description": "The URL of the remote authorizer. The remote authorizer is expected to return either 200 OK or 403 Forbidden to allow/deny access.\n\n>If this authorizer is enabled, this value is required.", + "examples": [ + "https://host/path" + ] + }, + "payload": { + "title": "JSON Payload", + "type": "string", + "description": "The JSON payload of the request sent to the remote authorizer. The string will be parsed by the Go text/template package and applied to an AuthenticationSession object.\n\n>If this authorizer is enabled, this value is required.", + "examples": [ + "{\"subject\":\"{{ .Subject }}\"}" + ] + } + }, + "required": [ + "remote", + "payload" + ], + "additionalProperties": false +} diff --git a/.schemas/config.schema.json b/.schemas/config.schema.json index 2e517b7240..8afbadc927 100644 --- a/.schemas/config.schema.json +++ b/.schemas/config.schema.json @@ -738,6 +738,35 @@ ], "additionalProperties": false }, + "configAuthorizersRemoteJSON": { + "type": "object", + "title": "Remote JSON Configuration", + "description": "This section is optional when the authorizer is disabled.", + "properties": { + "remote": { + "title": "Remote Authorizer URL", + "type": "string", + "format": "uri", + "description": "The URL of the remote authorizer. The remote authorizer is expected to return either 200 OK or 403 Forbidden to allow/deny access.\n\n>If this authorizer is enabled, this value is required.", + "examples": [ + "https://host/path" + ] + }, + "payload": { + "title": "JSON Payload", + "type": "string", + "description": "The JSON payload of the request sent to the remote authorizer. The string will be parsed by the Go text/template package and applied to an AuthenticationSession object.\n\n>If this authorizer is enabled, this value is required.", + "examples": [ + "{\"subject\":\"{{ .Subject }}\"}" + ] + } + }, + "required": [ + "remote", + "payload" + ], + "additionalProperties": false + }, "configMutatorsCookie": { "type": "object", "title": "Cookie Mutator Configuration", @@ -1372,6 +1401,38 @@ } } ] + }, + "remote_json": { + "title": "Remote JSON", + "description": "The [`remote_json` authorizer](https://www.ory.sh/docs/oathkeeper/pipeline/authz#remote_json).", + "type": "object", + "properties": { + "enabled": { + "$ref": "#/definitions/handlerSwitch" + } + }, + "oneOf": [ + { + "properties": { + "enabled": { + "const": true + }, + "config": { + "$ref": "#/definitions/configAuthorizersRemoteJSON" + } + }, + "required": [ + "config" + ] + }, + { + "properties": { + "enabled": { + "const": false + } + } + } + ] } } }, diff --git a/.schemas/pipeline/authorizers.remote_json.schema.json b/.schemas/pipeline/authorizers.remote_json.schema.json new file mode 100644 index 0000000000..ef45b5c741 --- /dev/null +++ b/.schemas/pipeline/authorizers.remote_json.schema.json @@ -0,0 +1,5 @@ +{ + "$id": "https://raw.githubusercontent.com/ory/oathkeeper/v0.34.0-beta.1/.schemas/authorizers.remote_json.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "https://raw.githubusercontent.com/ory/oathkeeper/v0.34.0-beta.1/.schemas/config.schema.json#/definitions/configAuthorizersRemoteJSON" +} diff --git a/docs/.oathkeeper.yaml b/docs/.oathkeeper.yaml index 9246af46ff..be5432d438 100644 --- a/docs/.oathkeeper.yaml +++ b/docs/.oathkeeper.yaml @@ -241,6 +241,15 @@ authorizers: required_action: unknown required_resource: unknown + # Configures the remote_json authorizer + remote_json: + # Set enabled to true if the authorizer should be enabled and false to disable the authorizer. Defaults to false. + enabled: true + + config: + remote: https://host/path + payload: "{}" + # All mutators can be configured under this configuration key mutators: header: diff --git a/driver/configuration/provider_viper.go b/driver/configuration/provider_viper.go index 2250028ad3..7ea1e74386 100644 --- a/driver/configuration/provider_viper.go +++ b/driver/configuration/provider_viper.go @@ -56,6 +56,8 @@ const ( ViperKeyAuthorizerDenyIsEnabled = "authorizers.deny.enabled" ViperKeyAuthorizerKetoEngineACPORYIsEnabled = "authorizers.keto_engine_acp_ory.enabled" + + ViperKeyAuthorizerRemoteJSONIsEnabled = "authorizers.remote_json.enabled" ) // Mutators diff --git a/driver/configuration/provider_viper_public_test.go b/driver/configuration/provider_viper_public_test.go index d67797526f..43cf630175 100644 --- a/driver/configuration/provider_viper_public_test.go +++ b/driver/configuration/provider_viper_public_test.go @@ -333,6 +333,18 @@ func TestViperProvider(t *testing.T) { assert.EqualValues(t, "http://my-keto/", config.BaseURL) }) + + t.Run("authorizer=remote_json", func(t *testing.T) { + a := authz.NewAuthorizerRemoteJSON(p) + assert.True(t, p.AuthorizerIsEnabled(a.GetID())) + require.NoError(t, a.Validate(nil)) + + config, err := a.Config(nil) + require.NoError(t, err) + + assert.EqualValues(t, "https://host/path", config.Remote) + assert.EqualValues(t, "{}", config.Payload) + }) }) t.Run("group=mutators", func(t *testing.T) { diff --git a/driver/registry_memory.go b/driver/registry_memory.go index 46d7cd5e8c..75d47973c0 100644 --- a/driver/registry_memory.go +++ b/driver/registry_memory.go @@ -365,6 +365,7 @@ func (r *RegistryMemory) prepareAuthz() { authz.NewAuthorizerAllow(r.c), authz.NewAuthorizerDeny(r.c), authz.NewAuthorizerKetoEngineACPORY(r.c), + authz.NewAuthorizerRemoteJSON(r.c), } r.authorizers = map[string]authz.Authorizer{} diff --git a/driver/registry_memory_test.go b/driver/registry_memory_test.go new file mode 100644 index 0000000000..7396e11178 --- /dev/null +++ b/driver/registry_memory_test.go @@ -0,0 +1,39 @@ +package driver + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRegistryMemoryAvailablePipelineAuthorizers(t *testing.T) { + r := NewRegistryMemory() + got := r.AvailablePipelineAuthorizers() + assert.ElementsMatch(t, got, []string{"allow", "deny", "keto_engine_acp_ory", "remote_json"}) +} + +func TestRegistryMemoryPipelineAuthorizer(t *testing.T) { + tests := []struct { + id string + wantErr bool + }{ + {id: "allow"}, + {id: "deny"}, + {id: "keto_engine_acp_ory"}, + {id: "remote_json"}, + {id: "unregistered", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.id, func(t *testing.T) { + r := NewRegistryMemory() + a, err := r.PipelineAuthorizer(tt.id) + if (err != nil) != tt.wantErr { + t.Errorf("PipelineAuthorizer() error = %v, wantErr %v", err, tt.wantErr) + return + } + if a != nil && a.GetID() != tt.id { + t.Errorf("PipelineAuthorizer() got = %v, want %v", a.GetID(), tt.id) + } + }) + } +} diff --git a/pipeline/authz/remote_json.go b/pipeline/authz/remote_json.go new file mode 100644 index 0000000000..66e12b40db --- /dev/null +++ b/pipeline/authz/remote_json.go @@ -0,0 +1,121 @@ +package authz + +import ( + "bytes" + "crypto/sha256" + "encoding/json" + "fmt" + "net/http" + "text/template" + + "github.com/ory/x/httpx" + "github.com/pkg/errors" + + "github.com/ory/oathkeeper/driver/configuration" + "github.com/ory/oathkeeper/helper" + "github.com/ory/oathkeeper/pipeline" + "github.com/ory/oathkeeper/pipeline/authn" + "github.com/ory/oathkeeper/x" +) + +// AuthorizerRemoteJSONConfiguration represents a configuration for the remote_json authorizer. +type AuthorizerRemoteJSONConfiguration struct { + Remote string `json:"remote"` + Payload string `json:"payload"` +} + +// 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))) +} + +// AuthorizerRemoteJSON implements the Authorizer interface. +type AuthorizerRemoteJSON struct { + c configuration.Provider + + client *http.Client + t *template.Template +} + +// NewAuthorizerRemoteJSON creates a new AuthorizerRemoteJSON. +func NewAuthorizerRemoteJSON(c configuration.Provider) *AuthorizerRemoteJSON { + return &AuthorizerRemoteJSON{ + c: c, + client: httpx.NewResilientClientLatencyToleranceSmall(nil), + t: x.NewTemplate("remote_json"), + } +} + +// GetID implements the Authorizer interface. +func (a *AuthorizerRemoteJSON) GetID() string { + return "remote_json" +} + +// Authorize implements the Authorizer interface. +func (a *AuthorizerRemoteJSON) Authorize(_ *http.Request, session *authn.AuthenticationSession, config json.RawMessage, _ pipeline.Rule) error { + c, err := a.Config(config) + if err != nil { + return err + } + + templateID := c.PayloadTemplateID() + t := a.t.Lookup(templateID) + if t == nil { + var err error + t, err = a.t.New(templateID).Parse(c.Payload) + if err != nil { + return errors.WithStack(err) + } + } + + var body bytes.Buffer + if err := t.Execute(&body, session); err != nil { + return errors.WithStack(err) + } + + var j json.RawMessage + if err := json.Unmarshal(body.Bytes(), &j); err != nil { + return errors.Wrap(err, "payload is not a JSON text") + } + + req, err := http.NewRequest("POST", c.Remote, &body) + if err != nil { + return errors.WithStack(err) + } + req.Header.Add("Content-Type", "application/json") + + res, err := a.client.Do(req) + if err != nil { + return errors.WithStack(err) + } + defer res.Body.Close() + + if res.StatusCode == http.StatusForbidden { + 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) + } + + return nil +} + +// Validate implements the Authorizer interface. +func (a *AuthorizerRemoteJSON) Validate(config json.RawMessage) error { + if !a.c.AuthorizerIsEnabled(a.GetID()) { + return NewErrAuthorizerNotEnabled(a) + } + + _, err := a.Config(config) + return err +} + +// Config merges config and the authorizer's configuration and validates the +// resulting configuration. It reports an error if the configuration is invalid. +func (a *AuthorizerRemoteJSON) Config(config json.RawMessage) (*AuthorizerRemoteJSONConfiguration, error) { + var c AuthorizerRemoteJSONConfiguration + if err := a.c.AuthorizerConfig(a.GetID(), config, &c); err != nil { + return nil, NewErrAuthorizerMisconfigured(a, err) + } + + return &c, nil +} diff --git a/pipeline/authz/remote_json_test.go b/pipeline/authz/remote_json_test.go new file mode 100644 index 0000000000..79e501f83d --- /dev/null +++ b/pipeline/authz/remote_json_test.go @@ -0,0 +1,199 @@ +package authz_test + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/ory/viper" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/sjson" + + "github.com/ory/oathkeeper/driver/configuration" + "github.com/ory/oathkeeper/pipeline/authn" + . "github.com/ory/oathkeeper/pipeline/authz" + "github.com/ory/oathkeeper/rule" +) + +func TestAuthorizerRemoteJSONAuthorize(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) *httptest.Server + session *authn.AuthenticationSession + config json.RawMessage + wantErr bool + }{ + { + name: "invalid configuration", + session: &authn.AuthenticationSession{}, + config: json.RawMessage(`{}`), + wantErr: true, + }, + { + name: "unresolvable host", + session: &authn.AuthenticationSession{}, + config: json.RawMessage(`{"remote":"http://unresolvable-host/path","payload":"{}"}`), + wantErr: true, + }, + { + name: "invalid template", + session: &authn.AuthenticationSession{}, + config: json.RawMessage(`{"remote":"http://host/path","payload":"{{"}`), + wantErr: true, + }, + { + name: "unknown field", + session: &authn.AuthenticationSession{}, + config: json.RawMessage(`{"remote":"http://host/path","payload":"{{ .foo }}"}`), + wantErr: true, + }, + { + name: "invalid json", + session: &authn.AuthenticationSession{}, + config: json.RawMessage(`{"remote":"http://host/path","payload":"{"}`), + wantErr: true, + }, + { + name: "forbidden", + setup: func(t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + }, + session: &authn.AuthenticationSession{}, + config: json.RawMessage(`{"payload":"{}"}`), + wantErr: true, + }, + { + name: "unexpected status code", + setup: func(t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + })) + }, + session: &authn.AuthenticationSession{}, + config: json.RawMessage(`{"payload":"{}"}`), + wantErr: true, + }, + { + name: "ok", + setup: func(t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Contains(t, r.Header, "Content-Type") + assert.Contains(t, r.Header["Content-Type"], "application/json") + body, err := ioutil.ReadAll(r.Body) + require.NoError(t, err) + assert.Equal(t, string(body), "{}") + w.WriteHeader(http.StatusOK) + })) + }, + session: &authn.AuthenticationSession{}, + config: json.RawMessage(`{"payload":"{}"}`), + }, + { + name: "authentication session", + setup: func(t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := ioutil.ReadAll(r.Body) + require.NoError(t, err) + assert.Equal(t, string(body), `{"subject":"alice","extra":"bar","match":"baz"}`) + w.WriteHeader(http.StatusOK) + })) + }, + session: &authn.AuthenticationSession{ + Subject: "alice", + Extra: map[string]interface{}{"foo": "bar"}, + MatchContext: authn.MatchContext{ + RegexpCaptureGroups: []string{"baz"}, + }, + }, + config: json.RawMessage(`{"payload":"{\"subject\":\"{{ .Subject }}\",\"extra\":\"{{ .Extra.foo }}\",\"match\":\"{{ index .MatchContext.RegexpCaptureGroups 0 }}\"}"}`), + }, + { + name: "json array", + setup: func(t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := ioutil.ReadAll(r.Body) + require.NoError(t, err) + assert.Equal(t, string(body), `["foo","bar"]`) + w.WriteHeader(http.StatusOK) + })) + }, + session: &authn.AuthenticationSession{}, + config: json.RawMessage(`{"payload":"[\"foo\",\"bar\"]"}`), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.setup != nil { + server := tt.setup(t) + defer server.Close() + tt.config, _ = sjson.SetBytes(tt.config, "remote", server.URL) + } + + p := configuration.NewViperProvider(logrus.New()) + a := NewAuthorizerRemoteJSON(p) + if err := a.Authorize(&http.Request{}, tt.session, tt.config, &rule.Rule{}); (err != nil) != tt.wantErr { + t.Errorf("Authorize() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestAuthorizerRemoteJSONValidate(t *testing.T) { + tests := []struct { + name string + enabled bool + config json.RawMessage + wantErr bool + }{ + { + name: "disabled", + config: json.RawMessage(`{}`), + wantErr: true, + }, + { + name: "empty configuration", + enabled: true, + config: json.RawMessage(`{}`), + wantErr: true, + }, + { + name: "missing payload", + enabled: true, + config: json.RawMessage(`{"remote":"http://host/path"}`), + wantErr: true, + }, + { + name: "missing remote", + enabled: true, + config: json.RawMessage(`{"payload":"{}"}`), + wantErr: true, + }, + { + name: "invalid url", + enabled: true, + config: json.RawMessage(`{"remote":"invalid-url","payload":"{}"}`), + wantErr: true, + }, + { + name: "valid configuration", + enabled: true, + config: json.RawMessage(`{"remote":"http://host/path","payload":"{}"}`), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := configuration.NewViperProvider(logrus.New()) + a := NewAuthorizerRemoteJSON(p) + viper.Set(configuration.ViperKeyAuthorizerRemoteJSONIsEnabled, tt.enabled) + if err := a.Validate(tt.config); (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +}