From 2d04106e6d26260ad15264d631b8506e1c1e1e8c Mon Sep 17 00:00:00 2001 From: Caleb Doxsey Date: Fri, 10 Dec 2021 07:28:51 -0700 Subject: [PATCH] ppl: add support for http_path and http_method (#2813) * ppl: add support for http_path and http_method * fix import ordering --- authorize/evaluator/evaluator.go | 13 ++++++ authorize/evaluator/evaluator_test.go | 57 +++++++++++++++++++++++++ authorize/grpc.go | 12 +++--- authorize/grpc_test.go | 32 ++++++++------ docs/docs/topics/ppl.md | 2 + docs/enterprise/console-settings.yaml | 2 + docs/enterprise/reference/manage.md | 2 + pkg/policy/criteria/criteria_test.go | 1 + pkg/policy/criteria/http_method.go | 43 +++++++++++++++++++ pkg/policy/criteria/http_method_test.go | 32 ++++++++++++++ pkg/policy/criteria/http_path.go | 43 +++++++++++++++++++ pkg/policy/criteria/http_path_test.go | 32 ++++++++++++++ pkg/policy/criteria/reasons.go | 4 ++ 13 files changed, 257 insertions(+), 18 deletions(-) create mode 100644 pkg/policy/criteria/http_method.go create mode 100644 pkg/policy/criteria/http_method_test.go create mode 100644 pkg/policy/criteria/http_path.go create mode 100644 pkg/policy/criteria/http_path_test.go diff --git a/authorize/evaluator/evaluator.go b/authorize/evaluator/evaluator.go index d08720d7e..43b96dcf8 100644 --- a/authorize/evaluator/evaluator.go +++ b/authorize/evaluator/evaluator.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "fmt" "net/http" + "net/url" "github.com/go-jose/go-jose/v3" "github.com/open-policy-agent/opa/rego" @@ -35,11 +36,23 @@ type Request struct { // RequestHTTP is the HTTP field in the request. type RequestHTTP struct { Method string `json:"method"` + Path string `json:"path"` URL string `json:"url"` Headers map[string]string `json:"headers"` ClientCertificate string `json:"client_certificate"` } +// NewRequestHTTP creates a new RequestHTTP. +func NewRequestHTTP(method string, requestURL url.URL, headers map[string]string, rawClientCertificate string) RequestHTTP { + return RequestHTTP{ + Method: method, + Path: requestURL.Path, + URL: requestURL.String(), + Headers: headers, + ClientCertificate: rawClientCertificate, + } +} + // RequestSession is the session field in the request. type RequestSession struct { ID string `json:"id"` diff --git a/authorize/evaluator/evaluator_test.go b/authorize/evaluator/evaluator_test.go index 7019b6688..873ceefd0 100644 --- a/authorize/evaluator/evaluator_test.go +++ b/authorize/evaluator/evaluator_test.go @@ -22,6 +22,7 @@ import ( "github.com/pomerium/pomerium/pkg/grpc/session" "github.com/pomerium/pomerium/pkg/grpc/user" "github.com/pomerium/pomerium/pkg/policy/criteria" + "github.com/pomerium/pomerium/pkg/policy/parser" "github.com/pomerium/pomerium/pkg/protoutil" ) @@ -86,6 +87,36 @@ func TestEvaluator(t *testing.T) { To: config.WeightedURLs{{URL: *mustParseURL("https://to9.example.com")}}, AllowAnyAuthenticatedUser: true, }, + { + To: config.WeightedURLs{{URL: *mustParseURL("https://to10.example.com")}}, + Policy: &config.PPLPolicy{ + Policy: &parser.Policy{ + Rules: []parser.Rule{{ + Action: parser.ActionAllow, + Or: []parser.Criterion{{ + Name: "http_method", Data: parser.Object{ + "is": parser.String("GET"), + }, + }}, + }}, + }, + }, + }, + { + To: config.WeightedURLs{{URL: *mustParseURL("https://to11.example.com")}}, + Policy: &config.PPLPolicy{ + Policy: &parser.Policy{ + Rules: []parser.Rule{{ + Action: parser.ActionAllow, + Or: []parser.Criterion{{ + Name: "http_path", Data: parser.Object{ + "is": parser.String("/test"), + }, + }}, + }}, + }, + }, + }, } options := []Option{ WithAuthenticateURL("https://authn.example.com"), @@ -442,6 +473,32 @@ func TestEvaluator(t *testing.T) { } } }) + t.Run("http method", func(t *testing.T) { + res, err := eval(t, options, []proto.Message{}, &Request{ + Policy: &policies[9], + HTTP: NewRequestHTTP( + "GET", + *mustParseURL("https://from.example.com/"), + nil, + testValidCert, + ), + }) + require.NoError(t, err) + assert.True(t, res.Allow.Value) + }) + t.Run("http path", func(t *testing.T) { + res, err := eval(t, options, []proto.Message{}, &Request{ + Policy: &policies[10], + HTTP: NewRequestHTTP( + "POST", + *mustParseURL("https://from.example.com/test"), + nil, + testValidCert, + ), + }) + require.NoError(t, err) + assert.True(t, res.Allow.Value) + }) } func mustParseURL(str string) *url.URL { diff --git a/authorize/grpc.go b/authorize/grpc.go index c80543e3e..e30ced33a 100644 --- a/authorize/grpc.go +++ b/authorize/grpc.go @@ -124,12 +124,12 @@ func (a *Authorize) getEvaluatorRequestFromCheckRequest( ) (*evaluator.Request, error) { requestURL := getCheckRequestURL(in) req := &evaluator.Request{ - HTTP: evaluator.RequestHTTP{ - Method: in.GetAttributes().GetRequest().GetHttp().GetMethod(), - URL: requestURL.String(), - Headers: getCheckRequestHeaders(in), - ClientCertificate: getPeerCertificate(in), - }, + HTTP: evaluator.NewRequestHTTP( + in.GetAttributes().GetRequest().GetHttp().GetMethod(), + requestURL, + getCheckRequestHeaders(in), + getPeerCertificate(in), + ), } if sessionState != nil { req.Session = evaluator.RequestSession{ diff --git a/authorize/grpc_test.go b/authorize/grpc_test.go index b31cd22c4..c22d37a08 100644 --- a/authorize/grpc_test.go +++ b/authorize/grpc_test.go @@ -90,15 +90,15 @@ func Test_getEvaluatorRequest(t *testing.T) { Session: evaluator.RequestSession{ ID: "SESSION_ID", }, - HTTP: evaluator.RequestHTTP{ - Method: "GET", - URL: "http://example.com/some/path?qs=1", - Headers: map[string]string{ + HTTP: evaluator.NewRequestHTTP( + "GET", + mustParseURL("http://example.com/some/path?qs=1"), + map[string]string{ "Accept": "text/html", "X-Forwarded-Proto": "https", }, - ClientCertificate: certPEM, - }, + certPEM, + ), } assert.Equal(t, expect, actual) } @@ -296,15 +296,15 @@ func Test_getEvaluatorRequestWithPortInHostHeader(t *testing.T) { expect := &evaluator.Request{ Policy: &a.currentOptions.Load().Policies[0], Session: evaluator.RequestSession{}, - HTTP: evaluator.RequestHTTP{ - Method: "GET", - URL: "http://example.com/some/path?qs=1", - Headers: map[string]string{ + HTTP: evaluator.NewRequestHTTP( + "GET", + mustParseURL("http://example.com/some/path?qs=1"), + map[string]string{ "Accept": "text/html", "X-Forwarded-Proto": "https", }, - ClientCertificate: certPEM, - }, + certPEM, + ), } assert.Equal(t, expect, actual) } @@ -414,3 +414,11 @@ func TestAuthorize_Check(t *testing.T) { }) } } + +func mustParseURL(rawURL string) url.URL { + u, err := url.Parse(rawURL) + if err != nil { + panic(err) + } + return *u +} diff --git a/docs/docs/topics/ppl.md b/docs/docs/topics/ppl.md index 3633f0d3d..cd3ec5406 100644 --- a/docs/docs/topics/ppl.md +++ b/docs/docs/topics/ppl.md @@ -83,6 +83,8 @@ PPL supports many different criteria: | `domain` | String Matcher | Returns true if the logged-in user's email address domain (the part after `@`) matches the given value. | | `email` | String Matcher | Returns true if the logged-in user's email address matches the given value. | | `groups` | List Matcher | Returns true if the logged-in user is a member of the given group. | +| `http_method` | String Matcher | Returns true if the HTTP method matches the given value. | +| `http_path` | String Matcher | Returns true if the HTTP path matches the given value. | | `invalid_client_certificate` | Anything. Typically `true`. | Returns true if the incoming request has an invalid client certificate. A default `deny` rule using this criterion is added to all Pomerium policies when an mTLS [client certificate authority] is set. | | `pomerium_routes` | Anything. Typically `true`. | Returns true if the incoming request is for the special `.pomerium` routes. A default `allow` rule using this criterion is added to all Pomerium policies. | | `reject` | Anything. Typically `true`. | Always returns false. The opposite of `accept`. | diff --git a/docs/enterprise/console-settings.yaml b/docs/enterprise/console-settings.yaml index eb172c23b..a585d1257 100644 --- a/docs/enterprise/console-settings.yaml +++ b/docs/enterprise/console-settings.yaml @@ -142,6 +142,8 @@ settings: - `domain` - `email` - `groups` + - `http_method` + - `http_path` - `reject` - `time_of_day` - `user` diff --git a/docs/enterprise/reference/manage.md b/docs/enterprise/reference/manage.md index 840396691..b21597176 100644 --- a/docs/enterprise/reference/manage.md +++ b/docs/enterprise/reference/manage.md @@ -333,6 +333,8 @@ The available criteria types are: - `domain` - `email` - `groups` +- `http_method` +- `http_path` - `reject` - `time_of_day` - `user` diff --git a/pkg/policy/criteria/criteria_test.go b/pkg/policy/criteria/criteria_test.go index b02d3ea4e..b5e1a27c6 100644 --- a/pkg/policy/criteria/criteria_test.go +++ b/pkg/policy/criteria/criteria_test.go @@ -32,6 +32,7 @@ type ( } InputHTTP struct { Method string `json:"method"` + Path string `json:"path"` Headers map[string][]string `json:"headers"` } InputSession struct { diff --git a/pkg/policy/criteria/http_method.go b/pkg/policy/criteria/http_method.go new file mode 100644 index 000000000..59d7e07b0 --- /dev/null +++ b/pkg/policy/criteria/http_method.go @@ -0,0 +1,43 @@ +package criteria + +import ( + "github.com/open-policy-agent/opa/ast" + + "github.com/pomerium/pomerium/pkg/policy/parser" +) + +type httpMethodCriterion struct { + g *Generator +} + +func (httpMethodCriterion) DataType() CriterionDataType { + return CriterionDataTypeStringMatcher +} + +func (httpMethodCriterion) Name() string { + return "http_method" +} + +func (c httpMethodCriterion) GenerateRule(_ string, data parser.Value) (*ast.Rule, []*ast.Rule, error) { + var body ast.Body + ref := ast.RefTerm(ast.VarTerm("input"), ast.VarTerm("http"), ast.VarTerm("method")) + err := matchString(&body, ref, data) + if err != nil { + return nil, nil, err + } + + rule := NewCriterionRule(c.g, c.Name(), + ReasonHTTPMethodOK, ReasonHTTPMethodUnauthorized, + body) + + return rule, nil, nil +} + +// HTTPMethod returns a Criterion which matches an HTTP method. +func HTTPMethod(generator *Generator) Criterion { + return httpMethodCriterion{g: generator} +} + +func init() { + Register(HTTPMethod) +} diff --git a/pkg/policy/criteria/http_method_test.go b/pkg/policy/criteria/http_method_test.go new file mode 100644 index 000000000..c55415c25 --- /dev/null +++ b/pkg/policy/criteria/http_method_test.go @@ -0,0 +1,32 @@ +package criteria + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestHTTPMethod(t *testing.T) { + t.Run("ok", func(t *testing.T) { + res, err := evaluate(t, ` +allow: + and: + - http_method: + is: GET +`, []dataBrokerRecord{}, Input{HTTP: InputHTTP{Method: "GET"}}) + require.NoError(t, err) + require.Equal(t, A{true, A{ReasonHTTPMethodOK}, M{}}, res["allow"]) + require.Equal(t, A{false, A{}}, res["deny"]) + }) + t.Run("unauthorized", func(t *testing.T) { + res, err := evaluate(t, ` +allow: + and: + - http_method: + is: GET +`, []dataBrokerRecord{}, Input{HTTP: InputHTTP{Method: "POST"}}) + require.NoError(t, err) + require.Equal(t, A{false, A{ReasonHTTPMethodUnauthorized}, M{}}, res["allow"]) + require.Equal(t, A{false, A{}}, res["deny"]) + }) +} diff --git a/pkg/policy/criteria/http_path.go b/pkg/policy/criteria/http_path.go new file mode 100644 index 000000000..3598bbdda --- /dev/null +++ b/pkg/policy/criteria/http_path.go @@ -0,0 +1,43 @@ +package criteria + +import ( + "github.com/open-policy-agent/opa/ast" + + "github.com/pomerium/pomerium/pkg/policy/parser" +) + +type httpPathCriterion struct { + g *Generator +} + +func (httpPathCriterion) DataType() CriterionDataType { + return CriterionDataTypeStringMatcher +} + +func (httpPathCriterion) Name() string { + return "http_path" +} + +func (c httpPathCriterion) GenerateRule(_ string, data parser.Value) (*ast.Rule, []*ast.Rule, error) { + var body ast.Body + ref := ast.RefTerm(ast.VarTerm("input"), ast.VarTerm("http"), ast.VarTerm("path")) + err := matchString(&body, ref, data) + if err != nil { + return nil, nil, err + } + + rule := NewCriterionRule(c.g, c.Name(), + ReasonHTTPPathOK, ReasonHTTPPathUnauthorized, + body) + + return rule, nil, nil +} + +// HTTPPath returns a Criterion which matches an HTTP path. +func HTTPPath(generator *Generator) Criterion { + return httpPathCriterion{g: generator} +} + +func init() { + Register(HTTPPath) +} diff --git a/pkg/policy/criteria/http_path_test.go b/pkg/policy/criteria/http_path_test.go new file mode 100644 index 000000000..bae108a64 --- /dev/null +++ b/pkg/policy/criteria/http_path_test.go @@ -0,0 +1,32 @@ +package criteria + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestHTTPPath(t *testing.T) { + t.Run("ok", func(t *testing.T) { + res, err := evaluate(t, ` +allow: + and: + - http_path: + is: /test +`, []dataBrokerRecord{}, Input{HTTP: InputHTTP{Path: "/test"}}) + require.NoError(t, err) + require.Equal(t, A{true, A{ReasonHTTPPathOK}, M{}}, res["allow"]) + require.Equal(t, A{false, A{}}, res["deny"]) + }) + t.Run("unauthorized", func(t *testing.T) { + res, err := evaluate(t, ` +allow: + and: + - http_path: + is: /test +`, []dataBrokerRecord{}, Input{HTTP: InputHTTP{Path: "/not-test"}}) + require.NoError(t, err) + require.Equal(t, A{false, A{ReasonHTTPPathUnauthorized}, M{}}, res["allow"]) + require.Equal(t, A{false, A{}}, res["deny"]) + }) +} diff --git a/pkg/policy/criteria/reasons.go b/pkg/policy/criteria/reasons.go index 4978ff599..0ee563a41 100644 --- a/pkg/policy/criteria/reasons.go +++ b/pkg/policy/criteria/reasons.go @@ -20,6 +20,10 @@ const ( ReasonEmailUnauthorized = "email-unauthorized" ReasonGroupsOK = "groups-ok" ReasonGroupsUnauthorized = "groups-unauthorized" + ReasonHTTPMethodOK = "http-method-ok" + ReasonHTTPMethodUnauthorized = "http-method-unauthorized" + ReasonHTTPPathOK = "http-path-ok" + ReasonHTTPPathUnauthorized = "http-path-unauthorized" ReasonInvalidClientCertificate = "invalid-client-certificate" ReasonNonCORSRequest = "non-cors-request" ReasonNonPomeriumRoute = "non-pomerium-route"