From 3a206376c0ec4d72d5d6ec66c2d738199a24e0c6 Mon Sep 17 00:00:00 2001 From: Marlinc Date: Fri, 24 Apr 2020 11:11:19 +0200 Subject: [PATCH] feat: add new remote authorizer that uses request body and headers (#416) This pull request implements a new authorizer that sends the original request body as body to the remote endpoint. This allows the remote endpoint to take the body into account in its decision. The current remote_json authorizer does not have the ability to send the request body of the request to authorize. This means this cannot be taken into account while checking permissions. Providing the request body as part of the JSON payload won't always work as JSON cannot handle binary data. --- .schema/config.schema.json | 58 ++++++ .../pipeline/authorizers.remote.schema.json | 5 + docs/docs/pipeline/authz.md | 81 ++++++++ driver/configuration/provider_viper.go | 2 + driver/registry_memory.go | 1 + driver/registry_memory_test.go | 3 +- internal/config/.oathkeeper.yaml | 9 + pipeline/authz/remote.go | 129 ++++++++++++ pipeline/authz/remote_test.go | 193 ++++++++++++++++++ pipeline/authz/utils.go | 19 ++ 10 files changed, 499 insertions(+), 1 deletion(-) create mode 100644 .schema/pipeline/authorizers.remote.schema.json create mode 100644 pipeline/authz/remote.go create mode 100644 pipeline/authz/remote_test.go create mode 100644 pipeline/authz/utils.go diff --git a/.schema/config.schema.json b/.schema/config.schema.json index e10bfa0817..5d18c42316 100644 --- a/.schema/config.schema.json +++ b/.schema/config.schema.json @@ -738,6 +738,32 @@ ], "additionalProperties": false }, + "configAuthorizersRemote": { + "type": "object", + "title": "Remote 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" + ] + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "remote" + ], + "additionalProperties": false + }, "configAuthorizersRemoteJSON": { "type": "object", "title": "Remote JSON Configuration", @@ -1402,6 +1428,38 @@ } ] }, + "remote": { + "title": "Remote", + "description": "The [`remote` authorizer](https://www.ory.sh/oathkeeper/docs/pipeline/authz#remote).", + "type": "object", + "properties": { + "enabled": { + "$ref": "#/definitions/handlerSwitch" + } + }, + "oneOf": [ + { + "properties": { + "enabled": { + "const": true + }, + "config": { + "$ref": "#/definitions/configAuthorizersRemote" + } + }, + "required": [ + "config" + ] + }, + { + "properties": { + "enabled": { + "const": false + } + } + } + ] + }, "remote_json": { "title": "Remote JSON", "description": "The [`remote_json` authorizer](https://www.ory.sh/oathkeeper/docs/pipeline/authz#remote_json).", diff --git a/.schema/pipeline/authorizers.remote.schema.json b/.schema/pipeline/authorizers.remote.schema.json new file mode 100644 index 0000000000..14d779d0ec --- /dev/null +++ b/.schema/pipeline/authorizers.remote.schema.json @@ -0,0 +1,5 @@ +{ + "$id": "/.schema/authorizers.remote.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "/.schema/config.schema.json#/definitions/configAuthorizersRemote" +} diff --git a/docs/docs/pipeline/authz.md b/docs/docs/pipeline/authz.md index d0c337b300..75a04d2748 100644 --- a/docs/docs/pipeline/authz.md +++ b/docs/docs/pipeline/authz.md @@ -274,6 +274,87 @@ $ cat ./rules.json }] ``` +## `remote` + +This authorizer performs authorization using a remote authorizer. The authorizer +makes a HTTP POST request to a remote endpoint with the original body request as +body. If the endpoint returns a "200 OK" response code, the access is allowed, +if it returns a "403 Forbidden" response code, the access is denied. + +### Configuration + +- `remote` (string, required) - The remote authorizer's URL. The remote + authorizer is expected to return either "200 OK" or "403 Forbidden" to + allow/deny access. +- `headers` (map of strings, optional) - The HTTP headers sent to the remote + authorizer. The values will be parsed by the Go + [`text/template`](https://golang.org/pkg/text/template/) package and applied + to an + [`AuthenticationSession`](https://github.com/ory/oathkeeper/blob/master/pipeline/authn/authenticator.go#L40) + object. See [Session](index.md#session) for more details. + +#### Example + +```yaml +# Global configuration file oathkeeper.yml +authorizers: + remote: + # Set enabled to "true" to enable the authenticator, and "false" to disable the authenticator. Defaults to "false". + enabled: true + + config: + remote: http://my-remote-authorizer/authorize + headers: + X-Subject: "{{ print .Subject }}" +``` + +```yaml +# Some Access Rule: access-rule-1.yaml +id: access-rule-1 +# match: ... +# upstream: ... +authorizers: + - handler: remote + config: + remote: http://my-remote-authorizer/authorize + headers: + X-Subject: "{{ print .Subject }}" +``` + +### Access Rule Example + +```shell +{ + "id": "some-id", + "upstream": { + "url": "http://my-backend-service" + }, + "match": { + "url": "http://my-app/api/<.*>", + "methods": ["GET"] + }, + "authenticators": [ + { + "handler": "anonymous" + } + ], + "authorizer": { + "handler": "remote", + "config": { + "remote": "http://my-remote-authorizer/authorize", + "headers": { + "X-Subject": "{{ print .Subject }}" + } + } + } + "mutators": [ + { + "handler": "noop" + } + ] +} +``` + ## `remote_json` This authorizer performs authorization using a remote authorizer. The authorizer diff --git a/driver/configuration/provider_viper.go b/driver/configuration/provider_viper.go index 7ea1e74386..7ec1d664ce 100644 --- a/driver/configuration/provider_viper.go +++ b/driver/configuration/provider_viper.go @@ -57,6 +57,8 @@ const ( ViperKeyAuthorizerKetoEngineACPORYIsEnabled = "authorizers.keto_engine_acp_ory.enabled" + ViperKeyAuthorizerRemoteIsEnabled = "authorizers.remote.enabled" + ViperKeyAuthorizerRemoteJSONIsEnabled = "authorizers.remote_json.enabled" ) diff --git a/driver/registry_memory.go b/driver/registry_memory.go index 75d47973c0..238ab4155b 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.NewAuthorizerRemote(r.c), authz.NewAuthorizerRemoteJSON(r.c), } diff --git a/driver/registry_memory_test.go b/driver/registry_memory_test.go index 7396e11178..5f43b7621d 100644 --- a/driver/registry_memory_test.go +++ b/driver/registry_memory_test.go @@ -9,7 +9,7 @@ import ( func TestRegistryMemoryAvailablePipelineAuthorizers(t *testing.T) { r := NewRegistryMemory() got := r.AvailablePipelineAuthorizers() - assert.ElementsMatch(t, got, []string{"allow", "deny", "keto_engine_acp_ory", "remote_json"}) + assert.ElementsMatch(t, got, []string{"allow", "deny", "keto_engine_acp_ory", "remote", "remote_json"}) } func TestRegistryMemoryPipelineAuthorizer(t *testing.T) { @@ -20,6 +20,7 @@ func TestRegistryMemoryPipelineAuthorizer(t *testing.T) { {id: "allow"}, {id: "deny"}, {id: "keto_engine_acp_ory"}, + {id: "remote"}, {id: "remote_json"}, {id: "unregistered", wantErr: true}, } diff --git a/internal/config/.oathkeeper.yaml b/internal/config/.oathkeeper.yaml index 0f4e7af04a..3bf0f0af14 100644 --- a/internal/config/.oathkeeper.yaml +++ b/internal/config/.oathkeeper.yaml @@ -241,6 +241,15 @@ authorizers: required_action: unknown required_resource: unknown + # Configures the remote authorizer + remote: + # 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 + headers: {} + # 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. diff --git a/pipeline/authz/remote.go b/pipeline/authz/remote.go new file mode 100644 index 0000000000..89cafe6d7e --- /dev/null +++ b/pipeline/authz/remote.go @@ -0,0 +1,129 @@ +package authz + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "text/template" + + "github.com/pkg/errors" + + "github.com/ory/x/httpx" + + "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" +) + +// AuthorizerRemoteConfiguration represents a configuration for the remote authorizer. +type AuthorizerRemoteConfiguration struct { + Remote string `json:"remote"` + Headers map[string]string `json:"headers"` +} + +// AuthorizerRemote implements the Authorizer interface. +type AuthorizerRemote struct { + c configuration.Provider + + client *http.Client + t *template.Template +} + +// NewAuthorizerRemote creates a new AuthorizerRemote. +func NewAuthorizerRemote(c configuration.Provider) *AuthorizerRemote { + return &AuthorizerRemote{ + c: c, + client: httpx.NewResilientClientLatencyToleranceSmall(nil), + t: x.NewTemplate("remote"), + } +} + +// GetID implements the Authorizer interface. +func (a *AuthorizerRemote) GetID() string { + return "remote" +} + +// Authorize implements the Authorizer interface. +func (a *AuthorizerRemote) Authorize(r *http.Request, session *authn.AuthenticationSession, config json.RawMessage, rl pipeline.Rule) error { + c, err := a.Config(config) + if err != nil { + return err + } + + var body bytes.Buffer + err = pipeRequestBody(r, &body) + if err != nil { + return errors.Wrapf(err, `could not pipe request body in rule "%s"`, rl.GetID()) + } + + req, err := http.NewRequest("POST", c.Remote, ioutil.NopCloser(&body)) + if err != nil { + return errors.WithStack(err) + } + req.Header.Add("Content-Type", r.Header.Get("Content-Type")) + + for hdr, templateString := range c.Headers { + var tmpl *template.Template + var err error + + templateId := fmt.Sprintf("%s:%s", rl.GetID(), hdr) + tmpl = a.t.Lookup(templateId) + if tmpl == nil { + tmpl, err = a.t.New(templateId).Parse(templateString) + if err != nil { + return errors.Wrapf(err, `error parsing headers template "%s" in rule "%s"`, templateString, rl.GetID()) + } + } + + headerValue := bytes.Buffer{} + err = tmpl.Execute(&headerValue, session) + if err != nil { + return errors.Wrapf(err, `error executing headers template "%s" in rule "%s"`, templateString, rl.GetID()) + } + // Don't send empty headers + if headerValue.String() == "" { + continue + } + + req.Header.Set(hdr, headerValue.String()) + } + + 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 *AuthorizerRemote) 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 *AuthorizerRemote) Config(config json.RawMessage) (*AuthorizerRemoteConfiguration, error) { + var c AuthorizerRemoteConfiguration + 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_test.go b/pipeline/authz/remote_test.go new file mode 100644 index 0000000000..13a2c31de7 --- /dev/null +++ b/pipeline/authz/remote_test.go @@ -0,0 +1,193 @@ +package authz_test + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/sjson" + + "github.com/ory/viper" + + "github.com/ory/oathkeeper/driver/configuration" + "github.com/ory/oathkeeper/pipeline/authn" + . "github.com/ory/oathkeeper/pipeline/authz" + "github.com/ory/oathkeeper/rule" +) + +func TestAuthorizerRemoteAuthorize(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) *httptest.Server + session *authn.AuthenticationSession + body string + 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",}`), + wantErr: true, + }, + { + name: "invalid json", + session: &authn.AuthenticationSession{}, + config: json.RawMessage(`{"remote":"http://host/path","headers":"{"}`), + 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(`{}`), + 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(`{}`), + 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"], "text/plain") + body, err := ioutil.ReadAll(r.Body) + require.NoError(t, err) + assert.Equal(t, "testtest", string(body)) + w.WriteHeader(http.StatusOK) + })) + }, + body: "testtest", + config: json.RawMessage(`{}`), + }, + { + name: "ok with large body", + 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, bytes.Repeat([]byte("1"), 1024*1024*50), body) + w.WriteHeader(http.StatusOK) + })) + }, + body: strings.Repeat("1", 1024*1024*50), + config: json.RawMessage(`{}`), + }, + { + name: "authentication session", + setup: func(t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Contains(t, r.Header, "Subject") + assert.Contains(t, r.Header["Subject"], "alice") + w.WriteHeader(http.StatusOK) + })) + }, + session: &authn.AuthenticationSession{ + Subject: "alice", + Extra: map[string]interface{}{"foo": "bar"}, + MatchContext: authn.MatchContext{ + RegexpCaptureGroups: []string{"baz"}, + }, + }, + config: json.RawMessage(`{"headers":{"Subject": "{{ .Subject }}"}}`), + }, + } + 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 := NewAuthorizerRemote(p) + r := &http.Request{ + Header: map[string][]string{ + "Content-Type": {"text/plain"}, + "User-Agent": {"Fancy Browser 5.1"}, + }, + } + if tt.body != "" { + r.Body = ioutil.NopCloser(strings.NewReader(tt.body)) + } + if err := a.Authorize(r, tt.session, tt.config, &rule.Rule{}); (err != nil) != tt.wantErr { + t.Errorf("Authorize() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestAuthorizerRemoteValidate(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 remote", + enabled: true, + config: json.RawMessage(`{}`), + wantErr: true, + }, + { + name: "invalid url", + enabled: true, + config: json.RawMessage(`{"remote":"invalid-url",}`), + wantErr: true, + }, + { + name: "valid configuration", + enabled: true, + config: json.RawMessage(`{"remote":"http://host/path"}`), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := configuration.NewViperProvider(logrus.New()) + a := NewAuthorizerRemote(p) + viper.Set(configuration.ViperKeyAuthorizerRemoteIsEnabled, tt.enabled) + if err := a.Validate(tt.config); (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/pipeline/authz/utils.go b/pipeline/authz/utils.go new file mode 100644 index 0000000000..9c1f866c3e --- /dev/null +++ b/pipeline/authz/utils.go @@ -0,0 +1,19 @@ +package authz + +import ( + "bytes" + "io" + "io/ioutil" + "net/http" +) + +func pipeRequestBody(r *http.Request, w io.Writer) error { + if r.Body == nil { + return nil + } + + var body bytes.Buffer + _, err := io.Copy(w, io.TeeReader(r.Body, &body)) + r.Body = ioutil.NopCloser(&body) + return err +}