diff --git a/Makefile b/Makefile index a002d74f3..6b7560083 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,6 @@ GOOS = $(shell $(GO) env GOOS) GOARCH= $(shell $(GO) env GOARCH) MISSPELL_VERSION = v0.3.4 GOLANGCI_VERSION = v1.34.1 -OPA_VERSION = v0.26.0 GETENVOY_VERSION = v0.2.0 GORELEASER_VERSION = v0.157.0 @@ -56,7 +55,6 @@ deps-lint: ## Install lint dependencies .PHONY: deps-build deps-build: ## Install build dependencies @echo "==> $@" - @$(GO) install github.com/open-policy-agent/opa@${OPA_VERSION} @$(GO) install github.com/tetratelabs/getenvoy/cmd/getenvoy@${GETENVOY_VERSION} .PHONY: deps-release @@ -107,7 +105,6 @@ lint: ## Verifies `golint` passes. test: ## Runs the go tests. @echo "==> $@" @$(GO) test -tags "$(BUILDTAGS)" $(shell $(GO) list ./... | grep -v vendor | grep -v github.com/pomerium/pomerium/integration) - @opa test ./authorize/evaluator/opa/policy .PHONY: spellcheck spellcheck: # Spellcheck docs diff --git a/authorize/authorize.go b/authorize/authorize.go index 0ac75435a..d34c3e5a1 100644 --- a/authorize/authorize.go +++ b/authorize/authorize.go @@ -88,7 +88,25 @@ func newPolicyEvaluator(opts *config.Options, store *evaluator.Store) (*evaluato ctx := context.Background() _, span := trace.StartSpan(ctx, "authorize.newPolicyEvaluator") defer span.End() - return evaluator.New(opts, store) + + clientCA, err := opts.GetClientCA() + if err != nil { + return nil, fmt.Errorf("authorize: invalid client CA: %w", err) + } + + authenticateURL, err := opts.GetAuthenticateURL() + if err != nil { + return nil, fmt.Errorf("authorize: invalid authenticate url: %w", err) + } + + return evaluator.New(ctx, store, + evaluator.WithPolicies(opts.GetAllPolicies()), + evaluator.WithClientCA(clientCA), + evaluator.WithSigningKey(opts.SigningKeyAlgorithm, opts.SigningKey), + evaluator.WithAuthenticateURL(authenticateURL.String()), + evaluator.WithGoogleCloudServerlessAuthenticationServiceAccount(opts.GetGoogleCloudServerlessAuthenticationServiceAccount()), + evaluator.WithJWTClaimsHeaders(opts.JWTClaimsHeaders), + ) } // OnConfigChange updates internal structures based on config.Options diff --git a/authorize/check_response.go b/authorize/check_response.go index d02cbfd4d..9c8efd67c 100644 --- a/authorize/check_response.go +++ b/authorize/check_response.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "net/url" "sort" + "strings" envoy_config_core_v3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" envoy_service_auth_v3 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3" @@ -25,15 +26,15 @@ import ( func (a *Authorize) okResponse(reply *evaluator.Result) *envoy_service_auth_v3.CheckResponse { var requestHeaders []*envoy_config_core_v3.HeaderValueOption - for k, v := range reply.Headers { - requestHeaders = append(requestHeaders, mkHeader(k, v, false)) + for k, vs := range reply.Headers { + requestHeaders = append(requestHeaders, mkHeader(k, strings.Join(vs, ","), false)) } // ensure request headers are sorted by key for deterministic output sort.Slice(requestHeaders, func(i, j int) bool { return requestHeaders[i].Header.Key < requestHeaders[j].Header.Value }) return &envoy_service_auth_v3.CheckResponse{ - Status: &status.Status{Code: int32(codes.OK), Message: reply.Message}, + Status: &status.Status{Code: int32(codes.OK), Message: "OK"}, HttpResponse: &envoy_service_auth_v3.CheckResponse_OkResponse{ OkResponse: &envoy_service_auth_v3.OkHttpResponse{ Headers: requestHeaders, @@ -47,7 +48,6 @@ func (a *Authorize) deniedResponse( in *envoy_service_auth_v3.CheckRequest, code int32, reason string, headers map[string]string, ) (*envoy_service_auth_v3.CheckResponse, error) { - var details string switch code { case httputil.StatusInvalidClientCertificate: diff --git a/authorize/check_response_test.go b/authorize/check_response_test.go index 865d2ab67..a41754811 100644 --- a/authorize/check_response_test.go +++ b/authorize/check_response_test.go @@ -30,6 +30,7 @@ func TestAuthorize_okResponse(t *testing.T) { AuthenticateURLString: "https://authenticate.example.com", Policies: []config.Policy{{ Source: &config.StringURL{URL: &url.URL{Host: "example.com"}}, + To: mustParseWeightedURLs(t, "https://to.example.com"), SubPolicies: []config.SubPolicy{{ Rego: []string{"allow = true"}, }}, @@ -62,45 +63,30 @@ func TestAuthorize_okResponse(t *testing.T) { }{ { "ok reply", - &evaluator.Result{Status: 0, Message: "ok"}, + &evaluator.Result{Allow: true}, &envoy_service_auth_v3.CheckResponse{ - Status: &status.Status{Code: 0, Message: "ok"}, + Status: &status.Status{Code: 0, Message: "OK"}, }, }, { "ok reply with k8s svc", - &evaluator.Result{ - Status: 0, - Message: "ok", - MatchingPolicy: &config.Policy{ - KubernetesServiceAccountToken: "k8s-svc-account", - }, - }, + &evaluator.Result{Allow: true}, &envoy_service_auth_v3.CheckResponse{ - Status: &status.Status{Code: 0, Message: "ok"}, + Status: &status.Status{Code: 0, Message: "OK"}, }, }, { "ok reply with k8s svc impersonate", - &evaluator.Result{ - Status: 0, - Message: "ok", - MatchingPolicy: &config.Policy{ - KubernetesServiceAccountToken: "k8s-svc-account", - }, - }, + &evaluator.Result{Allow: true}, &envoy_service_auth_v3.CheckResponse{ - Status: &status.Status{Code: 0, Message: "ok"}, + Status: &status.Status{Code: 0, Message: "OK"}, }, }, { "ok reply with jwt claims header", - &evaluator.Result{ - Status: 0, - Message: "ok", - }, + &evaluator.Result{Allow: true}, &envoy_service_auth_v3.CheckResponse{ - Status: &status.Status{Code: 0, Message: "ok"}, + Status: &status.Status{Code: 0, Message: "OK"}, }, }, } diff --git a/authorize/evaluator/config.go b/authorize/evaluator/config.go new file mode 100644 index 000000000..615dbd805 --- /dev/null +++ b/authorize/evaluator/config.go @@ -0,0 +1,70 @@ +package evaluator + +import ( + "github.com/pomerium/pomerium/config" +) + +type evaluatorConfig struct { + policies []config.Policy + clientCA []byte + signingKey string + signingKeyAlgorithm string + authenticateURL string + googleCloudServerlessAuthenticationServiceAccount string + jwtClaimsHeaders config.JWTClaimHeaders +} + +// An Option customizes the evaluator config. +type Option func(*evaluatorConfig) + +func getConfig(options ...Option) *evaluatorConfig { + cfg := new(evaluatorConfig) + for _, o := range options { + o(cfg) + } + return cfg +} + +// WithPolicies sets the policies in the config. +func WithPolicies(policies []config.Policy) Option { + return func(cfg *evaluatorConfig) { + cfg.policies = policies + } +} + +// WithClientCA sets the client CA in the config. +func WithClientCA(clientCA []byte) Option { + return func(cfg *evaluatorConfig) { + cfg.clientCA = clientCA + } +} + +// WithSigningKey sets the signing key and algorithm in the config. +func WithSigningKey(signingKeyAlgorithm, signingKey string) Option { + return func(cfg *evaluatorConfig) { + cfg.signingKeyAlgorithm = signingKeyAlgorithm + cfg.signingKey = signingKey + } +} + +// WithAuthenticateURL sets the authenticate URL in the config. +func WithAuthenticateURL(authenticateURL string) Option { + return func(cfg *evaluatorConfig) { + cfg.authenticateURL = authenticateURL + } +} + +// WithGoogleCloudServerlessAuthenticationServiceAccount sets the google cloud serverless authentication service +// account in the config. +func WithGoogleCloudServerlessAuthenticationServiceAccount(serviceAccount string) Option { + return func(cfg *evaluatorConfig) { + cfg.googleCloudServerlessAuthenticationServiceAccount = serviceAccount + } +} + +// WithJWTClaimsHeaders sets the JWT claims headers in the config. +func WithJWTClaimsHeaders(headers config.JWTClaimHeaders) Option { + return func(cfg *evaluatorConfig) { + cfg.jwtClaimsHeaders = headers + } +} diff --git a/authorize/evaluator/custom.go b/authorize/evaluator/custom.go deleted file mode 100644 index f8688c8ec..000000000 --- a/authorize/evaluator/custom.go +++ /dev/null @@ -1,124 +0,0 @@ -package evaluator - -import ( - "context" - "fmt" - "strings" - "sync" - - "github.com/open-policy-agent/opa/rego" - "github.com/open-policy-agent/opa/storage" - - "github.com/pomerium/pomerium/internal/telemetry/trace" -) - -// A CustomEvaluatorRequest is the data needed to evaluate a custom rego policy. -type CustomEvaluatorRequest struct { - RegoPolicy string - HTTP RequestHTTP `json:"http"` - Session RequestSession `json:"session"` -} - -// A CustomEvaluatorResponse is the response from the evaluation of a custom rego policy. -type CustomEvaluatorResponse struct { - Allowed bool - Denied bool - Reason string - Headers map[string]string -} - -// A CustomEvaluator evaluates custom rego policies. -type CustomEvaluator struct { - store storage.Store - mu sync.Mutex - queries map[string]rego.PreparedEvalQuery -} - -// NewCustomEvaluator creates a new CustomEvaluator. -func NewCustomEvaluator(store storage.Store) *CustomEvaluator { - ce := &CustomEvaluator{ - store: store, - queries: map[string]rego.PreparedEvalQuery{}, - } - return ce -} - -// Evaluate evaluates the custom rego policy. -func (ce *CustomEvaluator) Evaluate(ctx context.Context, req *CustomEvaluatorRequest) (*CustomEvaluatorResponse, error) { - _, span := trace.StartSpan(ctx, "authorize.evaluator.custom.Evaluate") - defer span.End() - - q, err := ce.getPreparedEvalQuery(ctx, req.RegoPolicy) - if err != nil { - return nil, err - } - - resultSet, err := q.Eval(ctx, rego.EvalInput(struct { - HTTP RequestHTTP `json:"http"` - Session RequestSession `json:"session"` - }{HTTP: req.HTTP, Session: req.Session})) - if err != nil { - return nil, err - } - - vars, ok := resultSet[0].Bindings.WithoutWildcards()["result"].(map[string]interface{}) - if !ok { - vars = make(map[string]interface{}) - } - - res := &CustomEvaluatorResponse{ - Headers: getHeadersVar(resultSet[0].Bindings.WithoutWildcards()), - } - res.Allowed, _ = vars["allow"].(bool) - if v, ok := vars["deny"]; ok { - // support `deny = true` - if b, ok := v.(bool); ok { - res.Denied = b - } - - // support `deny[reason] = true` - if m, ok := v.(map[string]interface{}); ok { - for mk, mv := range m { - if b, ok := mv.(bool); ok { - res.Denied = b - res.Reason = mk - } - } - } - } - return res, nil -} - -func (ce *CustomEvaluator) getPreparedEvalQuery(ctx context.Context, src string) (rego.PreparedEvalQuery, error) { - ce.mu.Lock() - defer ce.mu.Unlock() - - q, ok := ce.queries[src] - if ok { - return q, nil - } - - r := rego.New( - rego.Store(ce.store), - rego.Module("pomerium.custom_policy", src), - rego.Query("result = data.pomerium.custom_policy"), - ) - q, err := r.PrepareForEval(ctx) - if err != nil { - // if no package is in the src, add it - if strings.Contains(err.Error(), "package expected") { - r = rego.New( - rego.Store(ce.store), - rego.Module("pomerium.custom_policy", "package pomerium.custom_policy\n\n"+src), - rego.Query("result = data.pomerium.custom_policy"), - ) - q, err = r.PrepareForEval(ctx) - } - } - if err != nil { - return q, fmt.Errorf("invalid rego policy: %w", err) - } - - ce.queries[src] = q - return q, nil -} diff --git a/authorize/evaluator/custom_test.go b/authorize/evaluator/custom_test.go deleted file mode 100644 index d915b98cb..000000000 --- a/authorize/evaluator/custom_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package evaluator - -import ( - "context" - "testing" - "time" - - "github.com/stretchr/testify/assert" -) - -func TestCustomEvaluator(t *testing.T) { - ctx, clearTimeout := context.WithTimeout(context.Background(), time.Second*10) - defer clearTimeout() - - store := NewStore() - t.Run("bool deny", func(t *testing.T) { - ce := NewCustomEvaluator(store) - res, err := ce.Evaluate(ctx, &CustomEvaluatorRequest{ - RegoPolicy: ` - package pomerium.custom_policy - - deny = true - `, - }) - if !assert.NoError(t, err) { - return - } - assert.Equal(t, true, res.Denied) - assert.Empty(t, res.Reason) - }) - t.Run("set deny", func(t *testing.T) { - ce := NewCustomEvaluator(store) - res, err := ce.Evaluate(ctx, &CustomEvaluatorRequest{ - RegoPolicy: ` - package pomerium.custom_policy - - deny["test"] = true - `, - }) - if !assert.NoError(t, err) { - return - } - assert.Equal(t, true, res.Denied) - assert.Equal(t, "test", res.Reason) - }) - t.Run("missing package", func(t *testing.T) { - ce := NewCustomEvaluator(store) - res, err := ce.Evaluate(ctx, &CustomEvaluatorRequest{ - RegoPolicy: `allow = true`, - }) - if !assert.NoError(t, err) { - return - } - assert.NotNil(t, res) - }) -} diff --git a/authorize/evaluator/evaluator.go b/authorize/evaluator/evaluator.go index 03ce65243..47c280721 100644 --- a/authorize/evaluator/evaluator.go +++ b/authorize/evaluator/evaluator.go @@ -1,5 +1,4 @@ -// Package evaluator defines a Evaluator interfaces that can be implemented by -// a policy evaluator framework. +// Package evaluator contains rego evaluators for evaluating authorize policy. package evaluator import ( @@ -13,133 +12,180 @@ import ( "github.com/pomerium/pomerium/config" "github.com/pomerium/pomerium/internal/log" + "github.com/pomerium/pomerium/internal/urlutil" "github.com/pomerium/pomerium/pkg/cryptutil" ) -// Evaluator specifies the interface for a policy engine. +// notFoundOutput is what's returned if a route isn't found for a policy. +var notFoundOutput = &Result{ + Allow: false, + Deny: &Denial{ + Status: http.StatusNotFound, + Message: "route not found", + }, + Headers: make(http.Header), +} + +// Request contains the inputs needed for evaluation. +type Request struct { + Policy *config.Policy + HTTP RequestHTTP + Session RequestSession +} + +// RequestHTTP is the HTTP field in the request. +type RequestHTTP struct { + Method string `json:"method"` + URL string `json:"url"` + Headers map[string]string `json:"headers"` + ClientCertificate string `json:"client_certificate"` +} + +// RequestSession is the session field in the request. +type RequestSession struct { + ID string `json:"id"` +} + +// Result is the result of evaluation. +type Result struct { + Allow bool + Deny *Denial + Headers http.Header + + DataBrokerServerVersion, DataBrokerRecordVersion uint64 +} + +// An Evaluator evaluates policies. type Evaluator struct { - custom *CustomEvaluator - rego *rego.Rego - query rego.PreparedEvalQuery - policies []config.Policy - store *Store + store *Store + policyEvaluators map[uint64]*PolicyEvaluator + headersEvaluators *HeadersEvaluator + clientCA []byte } // New creates a new Evaluator. -func New(options *config.Options, store *Store) (*Evaluator, error) { - e := &Evaluator{ - custom: NewCustomEvaluator(store), - policies: options.GetAllPolicies(), - store: store, - } - jwk, err := getJWK(options) +func New(ctx context.Context, store *Store, options ...Option) (*Evaluator, error) { + e := &Evaluator{store: store} + + cfg := getConfig(options...) + + err := e.updateStore(cfg) if err != nil { - return nil, fmt.Errorf("authorize: couldn't create signer: %w", err) + return nil, err } - authzPolicy, err := readPolicy() + e.headersEvaluators, err = NewHeadersEvaluator(ctx, store) if err != nil { - return nil, fmt.Errorf("error loading rego policy: %w", err) + return nil, err } - authenticateURL, err := options.GetAuthenticateURL() - if err != nil { - return nil, fmt.Errorf("authorize: invalid authenticate URL: %w", err) + e.policyEvaluators = make(map[uint64]*PolicyEvaluator) + for _, configPolicy := range cfg.policies { + id, err := configPolicy.RouteID() + if err != nil { + return nil, fmt.Errorf("authorize: error computing policy route id: %w", err) + } + policyEvaluator, err := NewPolicyEvaluator(ctx, store, &configPolicy) //nolint + if err != nil { + return nil, err + } + e.policyEvaluators[id] = policyEvaluator } - store.UpdateIssuer(authenticateURL.Host) - store.UpdateGoogleCloudServerlessAuthenticationServiceAccount( - options.GetGoogleCloudServerlessAuthenticationServiceAccount(), - ) - store.UpdateJWTClaimHeaders(options.JWTClaimsHeaders) - store.UpdateRoutePolicies(options.GetAllPolicies()) - store.UpdateSigningKey(jwk) - - e.rego = rego.New( - rego.Store(store), - rego.Module("pomerium.authz", string(authzPolicy)), - rego.Query("result = data.pomerium.authz"), - getGoogleCloudServerlessHeadersRegoOption, - store.GetDataBrokerRecordOption(), - ) - - e.query, err = e.rego.PrepareForEval(context.Background()) - if err != nil { - return nil, fmt.Errorf("error preparing rego query: %w", err) - } + e.clientCA = cfg.clientCA return e, nil } -// Evaluate evaluates the policy against the request. +// Evaluate evaluates the rego for the given policy and generates the identity headers. func (e *Evaluator) Evaluate(ctx context.Context, req *Request) (*Result, error) { - isValid, err := isValidClientCertificate(req.ClientCA, req.HTTP.ClientCertificate) + if req.Policy == nil { + return notFoundOutput, nil + } + + id, err := req.Policy.RouteID() if err != nil { - return nil, fmt.Errorf("error validating client certificate: %w", err) + return nil, fmt.Errorf("authorize: error computing policy route id: %w", err) } - res, err := e.query.Eval(ctx, rego.EvalInput(e.newInput(req, isValid))) + policyEvaluator, ok := e.policyEvaluators[id] + if !ok { + return notFoundOutput, nil + } + + clientCA, err := e.getClientCA(req.Policy) if err != nil { - return nil, fmt.Errorf("error evaluating rego policy: %w", err) + return nil, err } - deny := getDenyVar(res[0].Bindings.WithoutWildcards()) - if len(deny) > 0 { - return &deny[0], nil + isValidClientCertificate, err := isValidClientCertificate(clientCA, req.HTTP.ClientCertificate) + if err != nil { + return nil, fmt.Errorf("authorize: error validating client certificate: %w", err) } - evalResult := &Result{ - MatchingPolicy: getMatchingPolicy(res[0].Bindings.WithoutWildcards(), e.policies), - Headers: getHeadersVar(res[0].Bindings.WithoutWildcards()), - } - evalResult.DataBrokerServerVersion, evalResult.DataBrokerRecordVersion = getDataBrokerVersions( - res[0].Bindings, - ) - - allow := getAllowVar(res[0].Bindings.WithoutWildcards()) - // evaluate any custom policies - if allow { - for _, src := range req.CustomPolicies { - cres, err := e.custom.Evaluate(ctx, &CustomEvaluatorRequest{ - RegoPolicy: src, - HTTP: req.HTTP, - Session: req.Session, - }) - if err != nil { - return nil, err - } - allow = allow && (cres.Allowed && !cres.Denied) - if cres.Reason != "" { - evalResult.Message = cres.Reason - } - for k, v := range cres.Headers { - evalResult.Headers[k] = v - } - } - } - if allow { - evalResult.Status = http.StatusOK - evalResult.Message = http.StatusText(http.StatusOK) - return evalResult, nil + policyOutput, err := policyEvaluator.Evaluate(ctx, &PolicyRequest{ + HTTP: req.HTTP, + Session: req.Session, + IsValidClientCertificate: isValidClientCertificate, + }) + if err != nil { + return nil, err } - if req.Session.ID == "" { - evalResult.Status = http.StatusUnauthorized - evalResult.Message = "login required" - return evalResult, nil + headersReq := NewHeadersRequestFromPolicy(req.Policy) + headersReq.Session = req.Session + headersOutput, err := e.headersEvaluators.Evaluate(ctx, headersReq) + if err != nil { + return nil, err } - evalResult.Status = http.StatusForbidden - if evalResult.Message == "" { - evalResult.Message = http.StatusText(http.StatusForbidden) + res := &Result{ + Allow: policyOutput.Allow, + Deny: policyOutput.Deny, + Headers: headersOutput.Headers, } - return evalResult, nil + res.DataBrokerServerVersion, res.DataBrokerRecordVersion = e.store.GetDataBrokerVersions() + return res, nil } -func getJWK(options *config.Options) (*jose.JSONWebKey, error) { +func (e *Evaluator) getClientCA(policy *config.Policy) (string, error) { + if policy != nil && policy.TLSDownstreamClientCA != "" { + bs, err := base64.StdEncoding.DecodeString(policy.TLSDownstreamClientCA) + if err != nil { + return "", err + } + return string(bs), nil + } + + return string(e.clientCA), nil +} + +func (e *Evaluator) updateStore(cfg *evaluatorConfig) error { + jwk, err := getJWK(cfg) + if err != nil { + return fmt.Errorf("authorize: couldn't create signer: %w", err) + } + + authenticateURL, err := urlutil.ParseAndValidateURL(cfg.authenticateURL) + if err != nil { + return fmt.Errorf("authorize: invalid authenticate URL: %w", err) + } + + e.store.UpdateIssuer(authenticateURL.Host) + e.store.UpdateGoogleCloudServerlessAuthenticationServiceAccount( + cfg.googleCloudServerlessAuthenticationServiceAccount, + ) + e.store.UpdateJWTClaimHeaders(cfg.jwtClaimsHeaders) + e.store.UpdateRoutePolicies(cfg.policies) + e.store.UpdateSigningKey(jwk) + + return nil +} + +func getJWK(cfg *evaluatorConfig) (*jose.JSONWebKey, error) { var decodedCert []byte // if we don't have a signing key, generate one - if options.SigningKey == "" { + if cfg.signingKey == "" { key, err := cryptutil.NewSigningKey() if err != nil { return nil, fmt.Errorf("couldn't generate signing key: %w", err) @@ -150,12 +196,12 @@ func getJWK(options *config.Options) (*jose.JSONWebKey, error) { } } else { var err error - decodedCert, err = base64.StdEncoding.DecodeString(options.SigningKey) + decodedCert, err = base64.StdEncoding.DecodeString(cfg.signingKey) if err != nil { return nil, fmt.Errorf("bad signing key: %w", err) } } - signingKeyAlgorithm := options.SigningKeyAlgorithm + signingKeyAlgorithm := cfg.signingKeyAlgorithm if signingKeyAlgorithm == "" { signingKeyAlgorithm = string(jose.ES256) } @@ -172,16 +218,12 @@ func getJWK(options *config.Options) (*jose.JSONWebKey, error) { return jwk, nil } -type input struct { - HTTP RequestHTTP `json:"http"` - Session RequestSession `json:"session"` - IsValidClientCertificate bool `json:"is_valid_client_certificate"` -} - -func (e *Evaluator) newInput(req *Request, isValidClientCertificate bool) *input { - i := new(input) - i.HTTP = req.HTTP - i.Session = req.Session - i.IsValidClientCertificate = isValidClientCertificate - return i +func safeEval(ctx context.Context, q rego.PreparedEvalQuery, options ...rego.EvalOption) (resultSet rego.ResultSet, err error) { + defer func() { + if e := recover(); e != nil { + err = fmt.Errorf("%v", e) + } + }() + resultSet, err = q.Eval(ctx, options...) + return resultSet, err } diff --git a/authorize/evaluator/evaluator_test.go b/authorize/evaluator/evaluator_test.go index 392f269cd..28b588145 100644 --- a/authorize/evaluator/evaluator_test.go +++ b/authorize/evaluator/evaluator_test.go @@ -2,153 +2,457 @@ package evaluator import ( "context" - "encoding/json" "fmt" - "net/http" + "math" "net/url" "testing" - "github.com/golang/protobuf/ptypes" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/anypb" "google.golang.org/protobuf/types/known/timestamppb" + "gopkg.in/square/go-jose.v2" "github.com/pomerium/pomerium/config" + "github.com/pomerium/pomerium/pkg/cryptutil" "github.com/pomerium/pomerium/pkg/grpc/databroker" "github.com/pomerium/pomerium/pkg/grpc/directory" "github.com/pomerium/pomerium/pkg/grpc/session" "github.com/pomerium/pomerium/pkg/grpc/user" ) -func TestJSONMarshal(t *testing.T) { - opt := config.NewDefaultOptions() - opt.AuthenticateURLString = "https://authenticate.example.com" - e, err := New(opt, NewStoreFromProtos(0, - &session.Session{ - UserId: "user1", - }, - &directory.User{ - Id: "user1", - GroupIds: []string{"group1", "group2"}, - }, - &directory.Group{ - Id: "group1", - Name: "admin", - Email: "admin@example.com", - }, - &directory.Group{ - Id: "group2", - Name: "test", - }, - )) +func TestEvaluator(t *testing.T) { + signingKey, err := cryptutil.NewSigningKey() + require.NoError(t, err) + encodedSigningKey, err := cryptutil.EncodePrivateKey(signingKey) + require.NoError(t, err) + privateJWK, err := cryptutil.PrivateJWKFromBytes(encodedSigningKey, jose.ES256) require.NoError(t, err) - bs, _ := json.Marshal(e.newInput(&Request{ - HTTP: RequestHTTP{ - Method: "GET", - URL: "https://example.com", - Headers: map[string]string{ - "Accept": "application/json", - }, - ClientCertificate: "CLIENT_CERTIFICATE", - }, - Session: RequestSession{ - ID: "SESSION_ID", - }, - }, true)) - assert.JSONEq(t, `{ - "http": { - "client_certificate": "CLIENT_CERTIFICATE", - "headers": { - "Accept": "application/json" - }, - "method": "GET", - "url": "https://example.com" - }, - "session": { - "id": "SESSION_ID" - }, - "is_valid_client_certificate": true - }`, string(bs)) -} -func TestEvaluator_Evaluate(t *testing.T) { - sessionID := uuid.New().String() - userID := uuid.New().String() - - ctx := context.Background() - allowedPolicy := []config.Policy{{From: "https://foo.com", AllowedUsers: []string{"foo@example.com"}}} - forbiddenPolicy := []config.Policy{{From: "https://bar.com", AllowedUsers: []string{"bar@example.com"}}} - - tests := []struct { - name string - reqURL string - policies []config.Policy - customPolicies []string - sessionID string - expectedStatus int - }{ - {"allowed", "https://foo.com/path", allowedPolicy, nil, sessionID, http.StatusOK}, - {"forbidden", "https://bar.com/path", forbiddenPolicy, nil, sessionID, http.StatusForbidden}, - {"unauthorized", "https://foo.com/path", allowedPolicy, nil, "", http.StatusUnauthorized}, - {"custom policy overwrite main policy", "https://foo.com/path", allowedPolicy, []string{"deny = true"}, sessionID, http.StatusForbidden}, + eval := func(t *testing.T, options []Option, data []proto.Message, req *Request) (*Result, error) { + store := NewStoreFromProtos(math.MaxUint64, data...) + store.UpdateIssuer("authenticate.example.com") + store.UpdateJWTClaimHeaders(config.NewJWTClaimHeaders("email", "groups", "user", "CUSTOM_KEY")) + store.UpdateSigningKey(privateJWK) + e, err := New(context.Background(), store, options...) + require.NoError(t, err) + return e.Evaluate(context.Background(), req) } - for _, tc := range tests { - tc := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - store := NewStoreFromProtos(0) - data, _ := ptypes.MarshalAny(&session.Session{ - Version: "1", - Id: sessionID, - UserId: userID, - IdToken: &session.IDToken{ - Issuer: "TestEvaluatorEvaluate", - Subject: userID, - IssuedAt: ptypes.TimestampNow(), + policies := []config.Policy{ + { + To: config.WeightedURLs{{URL: *mustParseURL("https://to1.example.com")}}, + AllowPublicUnauthenticatedAccess: true, + }, + { + To: config.WeightedURLs{{URL: *mustParseURL("https://to2.example.com")}}, + AllowPublicUnauthenticatedAccess: true, + KubernetesServiceAccountToken: "KUBERNETES", + }, + { + To: config.WeightedURLs{{URL: *mustParseURL("https://to3.example.com")}}, + AllowPublicUnauthenticatedAccess: true, + EnableGoogleCloudServerlessAuthentication: true, + }, + { + To: config.WeightedURLs{{URL: *mustParseURL("https://to4.example.com")}}, + AllowedUsers: []string{"a@example.com"}, + }, + { + To: config.WeightedURLs{{URL: *mustParseURL("https://to5.example.com")}}, + SubPolicies: []config.SubPolicy{ + { + AllowedUsers: []string{"a@example.com"}, }, - OauthToken: &session.OAuthToken{ - AccessToken: "ACCESS TOKEN", - TokenType: "Bearer", - RefreshToken: "REFRESH TOKEN", - }, - }) - store.UpdateRecord(0, &databroker.Record{ - Version: 1, - Type: "type.googleapis.com/session.Session", - Id: sessionID, - Data: data, - }) - data, _ = ptypes.MarshalAny(&user.User{ - Version: "1", - Id: userID, - Email: "foo@example.com", - }) - store.UpdateRecord(0, &databroker.Record{ - Version: 1, - Type: "type.googleapis.com/user.User", - Id: userID, - Data: data, - }) + }, + }, + { + To: config.WeightedURLs{{URL: *mustParseURL("https://to6.example.com")}}, + AllowedUsers: []string{"example/1234"}, + }, + { + To: config.WeightedURLs{{URL: *mustParseURL("https://to7.example.com")}}, + AllowedDomains: []string{"example.com"}, + }, + { + To: config.WeightedURLs{{URL: *mustParseURL("https://to8.example.com")}}, + AllowedGroups: []string{"group1@example.com"}, + }, + { + To: config.WeightedURLs{{URL: *mustParseURL("https://to9.example.com")}}, + AllowAnyAuthenticatedUser: true, + }, + } + options := []Option{ + WithAuthenticateURL("https://authn.example.com"), + WithClientCA([]byte(testCA)), + WithPolicies(policies), + } - e, err := New(&config.Options{ - AuthenticateURLString: "https://authn.example.com", - Policies: tc.policies, - }, store) - require.NoError(t, err) - res, err := e.Evaluate(ctx, &Request{ - HTTP: RequestHTTP{Method: "GET", URL: tc.reqURL}, - Session: RequestSession{ID: tc.sessionID}, - CustomPolicies: tc.customPolicies, + t.Run("client certificate", func(t *testing.T) { + t.Run("invalid", func(t *testing.T) { + res, err := eval(t, options, nil, &Request{ + Policy: &policies[0], }) require.NoError(t, err) - assert.NotNil(t, res) - assert.Equal(t, tc.expectedStatus, res.Status) + assert.Equal(t, &Denial{Status: 495, Message: "invalid client certificate"}, res.Deny) }) - } + t.Run("valid", func(t *testing.T) { + res, err := eval(t, options, nil, &Request{ + Policy: &policies[0], + HTTP: RequestHTTP{ + ClientCertificate: testValidCert, + }, + }) + require.NoError(t, err) + assert.Nil(t, res.Deny) + }) + }) + t.Run("identity_headers", func(t *testing.T) { + t.Run("kubernetes", func(t *testing.T) { + res, err := eval(t, options, []proto.Message{ + &session.Session{ + Id: "session1", + UserId: "user1", + ImpersonateGroups: []string{"i1", "i2"}, + }, + &user.User{ + Id: "user1", + Email: "a@example.com", + }, + }, &Request{ + Policy: &policies[1], + Session: RequestSession{ + ID: "session1", + }, + HTTP: RequestHTTP{ + Method: "GET", + URL: "https://from.example.com", + ClientCertificate: testValidCert, + }, + }) + require.NoError(t, err) + assert.Equal(t, "a@example.com", res.Headers.Get("Impersonate-User")) + assert.Equal(t, "i1,i2", res.Headers.Get("Impersonate-Group")) + }) + t.Run("google_cloud_serverless", func(t *testing.T) { + withMockGCP(t, func() { + res, err := eval(t, options, []proto.Message{ + &session.Session{ + Id: "session1", + UserId: "user1", + ImpersonateGroups: []string{"i1", "i2"}, + }, + &user.User{ + Id: "user1", + Email: "a@example.com", + }, + }, &Request{ + Policy: &policies[2], + Session: RequestSession{ + ID: "session1", + }, + HTTP: RequestHTTP{ + Method: "GET", + URL: "https://from.example.com", + ClientCertificate: testValidCert, + }, + }) + require.NoError(t, err) + assert.NotEmpty(t, res.Headers.Get("Authorization")) + }) + }) + }) + t.Run("email", func(t *testing.T) { + t.Run("allowed", func(t *testing.T) { + res, err := eval(t, options, []proto.Message{ + &session.Session{ + Id: "session1", + UserId: "user1", + }, + &user.User{ + Id: "user1", + Email: "a@example.com", + }, + }, &Request{ + Policy: &policies[3], + Session: RequestSession{ + ID: "session1", + }, + HTTP: RequestHTTP{ + Method: "GET", + URL: "https://from.example.com", + ClientCertificate: testValidCert, + }, + }) + require.NoError(t, err) + assert.True(t, res.Allow) + }) + t.Run("allowed sub", func(t *testing.T) { + res, err := eval(t, options, []proto.Message{ + &session.Session{ + Id: "session1", + UserId: "user1", + }, + &user.User{ + Id: "user1", + Email: "a@example.com", + }, + }, &Request{ + Policy: &policies[4], + Session: RequestSession{ + ID: "session1", + }, + HTTP: RequestHTTP{ + Method: "GET", + URL: "https://from.example.com", + ClientCertificate: testValidCert, + }, + }) + require.NoError(t, err) + assert.True(t, res.Allow) + }) + t.Run("denied", func(t *testing.T) { + res, err := eval(t, options, []proto.Message{ + &session.Session{ + Id: "session1", + UserId: "user1", + }, + &user.User{ + Id: "user1", + Email: "b@example.com", + }, + }, &Request{ + Policy: &policies[3], + Session: RequestSession{ + ID: "session1", + }, + HTTP: RequestHTTP{ + Method: "GET", + URL: "https://from.example.com", + ClientCertificate: testValidCert, + }, + }) + require.NoError(t, err) + assert.False(t, res.Allow) + }) + }) + t.Run("impersonate email", func(t *testing.T) { + t.Run("allowed", func(t *testing.T) { + res, err := eval(t, options, []proto.Message{ + &user.ServiceAccount{ + Id: "session1", + UserId: "user1", + ImpersonateEmail: proto.String("a@example.com"), + }, + &user.User{ + Id: "user1", + Email: "b@example.com", + }, + }, &Request{ + Policy: &policies[3], + Session: RequestSession{ + ID: "session1", + }, + HTTP: RequestHTTP{ + Method: "GET", + URL: "https://from.example.com", + ClientCertificate: testValidCert, + }, + }) + require.NoError(t, err) + assert.True(t, res.Allow) + }) + t.Run("denied", func(t *testing.T) { + res, err := eval(t, options, []proto.Message{ + &session.Session{ + Id: "session1", + UserId: "user1", + ImpersonateEmail: proto.String("b@example.com"), + }, + &user.User{ + Id: "user1", + Email: "a@example.com", + }, + }, &Request{ + Policy: &policies[3], + Session: RequestSession{ + ID: "session1", + }, + HTTP: RequestHTTP{ + Method: "GET", + URL: "https://from.example.com", + ClientCertificate: testValidCert, + }, + }) + require.NoError(t, err) + assert.False(t, res.Allow) + }) + }) + t.Run("user_id", func(t *testing.T) { + res, err := eval(t, options, []proto.Message{ + &session.Session{ + Id: "session1", + UserId: "example/1234", + }, + &user.User{ + Id: "example/1234", + Email: "a@example.com", + }, + }, &Request{ + Policy: &policies[5], + Session: RequestSession{ + ID: "session1", + }, + HTTP: RequestHTTP{ + Method: "GET", + URL: "https://from.example.com", + ClientCertificate: testValidCert, + }, + }) + require.NoError(t, err) + assert.True(t, res.Allow) + }) + t.Run("domain", func(t *testing.T) { + res, err := eval(t, options, []proto.Message{ + &session.Session{ + Id: "session1", + UserId: "user1", + }, + &user.User{ + Id: "user1", + Email: "a@example.com", + }, + }, &Request{ + Policy: &policies[6], + Session: RequestSession{ + ID: "session1", + }, + HTTP: RequestHTTP{ + Method: "GET", + URL: "https://from.example.com", + ClientCertificate: testValidCert, + }, + }) + require.NoError(t, err) + assert.True(t, res.Allow) + }) + t.Run("impersonate domain", func(t *testing.T) { + res, err := eval(t, options, []proto.Message{ + &session.Session{ + Id: "session1", + UserId: "user1", + ImpersonateEmail: proto.String("a@example.com"), + }, + &user.User{ + Id: "user1", + Email: "a@notexample.com", + }, + }, &Request{ + Policy: &policies[6], + Session: RequestSession{ + ID: "session1", + }, + HTTP: RequestHTTP{ + Method: "GET", + URL: "https://from.example.com", + ClientCertificate: testValidCert, + }, + }) + require.NoError(t, err) + assert.True(t, res.Allow) + }) + t.Run("groups", func(t *testing.T) { + res, err := eval(t, options, []proto.Message{ + &session.Session{ + Id: "session1", + UserId: "user1", + }, + &user.User{ + Id: "user1", + Email: "a@example.com", + }, + &directory.User{ + Id: "user1", + GroupIds: []string{"group1"}, + }, + &directory.Group{ + Id: "group1", + Name: "group1name", + Email: "group1@example.com", + }, + }, &Request{ + Policy: &policies[7], + Session: RequestSession{ + ID: "session1", + }, + HTTP: RequestHTTP{ + Method: "GET", + URL: "https://from.example.com", + ClientCertificate: testValidCert, + }, + }) + require.NoError(t, err) + assert.True(t, res.Allow) + }) + t.Run("impersonate groups", func(t *testing.T) { + res, err := eval(t, options, []proto.Message{ + &session.Session{ + Id: "session1", + UserId: "user1", + ImpersonateGroups: []string{"group1"}, + }, + &user.User{ + Id: "user1", + Email: "a@example.com", + }, + &directory.User{ + Id: "user1", + }, + &directory.Group{ + Id: "group1", + Name: "group1name", + Email: "group1@example.com", + }, + }, &Request{ + Policy: &policies[7], + Session: RequestSession{ + ID: "session1", + }, + HTTP: RequestHTTP{ + Method: "GET", + URL: "https://from.example.com", + ClientCertificate: testValidCert, + }, + }) + require.NoError(t, err) + assert.True(t, res.Allow) + }) + t.Run("any authenticated user", func(t *testing.T) { + res, err := eval(t, options, []proto.Message{ + &session.Session{ + Id: "session1", + UserId: "user1", + }, + &user.User{ + Id: "user1", + }, + }, &Request{ + Policy: &policies[8], + Session: RequestSession{ + ID: "session1", + }, + HTTP: RequestHTTP{ + Method: "GET", + URL: "https://from.example.com", + ClientCertificate: testValidCert, + }, + }) + require.NoError(t, err) + assert.True(t, res.Allow) + }) } func mustParseURL(str string) *url.URL { @@ -161,9 +465,22 @@ func mustParseURL(str string) *url.URL { func BenchmarkEvaluator_Evaluate(b *testing.B) { store := NewStore() - e, err := New(&config.Options{ - AuthenticateURLString: "https://authn.example.com", - }, store) + + policies := []config.Policy{ + { + From: "https://from.example.com", + To: config.WeightedURLs{ + {URL: *mustParseURL("https://to.example.com")}, + }, + AllowedUsers: []string{"SOME_USER"}, + }, + } + options := []Option{ + WithAuthenticateURL("https://authn.example.com"), + WithPolicies(policies), + } + + e, err := New(context.Background(), store, options...) if !assert.NoError(b, err) { return } @@ -233,7 +550,8 @@ func BenchmarkEvaluator_Evaluate(b *testing.B) { b.ResetTimer() ctx := context.Background() for i := 0; i < b.N; i++ { - e.Evaluate(ctx, &Request{ + _, _ = e.Evaluate(ctx, &Request{ + Policy: &policies[0], HTTP: RequestHTTP{ Method: "GET", URL: "https://example.com/path", diff --git a/authorize/evaluator/functions.go b/authorize/evaluator/functions.go index 4207c98fe..965ee0ee8 100644 --- a/authorize/evaluator/functions.go +++ b/authorize/evaluator/functions.go @@ -8,7 +8,6 @@ import ( lru "github.com/hashicorp/golang-lru" - "github.com/pomerium/pomerium/authorize/evaluator/opa" "github.com/pomerium/pomerium/internal/log" ) @@ -65,7 +64,3 @@ func parseCertificate(pemStr string) (*x509.Certificate, error) { } return x509.ParseCertificate(block.Bytes) } - -func readPolicy() ([]byte, error) { - return opa.FS.ReadFile("policy/authz.rego") -} diff --git a/authorize/evaluator/headers_evaluator.go b/authorize/evaluator/headers_evaluator.go new file mode 100644 index 000000000..7fea6b797 --- /dev/null +++ b/authorize/evaluator/headers_evaluator.go @@ -0,0 +1,107 @@ +package evaluator + +import ( + "context" + "fmt" + "net/http" + + "github.com/open-policy-agent/opa/rego" + + "github.com/pomerium/pomerium/authorize/evaluator/opa" + "github.com/pomerium/pomerium/config" + "github.com/pomerium/pomerium/internal/urlutil" +) + +// HeadersRequest is the input to the headers.rego script. +type HeadersRequest struct { + EnableGoogleCloudServerlessAuthentication bool `json:"enable_google_cloud_serverless_authentication"` + FromAudience string `json:"from_audience"` + KubernetesServiceAccountToken string `json:"kubernetes_service_account_token"` + ToAudience string `json:"to_audience"` + Session RequestSession `json:"session"` +} + +// NewHeadersRequestFromPolicy creates a new HeadersRequest from a policy. +func NewHeadersRequestFromPolicy(policy *config.Policy) *HeadersRequest { + input := new(HeadersRequest) + input.EnableGoogleCloudServerlessAuthentication = policy.EnableGoogleCloudServerlessAuthentication + if u, err := urlutil.ParseAndValidateURL(policy.From); err == nil { + input.FromAudience = u.Hostname() + } + input.KubernetesServiceAccountToken = policy.KubernetesServiceAccountToken + for _, wu := range policy.To { + input.ToAudience = wu.URL.Hostname() + } + return input +} + +// HeadersResponse is the output from the headers.rego script. +type HeadersResponse struct { + Headers http.Header +} + +// A HeadersEvaluator evaluates the headers.rego script. +type HeadersEvaluator struct { + q rego.PreparedEvalQuery +} + +// NewHeadersEvaluator creates a new HeadersEvaluator. +func NewHeadersEvaluator(ctx context.Context, store *Store) (*HeadersEvaluator, error) { + r := rego.New( + rego.Store(store), + rego.Module("pomerium.headers", opa.HeadersRego), + rego.Query("result = data.pomerium.headers"), + getGoogleCloudServerlessHeadersRegoOption, + store.GetDataBrokerRecordOption(), + ) + + q, err := r.PrepareForEval(ctx) + if err != nil { + return nil, err + } + + return &HeadersEvaluator{ + q: q, + }, nil +} + +// Evaluate evaluates the headers.rego script. +func (e *HeadersEvaluator) Evaluate(ctx context.Context, req *HeadersRequest) (*HeadersResponse, error) { + rs, err := safeEval(ctx, e.q, rego.EvalInput(req)) + if err != nil { + return nil, fmt.Errorf("authorize: error evaluating headers.rego: %w", err) + } + + if len(rs) == 0 { + return nil, fmt.Errorf("authorize: unexpected empty result from evaluating headers.rego") + } + + return &HeadersResponse{ + Headers: e.getHeader(rs[0].Bindings), + }, nil +} + +func (e *HeadersEvaluator) getHeader(vars rego.Vars) http.Header { + h := make(http.Header) + + m, ok := vars["result"].(map[string]interface{}) + if !ok { + return h + } + + m, ok = m["identity_headers"].(map[string]interface{}) + if !ok { + return h + } + + for k := range m { + vs, ok := m[k].([]interface{}) + if !ok { + continue + } + for _, v := range vs { + h.Add(k, fmt.Sprintf("%v", v)) + } + } + return h +} diff --git a/authorize/evaluator/headers_evaluator_test.go b/authorize/evaluator/headers_evaluator_test.go new file mode 100644 index 000000000..c9f4715e6 --- /dev/null +++ b/authorize/evaluator/headers_evaluator_test.go @@ -0,0 +1,62 @@ +package evaluator + +import ( + "context" + "math" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "gopkg.in/square/go-jose.v2" + "gopkg.in/square/go-jose.v2/jwt" + + "github.com/pomerium/pomerium/config" + + "github.com/pomerium/pomerium/pkg/cryptutil" +) + +func TestHeadersEvaluator(t *testing.T) { + type A = []interface{} + type M = map[string]interface{} + + signingKey, err := cryptutil.NewSigningKey() + require.NoError(t, err) + encodedSigningKey, err := cryptutil.EncodePrivateKey(signingKey) + require.NoError(t, err) + privateJWK, err := cryptutil.PrivateJWKFromBytes(encodedSigningKey, jose.ES256) + require.NoError(t, err) + publicJWK, err := cryptutil.PublicJWKFromBytes(encodedSigningKey, jose.ES256) + require.NoError(t, err) + + eval := func(t *testing.T, data []proto.Message, input *HeadersRequest) (*HeadersResponse, error) { + store := NewStoreFromProtos(math.MaxUint64, data...) + store.UpdateIssuer("authenticate.example.com") + store.UpdateJWTClaimHeaders(config.NewJWTClaimHeaders("email", "groups", "user", "CUSTOM_KEY")) + store.UpdateSigningKey(privateJWK) + e, err := NewHeadersEvaluator(context.Background(), store) + require.NoError(t, err) + return e.Evaluate(context.Background(), input) + } + + t.Run("jwt", func(t *testing.T) { + output, err := eval(t, + []proto.Message{}, + &HeadersRequest{ + FromAudience: "from.example.com", + ToAudience: "to.example.com", + }) + require.NoError(t, err) + + rawJWT, err := jwt.ParseSigned(output.Headers.Get("X-Pomerium-Jwt-Assertion")) + require.NoError(t, err) + + var claims M + err = rawJWT.Claims(publicJWK, &claims) + require.NoError(t, err) + + assert.LessOrEqual(t, claims["exp"], float64(time.Now().Add(time.Minute*6).Unix()), + "JWT should expire within 5 minutes, but got: %v", claims["exp"]) + }) +} diff --git a/authorize/evaluator/opa/opa.go b/authorize/evaluator/opa/opa.go index 00eb05766..454faa5b6 100644 --- a/authorize/evaluator/opa/opa.go +++ b/authorize/evaluator/opa/opa.go @@ -2,8 +2,8 @@ // decisions. package opa -import "embed" +import _ "embed" // to embed files -// FS is the filesystem for OPA files. -//go:embed policy -var FS embed.FS +// HeadersRego is the headers.rego script. +//go:embed policy/headers.rego +var HeadersRego string diff --git a/authorize/evaluator/opa/policy/authz.rego b/authorize/evaluator/opa/policy/authz.rego deleted file mode 100644 index 1c25c0686..000000000 --- a/authorize/evaluator/opa/policy/authz.rego +++ /dev/null @@ -1,474 +0,0 @@ -package pomerium.authz - -default allow = false - -# 5 minutes from now in seconds -five_minutes := (time.now_ns() / 1e9) + (60 * 5) - -# databroker versions to know which version of the data was evaluated -databroker_server_version := data.databroker_server_version - -databroker_record_version := data.databroker_record_version - -route_policy_idx := first_allowed_route_policy_idx(input.http.url) - -route_policy := data.route_policies[route_policy_idx] - -session = s { - s = get_databroker_record("type.googleapis.com/user.ServiceAccount", input.session.id) - s != null -} else = s { - s = get_databroker_record("type.googleapis.com/session.Session", input.session.id) - s != null -} else = {} { - true -} - -user = u { - u = get_databroker_record("type.googleapis.com/user.User", session.impersonate_user_id) - u != null -} else = u { - u = get_databroker_record("type.googleapis.com/user.User", session.user_id) - u != null -} else = {} { - true -} - -directory_user = du { - du = get_databroker_record("type.googleapis.com/directory.User", session.impersonate_user_id) - du != null -} else = du { - du = get_databroker_record("type.googleapis.com/directory.User", session.user_id) - du != null -} else = {} { - true -} - -group_ids = gs { - gs = session.impersonate_groups - gs != null -} else = gs { - gs = directory_user.group_ids - gs != null -} else = [] { - true -} - -groups := array.concat(group_ids, array.concat(get_databroker_group_names(group_ids), get_databroker_group_emails(group_ids))) - -all_allowed_domains := get_allowed_domains(route_policy) - -all_allowed_groups := get_allowed_groups(route_policy) - -all_allowed_users := get_allowed_users(route_policy) - -all_allowed_idp_claims := get_allowed_idp_claims(route_policy) - -is_impersonating := count(session.impersonate_email) > 0 - -# allow public -allow { - route_policy.AllowPublicUnauthenticatedAccess == true -} - -# allow cors preflight -allow { - route_policy.CORSAllowPreflight == true - input.http.method == "OPTIONS" - count(object.get(input.http.headers, "Access-Control-Request-Method", [])) > 0 - count(object.get(input.http.headers, "Origin", [])) > 0 -} - -# allow any authenticated user -allow { - route_policy.AllowAnyAuthenticatedUser == true - session.user_id != "" -} - -# allow by user email -allow { - not is_impersonating - user.email == all_allowed_users[_] -} - -# allow by user id -allow { - not is_impersonating - user.id == all_allowed_users[_] -} - -# allow group -allow { - not is_impersonating - some group - groups[_] = group - all_allowed_groups[_] = group -} - -# allow by impersonate email -allow { - is_impersonating - all_allowed_users[_] = session.impersonate_email -} - -# allow by impersonate group -allow { - is_impersonating - some group - session.impersonate_groups[_] = group - all_allowed_groups[_] = group -} - -# allow by domain -allow { - not is_impersonating - some domain - email_in_domain(user.email, all_allowed_domains[domain]) -} - -# allow by impersonate domain -allow { - is_impersonating - some domain - email_in_domain(session.impersonate_email, all_allowed_domains[domain]) -} - -# allow by arbitrary idp claims -allow { - are_claims_allowed(all_allowed_idp_claims[_], session.claims) -} - -allow { - are_claims_allowed(all_allowed_idp_claims[_], user.claims) -} - -# allow pomerium urls -allow { - contains(input.http.url, "/.pomerium/") -} - -deny[reason] { - reason = [495, "invalid client certificate"] - is_boolean(input.is_valid_client_certificate) - not input.is_valid_client_certificate -} - -jwt_headers = { - "typ": "JWT", - "alg": data.signing_key.alg, - "kid": data.signing_key.kid, -} - -jwt_payload_aud = v { - v = parse_url(input.http.url).hostname -} else = "" { - true -} - -jwt_payload_iss = data.issuer - -jwt_payload_jti = v { - v = session.id -} else = "" { - true -} - -jwt_payload_exp = v { - v = min([five_minutes, session.expires_at.seconds]) -} else = v { - v = five_minutes -} else = null { - true -} - -jwt_payload_iat = v { - # sessions store the issued_at on the id_token - v = session.id_token.issued_at.seconds -} else = v { - # service accounts store the issued at directly - v = session.issued_at.seconds -} else = null { - true -} - -jwt_payload_sub = v { - v = user.id -} else = "" { - true -} - -jwt_payload_user = v { - v = user.id -} else = "" { - true -} - -jwt_payload_email = v { - v = session.impersonate_email -} else = v { - v = directory_user.email -} else = v { - v = user.email -} else = "" { - true -} - -jwt_payload_groups = v { - v = array.concat(group_ids, get_databroker_group_names(group_ids)) - v != [] -} else = v { - v = session.claims.groups - v != null -} else = [] { - true -} - -base_jwt_claims := [ - ["iss", jwt_payload_iss], - ["aud", jwt_payload_aud], - ["jti", jwt_payload_jti], - ["exp", jwt_payload_exp], - ["iat", jwt_payload_iat], - ["sub", jwt_payload_sub], - ["user", jwt_payload_user], - ["email", jwt_payload_email], - ["groups", jwt_payload_groups], -] - -additional_jwt_claims := [[k, v] | - some header_name - claim_key := data.jwt_claim_headers[header_name] - - # exclude base_jwt_claims - count([1 | - [xk, xv] := base_jwt_claims[_] - xk == claim_key - ]) == 0 - - # the claim value can come from session claims or user claims - claim_value := object.get(session.claims, claim_key, object.get(user.claims, claim_key, null)) - - k := claim_key - v := get_header_string_value(claim_value) -] - -jwt_claims := array.concat(base_jwt_claims, additional_jwt_claims) - -jwt_payload = {key: value | - # use a comprehension over an array to remove nil values - [key, value] := jwt_claims[_] - value != null -} - -signed_jwt = io.jwt.encode_sign(jwt_headers, jwt_payload, data.signing_key) - -kubernetes_headers = h { - route_policy.KubernetesServiceAccountToken != "" - h := [ - ["Authorization", concat(" ", ["Bearer", route_policy.KubernetesServiceAccountToken])], - ["Impersonate-User", jwt_payload_email], - ["Impersonate-Group", get_header_string_value(jwt_payload_groups)], - ] -} else = [] { - true -} - -google_cloud_serverless_authentication_service_account = s { - s := data.google_cloud_serverless_authentication_service_account -} else = "" { - true -} - -google_cloud_serverless_headers = h { - route_policy.EnableGoogleCloudServerlessAuthentication - [hostname, _] := parse_host_port(route_policy.To[0].URL.Host) - audience := concat("", ["https://", hostname]) - h := get_google_cloud_serverless_headers(google_cloud_serverless_authentication_service_account, audience) -} else = {} { - true -} - -identity_headers := {key: value | - h1 := [["x-pomerium-jwt-assertion", signed_jwt]] - h2 := [[k, v] | - [claim_key, claim_value] := jwt_claims[_] - claim_value != null - - # only include those headers requested by the user - some header_name - available := data.jwt_claim_headers[header_name] - available == claim_key - - # create the header key and value - k := header_name - v := get_header_string_value(claim_value) - ] - - h3 := kubernetes_headers - h4 := [[k, v] | v := google_cloud_serverless_headers[k]] - - h := array.concat(array.concat(array.concat(h1, h2), h3), h4) - [key, value] := h[_] -} - -# returns the first matching route -first_allowed_route_policy_idx(input_url) = first_policy_idx { - first_policy_idx := [idx | some idx, policy; policy = data.route_policies[idx]; allowed_route(input.http.url, policy)][0] -} - -allowed_route(input_url, policy) { - input_url_obj := parse_url(input_url) - allowed_route_source(input_url_obj, policy) - allowed_route_prefix(input_url_obj, policy) - allowed_route_path(input_url_obj, policy) - allowed_route_regex(input_url_obj, policy) -} - -allowed_route_source(input_url_obj, policy) { - object.get(policy, "source", "") == "" -} - -allowed_route_source(input_url_obj, policy) { - object.get(policy, "source", "") != "" - source_url_obj := parse_url(policy.source) - input_url_obj.host == source_url_obj.host -} - -allowed_route_prefix(input_url_obj, policy) { - object.get(policy, "prefix", "") == "" -} - -allowed_route_prefix(input_url_obj, policy) { - object.get(policy, "prefix", "") != "" - startswith(input_url_obj.path, policy.prefix) -} - -allowed_route_path(input_url_obj, policy) { - object.get(policy, "path", "") == "" -} - -allowed_route_path(input_url_obj, policy) { - object.get(policy, "path", "") != "" - policy.path == input_url_obj.path -} - -allowed_route_regex(input_url_obj, policy) { - object.get(policy, "regex", "") == "" -} - -allowed_route_regex(input_url_obj, policy) { - object.get(policy, "regex", "") != "" - re_match(policy.regex, input_url_obj.path) -} - -parse_url(str) = {"scheme": scheme, "host": host, "hostname": hostname, "path": path} { - [_, scheme, host, rawpath] = regex.find_all_string_submatch_n(`(?:((?:tcp[+])?http[s]?)://)?([^/]+)([^?#]*)`, str, 1)[0] - [hostname, _] = parse_host_port(host) - path = normalize_url_path(rawpath) -} - -parse_host_port(str) = [host, port] { - contains(str, ":") - [host, port] = split(str, ":") -} else = [host, port] { - host = str - port = "443" -} - -normalize_url_path(str) = "/" { - str == "" -} - -normalize_url_path(str) = str { - str != "" -} - -email_in_domain(email, domain) { - x := split(email, "@") - count(x) == 2 - x[1] == domain -} - -element_in_list(list, elem) { - list[_] = elem -} - -get_allowed_users(policy) = v { - sub_array := [x | x = policy.sub_policies[_].allowed_users[_]] - main_array := [x | x = policy.allowed_users[_]] - v := {x | x = array.concat(main_array, sub_array)[_]} -} - -get_allowed_domains(policy) = v { - sub_array := [x | x = policy.sub_policies[_].allowed_domains[_]] - main_array := [x | x = policy.allowed_domains[_]] - v := {x | x = array.concat(main_array, sub_array)[_]} -} - -get_allowed_groups(policy) = v { - sub_array := [x | x = policy.sub_policies[_].allowed_groups[_]] - main_array := [x | x = policy.allowed_groups[_]] - v := {x | x = array.concat(main_array, sub_array)[_]} -} - -get_allowed_idp_claims(policy) = v { - v := array.concat([policy.allowed_idp_claims], [u | u := policy.sub_policies[_].allowed_idp_claims]) -} - -are_claims_allowed(a, b) { - is_object(a) - is_object(b) - avs := a[ak] - bvs := object.get(b, ak, null) - - is_array(avs) - is_array(bvs) - avs[_] == bvs[_] -} - -get_databroker_group_names(ids) = gs { - gs := [name | id := ids[i]; group := get_databroker_record("type.googleapis.com/directory.Group", id); name := group.name] -} - -get_databroker_group_emails(ids) = gs { - gs := [email | id := ids[i]; group := get_databroker_record("type.googleapis.com/directory.Group", id); email := group.email] -} - -get_header_string_value(obj) = s { - is_array(obj) - s := concat(",", obj) -} else = s { - s := concat(",", [obj]) -} - -# object_get is like object.get, but supports converting "/" in keys to separate lookups -# rego doesn't support recursion, so we hard code a limited number of /'s -object_get(obj, key, def) = value { - segments := split(key, "/") - count(segments) == 2 - o1 := object.get(obj, segments[0], {}) - value = object.get(o1, segments[1], def) -} else = value { - segments := split(key, "/") - count(segments) == 3 - o1 := object.get(obj, segments[0], {}) - o2 := object.get(o1, segments[1], {}) - value = object.get(o2, segments[2], def) -} else = value { - segments := split(key, "/") - count(segments) == 4 - o1 := object.get(obj, segments[0], {}) - o2 := object.get(o1, segments[1], {}) - o3 := object.get(o2, segments[2], {}) - value = object.get(o3, segments[3], def) -} else = value { - segments := split(key, "/") - count(segments) == 5 - o1 := object.get(obj, segments[0], {}) - o2 := object.get(o1, segments[1], {}) - o3 := object.get(o2, segments[2], {}) - o4 := object.get(o3, segments[3], {}) - value = object.get(o4, segments[4], def) -} else = value { - value = object.get(obj, key, def) -} diff --git a/authorize/evaluator/opa/policy/authz_test.rego b/authorize/evaluator/opa/policy/authz_test.rego deleted file mode 100644 index d067d59f5..000000000 --- a/authorize/evaluator/opa/policy/authz_test.rego +++ /dev/null @@ -1,341 +0,0 @@ -package pomerium.authz - -get_google_cloud_serverless_headers(serviceAccount, audience) = h { - h := {"Authorization": "Bearer xxx"} -} - -get_databroker_record(typeURL, id) = v { - v := object_get(data.databroker_data, typeURL, null)[id] -} - -test_email_allowed { - allow with data.route_policies as [{ - "source": "example.com", - "allowed_users": ["x@example.com"], - }] - with data.databroker_data as {"type.googleapis.com": { - "session.Session": {"session1": {"id": "session1", "user_id": "user1"}}, - "user.User": {"user1": {"id": "user1", "email": "x@example.com"}}, - }} - with input.http as {"url": "http://example.com"} - with input.session as {"id": "session1"} -} - -test_impersonate_email_not_allowed { - not allow with data.route_policies as [{ - "source": "example.com", - "allowed_users": ["x@example.com"], - }] - with data.databroker_data as {"type.googleapis.com": { - "session.Session": {"session1": {"id": "session1", "user_id": "user1", "impersonate_email": "y@example.com"}}, - "user.User": {"user1": {"id": "user1", "email": "x@example.com"}}, - }} - with input.http as {"url": "http://example.com"} - with input.session as {"id": "session1"} -} - -test_impersonate_email_allowed { - allow with data.route_policies as [{ - "source": "example.com", - "allowed_users": ["y@example.com"], - }] - with data.databroker_data as {"type.googleapis.com": { - "session.Session": {"session1": {"id": "session1", "user_id": "user1", "impersonate_email": "y@example.com"}}, - "user.User": {"user1": {"id": "user1", "email": "x@example.com"}}, - }} - with input.http as {"url": "http://example.com"} - with input.session as {"id": "session1"} -} - -test_group_allowed { - allow with data.route_policies as [{ - "source": "example.com", - "allowed_groups": ["group1"], - }] - with data.databroker_data as {"type.googleapis.com": { - "session.Session": {"session1": {"id": "session1", "user_id": "user1"}}, - "user.User": {"user1": {"id": "user1", "email": "x@example.com"}}, - "directory.User": {"user1": {"id": "user1", "group_ids": ["group1"]}}, - "directory.Group": {"group1": {"id": "group1"}}, - }} - with input.http as {"url": "http://example.com"} - with input.session as {"id": "session1"} -} - -test_impersonate_groups_not_allowed { - not allow with data.route_policies as [{ - "source": "example.com", - "allowed_groups": ["group1"], - }] - with data.databroker_data as {"type.googleapis.com": { - "session.Session": {"session1": {"id": "session1", "user_id": "user1", "impersonate_email": "y@example.com", "impersonate_groups": ["group2"]}}, - "user.User": {"user1": {"id": "user1", "email": "x@example.com"}}, - "directory.User": {"user1": {"id": "user1", "group_ids": ["group1"]}}, - "directory.Group": {"group1": {"id": "group1"}}, - }} - with input.http as {"url": "http://example.com"} - with input.session as {"id": "session1"} -} - -test_impersonate_groups_allowed { - allow with data.route_policies as [{ - "source": "example.com", - "allowed_groups": ["group2"], - }] - with data.databroker_data as {"type.googleapis.com": { - "session.Session": {"session1": {"id": "session1", "user_id": "user1", "impersonate_email": "y@example.com", "impersonate_groups": ["group2"]}}, - "user.User": {"user1": {"id": "user1", "email": "x@example.com"}}, - "directory.User": {"user1": {"id": "user1", "group_ids": ["group1"]}}, - "directory.Group": {"group1": {"id": "group1"}}, - }} - with input.http as {"url": "http://example.com"} - with input.session as {"id": "session1"} -} - -test_domain_allowed { - allow with data.route_policies as [{ - "source": "example.com", - "allowed_domains": ["example.com"], - }] - with data.databroker_data as {"type.googleapis.com": { - "session.Session": {"session1": {"id": "session1", "user_id": "user1"}}, - "user.User": {"user1": {"id": "user1", "email": "x@example.com"}}, - }} - with input.http as {"url": "http://example.com"} - with input.session as {"id": "session1"} -} - -test_impersonate_domain_not_allowed { - not allow with data.route_policies as [{ - "source": "example.com", - "allowed_domains": ["example.com"], - }] - with data.databroker_data as {"type.googleapis.com": { - "session.Session": {"session1": {"id": "session1", "user_id": "user1", "impersonate_email": "y@notexample.com"}}, - "user.User": {"user1": {"id": "user1", "email": "x@example.com"}}, - }} - with input.http as {"url": "http://example.com"} - with input.session as {"id": "session1"} -} - -test_impersonate_domain_allowed { - allow with data.route_policies as [{ - "source": "example.com", - "allowed_domains": ["notexample.com"], - }] - with data.databroker_data as {"type.googleapis.com": { - "session.Session": {"session1": {"id": "session1", "user_id": "user1", "impersonate_email": "y@notexample.com"}}, - "user.User": {"user1": {"id": "user1", "email": "x@example.com"}}, - }} - with input.http as {"url": "http://example.com"} - with input.session as {"id": "session1"} -} - -test_idp_claims_allowed { - allow with data.route_policies as [{ - "source": "example.com", - "allowed_idp_claims": {"some.claim": ["a", "b"]}, - }] - with data.databroker_data as {"type.googleapis.com": {"session.Session": {"session1": {"id": "session1", "claims": {"some.claim": ["b"]}}}}} - with input.http as {"url": "http://example.com"} - with input.session as {"id": "session1"} -} - -test_example { - not allow with data.route_policies as [ - { - "source": "http://example.com", - "path": "/a", - "allowed_domains": ["example.com"], - }, - { - "source": "http://example.com", - "path": "/b", - "allowed_users": ["noone@pomerium.com"], - }, - ] - with input.http as {"url": "http://example.com/b"} - with input.user as {"id": "1", "email": "joe@example.com"} -} - -test_email_denied { - not allow with data.route_policies as [{ - "source": "example.com", - "allowed_users": ["bob@example.com"], - }] - with input.http as {"url": "http://example.com"} - with input.user as {"id": "1", "email": "joe@example.com"} -} - -test_public_allowed { - allow with data.route_policies as [{ - "source": "example.com", - "AllowPublicUnauthenticatedAccess": true, - }] - with input.http as {"url": "http://example.com"} -} - -test_public_denied { - not allow with data.route_policies as [ - { - "source": "example.com", - "prefix": "/by-user", - "allowed_users": ["bob@example.com"], - }, - { - "source": "example.com", - "AllowPublicUnauthenticatedAccess": true, - }, - ] - with input.http as {"url": "http://example.com/by-user"} -} - -test_pomerium_allowed { - allow with data.route_policies as [{ - "source": "example.com", - "allowed_users": ["bob@example.com"], - }] - with input.http as {"url": "http://example.com/.pomerium/"} -} - -test_cors_preflight_allowed { - allow with data.route_policies as [{ - "source": "example.com", - "allowed_users": ["bob@example.com"], - "CORSAllowPreflight": true, - }] - with input.http as { - "method": "OPTIONS", - "url": "http://example.com/", - "headers": { - "Origin": ["someorigin"], - "Access-Control-Request-Method": ["GET"], - }, - } -} - -test_cors_preflight_denied { - not allow with data.route_policies as [{ - "source": "example.com", - "allowed_users": ["bob@example.com"], - }] - with input.http as { - "method": "OPTIONS", - "url": "http://example.com/", - "headers": { - "Origin": ["someorigin"], - "Access-Control-Request-Method": ["GET"], - }, - } -} - -test_parse_url { - url := parse_url("http://example.com/some/path?qs") - url.scheme == "http" - url.host == "example.com" - url.path == "/some/path" -} - -test_parse_tcp_url { - url := parse_url("tcp+http://example.com/some/path?qs") - url.scheme == "tcp+http" - url.host == "example.com" - url.path == "/some/path" -} - -test_allowed_route_source { - allowed_route("http://example.com", {"source": "example.com"}) - allowed_route("http://example.com", {"source": "http://example.com"}) - allowed_route("http://example.com", {"source": "https://example.com"}) - allowed_route("http://example.com/", {"source": "https://example.com"}) - allowed_route("http://example.com", {"source": "https://example.com/"}) - allowed_route("http://example.com/", {"source": "https://example.com/"}) - not allowed_route("http://example.org", {"source": "example.com"}) -} - -test_allowed_route_prefix { - allowed_route("http://example.com", {"prefix": "/"}) - allowed_route("http://example.com/admin/somepath", {"prefix": "/admin"}) - not allowed_route("http://example.com", {"prefix": "/admin"}) -} - -test_allowed_route_path { - allowed_route("http://example.com", {"path": "/"}) - allowed_route("http://example.com/", {"path": "/"}) - not allowed_route("http://example.com/admin/somepath", {"path": "/admin"}) - not allowed_route("http://example.com", {"path": "/admin"}) -} - -test_allowed_route_regex { - allowed_route("http://example.com", {"regex": ".*"}) - allowed_route("http://example.com/admin/somepath", {"regex": "/admin/.*"}) - not allowed_route("http://example.com", {"regex": "[xyz]"}) -} - -test_sub_policy { - x := get_allowed_users({ - "source": "example.com", - "allowed_users": ["u1", "u2"], - "sub_policies": [ - {"allowed_users": ["u1", "u3"]}, - {"allowed_users": ["u2", "u4"]}, - ], - }) - - x == {"u1", "u2", "u3", "u4"} - - y := get_allowed_domains({ - "source": "example.com", - "allowed_domains": ["d1", "d2"], - "sub_policies": [ - {"allowed_domains": ["d1", "d3"]}, - {"allowed_domains": ["d2", "d4"]}, - ], - }) - - y == {"d1", "d2", "d3", "d4"} - - z := get_allowed_groups({ - "source": "example.com", - "allowed_groups": ["g1", "g2"], - "sub_policies": [ - {"allowed_groups": ["g1", "g3"]}, - {"allowed_groups": ["g2", "g4"]}, - ], - }) - - z == {"g1", "g2", "g3", "g4"} -} - -test_are_claims_allowed { - are_claims_allowed({"a": ["1"]}, {"a": ["1"]}) - not are_claims_allowed({"a": ["2"]}, {"a": ["1"]}) - - are_claims_allowed({"a": ["1", "2", "3"]}, {"a": ["1"]}) - are_claims_allowed({"a": ["1"]}, {"a": ["1", "2", "3"]}) - not are_claims_allowed({"a": ["4", "5", "6"]}, {"a": ["1"]}) - - are_claims_allowed({"a.b.c": ["1"], "d.e.f": ["2"]}, {"d.e.f": ["2"]}) -} - -test_any_authenticated_user_allowed { - allow with data.route_policies as [{ - "source": "example.com", - "AllowAnyAuthenticatedUser": true, - }] - with data.databroker_data as {"type.googleapis.com": { - "session.Session": {"session1": {"id": "session1", "user_id": "user1"}}, - "user.User": {"user1": {"id": "user1", "email": "x@example.com"}}, - }} - with input.http as {"url": "http://example.com"} - with input.session as {"id": "session1"} -} - -test_any_authenticated_user_denied { - not allow with data.route_policies as [{ - "source": "example.com", - "AllowAnyAuthenticatedUser": true, - }] - with input.http as {"url": "http://example.com"} - with input.session as {"id": "session1"} -} diff --git a/authorize/evaluator/opa/policy/headers.rego b/authorize/evaluator/opa/policy/headers.rego new file mode 100644 index 000000000..1444a08fb --- /dev/null +++ b/authorize/evaluator/opa/policy/headers.rego @@ -0,0 +1,247 @@ +package pomerium.headers + +# input: +# enable_google_cloud_serverless_authentication: boolean +# from_audience: string +# kubernetes_service_account_token: string +# session: +# id: string +# to_audience: string +# +# data: +# issuer: string +# jwt_claim_headers: map[string]string +# signing_key: +# alg: string +# kid: string +# +# functions: +# get_databroker_record +# get_google_cloud_serverless_headers +# +# +# output: +# identity_headers: map[string][]string + +# 5 minutes from now in seconds +five_minutes := (time.now_ns() / 1e9) + (60 * 5) + +session = s { + s = get_databroker_record("type.googleapis.com/user.ServiceAccount", input.session.id) + s != null +} else = s { + s = get_databroker_record("type.googleapis.com/session.Session", input.session.id) + s != null +} else = {} { + true +} + +user = u { + u = get_databroker_record("type.googleapis.com/user.User", session.impersonate_user_id) + u != null +} else = u { + u = get_databroker_record("type.googleapis.com/user.User", session.user_id) + u != null +} else = {} { + true +} + +directory_user = du { + du = get_databroker_record("type.googleapis.com/directory.User", session.impersonate_user_id) + du != null +} else = du { + du = get_databroker_record("type.googleapis.com/directory.User", session.user_id) + du != null +} else = {} { + true +} + +group_ids = gs { + gs = session.impersonate_groups + gs != null +} else = gs { + gs = directory_user.group_ids + gs != null +} else = [] { + true +} + +groups := array.concat(group_ids, array.concat(get_databroker_group_names(group_ids), get_databroker_group_emails(group_ids))) + +jwt_headers = { + "typ": "JWT", + "alg": data.signing_key.alg, + "kid": data.signing_key.kid, +} + +jwt_payload_aud = v { + v := input.from_audience +} else = "" { + true +} + +jwt_payload_iss = data.issuer + +jwt_payload_jti = v { + v = session.id +} else = "" { + true +} + +jwt_payload_exp = v { + v = min([five_minutes, session.expires_at.seconds]) +} else = v { + v = five_minutes +} else = null { + true +} + +jwt_payload_iat = v { + # sessions store the issued_at on the id_token + v = session.id_token.issued_at.seconds +} else = v { + # service accounts store the issued at directly + v = session.issued_at.seconds +} else = null { + true +} + +jwt_payload_sub = v { + v = user.id +} else = "" { + true +} + +jwt_payload_user = v { + v = user.id +} else = "" { + true +} + +jwt_payload_email = v { + v = session.impersonate_email +} else = v { + v = directory_user.email +} else = v { + v = user.email +} else = "" { + true +} + +jwt_payload_groups = v { + v = array.concat(group_ids, get_databroker_group_names(group_ids)) + v != [] +} else = v { + v = session.claims.groups + v != null +} else = [] { + true +} + +base_jwt_claims := [ + ["iss", jwt_payload_iss], + ["aud", jwt_payload_aud], + ["jti", jwt_payload_jti], + ["exp", jwt_payload_exp], + ["iat", jwt_payload_iat], + ["sub", jwt_payload_sub], + ["user", jwt_payload_user], + ["email", jwt_payload_email], + ["groups", jwt_payload_groups], +] + +additional_jwt_claims := [[k, v] | + some header_name + claim_key := data.jwt_claim_headers[header_name] + + # exclude base_jwt_claims + count([1 | + [xk, xv] := base_jwt_claims[_] + xk == claim_key + ]) == 0 + + # the claim value can come from session claims or user claims + claim_value := object.get(session.claims, claim_key, object.get(user.claims, claim_key, null)) + + k := claim_key + v := get_header_string_value(claim_value) +] + +jwt_claims := array.concat(base_jwt_claims, additional_jwt_claims) + +jwt_payload = {key: value | + # use a comprehension over an array to remove nil values + [key, value] := jwt_claims[_] + value != null +} + +signed_jwt = io.jwt.encode_sign(jwt_headers, jwt_payload, data.signing_key) + +kubernetes_headers = h { + input.kubernetes_service_account_token != "" + h := [ + ["Authorization", concat(" ", ["Bearer", input.kubernetes_service_account_token])], + ["Impersonate-User", jwt_payload_email], + ["Impersonate-Group", get_header_string_value(jwt_payload_groups)], + ] +} else = [] { + true +} + +google_cloud_serverless_authentication_service_account = s { + s := data.google_cloud_serverless_authentication_service_account +} else = "" { + true +} + +google_cloud_serverless_headers = h { + input.enable_google_cloud_serverless_authentication + h := get_google_cloud_serverless_headers(google_cloud_serverless_authentication_service_account, input.to_audience) +} else = {} { + true +} + +identity_headers := {key: values | + h1 := [["x-pomerium-jwt-assertion", signed_jwt]] + h2 := [[k, v] | + [claim_key, claim_value] := jwt_claims[_] + claim_value != null + + # only include those headers requested by the user + some header_name + available := data.jwt_claim_headers[header_name] + available == claim_key + + # create the header key and value + k := header_name + v := get_header_string_value(claim_value) + ] + + h3 := kubernetes_headers + h4 := [[k, v] | v := google_cloud_serverless_headers[k]] + + h := array.concat(array.concat(array.concat(h1, h2), h3), h4) + + some i + [key, v1] := h[i] + values := [ v2 | + some j + [k2, v2] := h[j] + key == k2 + ] +} + +get_databroker_group_names(ids) = gs { + gs := [name | id := ids[i]; group := get_databroker_record("type.googleapis.com/directory.Group", id); name := group.name] +} + +get_databroker_group_emails(ids) = gs { + gs := [email | id := ids[i]; group := get_databroker_record("type.googleapis.com/directory.Group", id); email := group.email] +} + +get_header_string_value(obj) = s { + is_array(obj) + s := concat(",", obj) +} else = s { + s := concat(",", [obj]) +} diff --git a/authorize/evaluator/opa_test.go b/authorize/evaluator/opa_test.go deleted file mode 100644 index f7f4433b2..000000000 --- a/authorize/evaluator/opa_test.go +++ /dev/null @@ -1,747 +0,0 @@ -package evaluator - -import ( - "context" - "encoding/json" - "math" - "testing" - "time" - - "github.com/open-policy-agent/opa/rego" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "google.golang.org/protobuf/proto" - "google.golang.org/protobuf/types/known/structpb" - "google.golang.org/protobuf/types/known/timestamppb" - "google.golang.org/protobuf/types/known/wrapperspb" - "gopkg.in/square/go-jose.v2" - "gopkg.in/square/go-jose.v2/jwt" - - "github.com/pomerium/pomerium/config" - "github.com/pomerium/pomerium/pkg/cryptutil" - "github.com/pomerium/pomerium/pkg/grpc/directory" - "github.com/pomerium/pomerium/pkg/grpc/session" - "github.com/pomerium/pomerium/pkg/grpc/user" -) - -func TestOPA(t *testing.T) { - type A = []interface{} - type M = map[string]interface{} - - signingKey, err := cryptutil.NewSigningKey() - require.NoError(t, err) - encodedSigningKey, err := cryptutil.EncodePrivateKey(signingKey) - require.NoError(t, err) - privateJWK, err := cryptutil.PrivateJWKFromBytes(encodedSigningKey, jose.ES256) - require.NoError(t, err) - publicJWK, err := cryptutil.PublicJWKFromBytes(encodedSigningKey, jose.ES256) - require.NoError(t, err) - - eval := func(t *testing.T, policies []config.Policy, data []proto.Message, req *Request, isValidClientCertificate bool) rego.Result { - authzPolicy, err := readPolicy() - require.NoError(t, err) - store := NewStoreFromProtos(math.MaxUint64, data...) - store.UpdateIssuer("authenticate.example.com") - store.UpdateJWTClaimHeaders(config.NewJWTClaimHeaders("email", "groups", "user", "CUSTOM_KEY")) - store.UpdateRoutePolicies(policies) - store.UpdateSigningKey(privateJWK) - r := rego.New( - rego.Store(store), - rego.Module("pomerium.authz", string(authzPolicy)), - rego.Query("result = data.pomerium.authz"), - getGoogleCloudServerlessHeadersRegoOption, - store.GetDataBrokerRecordOption(), - ) - q, err := r.PrepareForEval(context.Background()) - require.NoError(t, err) - rs, err := q.Eval(context.Background(), - rego.EvalInput((&Evaluator{store: store}).newInput(req, isValidClientCertificate)), - ) - require.NoError(t, err) - require.Len(t, rs, 1) - return rs[0] - } - - t.Run("client certificate", func(t *testing.T) { - res := eval(t, nil, nil, &Request{}, false) - assert.Equal(t, - A{A{json.Number("495"), "invalid client certificate"}}, - res.Bindings["result"].(M)["deny"]) - }) - t.Run("identity_headers", func(t *testing.T) { - t.Run("kubernetes", func(t *testing.T) { - res := eval(t, []config.Policy{{ - Source: &config.StringURL{URL: mustParseURL("https://from.example.com")}, - To: config.WeightedURLs{ - {URL: *mustParseURL("https://to.example.com")}, - }, - KubernetesServiceAccountToken: "KUBERNETES", - }}, []proto.Message{ - &session.Session{ - Id: "session1", - UserId: "user1", - ImpersonateGroups: []string{"i1", "i2"}, - }, - &user.User{ - Id: "user1", - Email: "a@example.com", - }, - }, &Request{ - Session: RequestSession{ - ID: "session1", - }, - HTTP: RequestHTTP{ - Method: "GET", - URL: "https://from.example.com", - }, - }, true) - headers := res.Bindings["result"].(M)["identity_headers"].(M) - assert.NotEmpty(t, headers["Authorization"]) - assert.Equal(t, "a@example.com", headers["Impersonate-User"]) - assert.Equal(t, "i1,i2", headers["Impersonate-Group"]) - }) - t.Run("google_cloud_serverless", func(t *testing.T) { - withMockGCP(t, func() { - res := eval(t, []config.Policy{{ - Source: &config.StringURL{URL: mustParseURL("https://from.example.com")}, - To: config.WeightedURLs{ - {URL: *mustParseURL("https://to.example.com")}, - }, - EnableGoogleCloudServerlessAuthentication: true, - }}, []proto.Message{ - &session.Session{ - Id: "session1", - UserId: "user1", - ImpersonateGroups: []string{"i1", "i2"}, - }, - &user.User{ - Id: "user1", - Email: "a@example.com", - }, - }, &Request{ - Session: RequestSession{ - ID: "session1", - }, - HTTP: RequestHTTP{ - Method: "GET", - URL: "https://from.example.com", - }, - }, true) - headers := res.Bindings["result"].(M)["identity_headers"].(M) - assert.NotEmpty(t, headers["Authorization"]) - }) - }) - }) - t.Run("jwt", func(t *testing.T) { - evalJWT := func(msgs ...proto.Message) M { - res := eval(t, []config.Policy{{ - Source: &config.StringURL{URL: mustParseURL("https://from.example.com:8000")}, - To: config.WeightedURLs{ - {URL: *mustParseURL("https://to.example.com")}, - }, - }}, msgs, &Request{ - Session: RequestSession{ - ID: "session1", - }, - HTTP: RequestHTTP{ - Method: "GET", - URL: "https://from.example.com:8000", - }, - }, true) - signedCompactJWTStr := res.Bindings["result"].(M)["signed_jwt"].(string) - authJWT, err := jwt.ParseSigned(signedCompactJWTStr) - require.NoError(t, err) - var claims M - err = authJWT.Claims(publicJWK, &claims) - require.NoError(t, err) - assert.LessOrEqual(t, claims["exp"], float64(time.Now().Add(time.Minute*6).Unix()), - "JWT should expire within 5 minutes, but got: %v", claims["exp"]) - return claims - } - - t.Run("impersonate groups", func(t *testing.T) { - payload := evalJWT( - &session.Session{ - Id: "session1", - UserId: "user1", - ImpersonateGroups: []string{"i1", "i2"}, - }, - &user.User{ - Id: "user1", - Email: "a@example.com", - }, - &directory.User{ - Id: "user1", - GroupIds: []string{"group1"}, - }, - &directory.Group{ - Id: "group1", - Name: "group1name", - Email: "group1@example.com", - }, - ) - delete(payload, "exp") - assert.Equal(t, M{ - "aud": "from.example.com", - "iss": "authenticate.example.com", - "jti": "session1", - "sub": "user1", - "user": "user1", - "email": "a@example.com", - "groups": []interface{}{"i1", "i2"}, - }, payload) - }) - t.Run("directory", func(t *testing.T) { - payload := evalJWT( - &session.Session{ - Id: "session1", - UserId: "user1", - ExpiresAt: timestamppb.New(time.Date(2021, 1, 1, 1, 1, 1, 1, time.UTC)), - IdToken: &session.IDToken{ - IssuedAt: timestamppb.New(time.Date(2021, 2, 1, 1, 1, 1, 1, time.UTC)), - }, - Claims: map[string]*structpb.ListValue{ - "CUSTOM_KEY": { - Values: []*structpb.Value{ - structpb.NewStringValue("FROM_SESSION"), - }, - }, - "email": { - Values: []*structpb.Value{ - structpb.NewStringValue("value"), - }, - }, - }, - }, - &user.User{ - Id: "user1", - Email: "a@example.com", - Claims: map[string]*structpb.ListValue{ - "CUSTOM_KEY": { - Values: []*structpb.Value{ - structpb.NewStringValue("FROM_USER"), - }, - }, - }, - }, - &directory.User{ - Id: "user1", - GroupIds: []string{"group1"}, - }, - &directory.Group{ - Id: "group1", - Name: "group1name", - Email: "group1@example.com", - }, - ) - assert.Equal(t, M{ - "aud": "from.example.com", - "iss": "authenticate.example.com", - "jti": "session1", - "iat": 1612141261.0, - "exp": 1609462861.0, - "sub": "user1", - "user": "user1", - "email": "a@example.com", - "groups": A{"group1", "group1name"}, - "CUSTOM_KEY": "FROM_SESSION", - }, payload) - }) - }) - t.Run("email", func(t *testing.T) { - t.Run("allowed", func(t *testing.T) { - res := eval(t, []config.Policy{ - { - Source: &config.StringURL{URL: mustParseURL("https://from.example.com:8000")}, - To: config.WeightedURLs{ - {URL: *mustParseURL("https://to.example.com")}, - }, - AllowedUsers: []string{"a@example.com"}, - }, - }, []proto.Message{ - &session.Session{ - Id: "session1", - UserId: "user1", - }, - &user.User{ - Id: "user1", - Email: "a@example.com", - }, - }, &Request{ - Session: RequestSession{ - ID: "session1", - }, - HTTP: RequestHTTP{ - Method: "GET", - URL: "https://from.example.com:8000", - }, - }, true) - assert.True(t, res.Bindings["result"].(M)["allow"].(bool)) - }) - t.Run("allowed sub", func(t *testing.T) { - res := eval(t, []config.Policy{ - { - Source: &config.StringURL{URL: mustParseURL("https://from.example.com:8000")}, - To: config.WeightedURLs{ - {URL: *mustParseURL("https://to.example.com")}, - }, - SubPolicies: []config.SubPolicy{ - { - AllowedUsers: []string{"a@example.com"}, - }, - }, - }, - }, []proto.Message{ - &session.Session{ - Id: "session1", - UserId: "user1", - }, - &user.User{ - Id: "user1", - Email: "a@example.com", - }, - }, &Request{ - Session: RequestSession{ - ID: "session1", - }, - HTTP: RequestHTTP{ - Method: "GET", - URL: "https://from.example.com:8000", - }, - }, true) - assert.True(t, res.Bindings["result"].(M)["allow"].(bool)) - }) - t.Run("denied", func(t *testing.T) { - res := eval(t, []config.Policy{ - { - Source: &config.StringURL{URL: mustParseURL("https://from.example.com")}, - To: config.WeightedURLs{ - {URL: *mustParseURL("https://to.example.com")}, - }, - AllowedUsers: []string{"a@example.com"}, - }, - }, []proto.Message{ - &session.Session{ - Id: "session1", - UserId: "user1", - }, - &user.User{ - Id: "user1", - Email: "b@example.com", - }, - }, &Request{ - Session: RequestSession{ - ID: "session1", - }, - HTTP: RequestHTTP{ - Method: "GET", - URL: "https://from.example.com", - }, - }, true) - assert.False(t, res.Bindings["result"].(M)["allow"].(bool)) - }) - }) - t.Run("impersonate email", func(t *testing.T) { - t.Run("allowed", func(t *testing.T) { - res := eval(t, []config.Policy{ - { - Source: &config.StringURL{URL: mustParseURL("https://from.example.com")}, - To: config.WeightedURLs{ - {URL: *mustParseURL("https://to.example.com")}, - }, - AllowedUsers: []string{"b@example.com"}, - }, - }, []proto.Message{ - &user.ServiceAccount{ - Id: "session1", - UserId: "user1", - ImpersonateEmail: proto.String("b@example.com"), - }, - &user.User{ - Id: "user1", - Email: "a@example.com", - }, - }, &Request{ - Session: RequestSession{ - ID: "session1", - }, - HTTP: RequestHTTP{ - Method: "GET", - URL: "https://from.example.com", - }, - }, true) - assert.True(t, res.Bindings["result"].(M)["allow"].(bool)) - }) - t.Run("denied", func(t *testing.T) { - res := eval(t, []config.Policy{ - { - Source: &config.StringURL{URL: mustParseURL("https://from.example.com")}, - To: config.WeightedURLs{ - {URL: *mustParseURL("https://to.example.com")}, - }, - AllowedUsers: []string{"a@example.com"}, - }, - }, []proto.Message{ - &session.Session{ - Id: "session1", - UserId: "user1", - ImpersonateEmail: proto.String("b@example.com"), - }, - &user.User{ - Id: "user1", - Email: "a@example.com", - }, - }, &Request{ - Session: RequestSession{ - ID: "session1", - }, - HTTP: RequestHTTP{ - Method: "GET", - URL: "https://from.example.com", - }, - }, true) - assert.False(t, res.Bindings["result"].(M)["allow"].(bool)) - }) - }) - t.Run("user_id", func(t *testing.T) { - res := eval(t, []config.Policy{ - { - Source: &config.StringURL{URL: mustParseURL("https://from.example.com")}, - To: config.WeightedURLs{ - {URL: *mustParseURL("https://to.example.com")}, - }, - AllowedUsers: []string{"example/1234"}, - }, - }, []proto.Message{ - &session.Session{ - Id: "session1", - UserId: "example/1234", - }, - &user.User{ - Id: "example/1234", - Email: "a@example.com", - }, - }, &Request{ - Session: RequestSession{ - ID: "session1", - }, - HTTP: RequestHTTP{ - Method: "GET", - URL: "https://from.example.com", - }, - }, true) - assert.True(t, res.Bindings["result"].(M)["allow"].(bool)) - }) - t.Run("domain", func(t *testing.T) { - t.Run("allowed", func(t *testing.T) { - res := eval(t, []config.Policy{ - { - Source: &config.StringURL{URL: mustParseURL("https://from.example.com")}, - To: config.WeightedURLs{ - {URL: *mustParseURL("https://to.example.com")}, - }, - AllowedDomains: []string{"example.com"}, - }, - }, []proto.Message{ - &user.ServiceAccount{Id: "serviceaccount1"}, - &session.Session{ - Id: "session1", - UserId: "example/user1", - }, - &user.User{ - Id: "example/user1", - Email: "a@example.com", - }, - }, &Request{ - Session: RequestSession{ - ID: "session1", - }, - HTTP: RequestHTTP{ - Method: "GET", - URL: "https://from.example.com", - }, - }, true) - assert.True(t, res.Bindings["result"].(M)["allow"].(bool)) - }) - t.Run("allowed sub", func(t *testing.T) { - res := eval(t, []config.Policy{ - { - Source: &config.StringURL{URL: mustParseURL("https://from.example.com")}, - To: config.WeightedURLs{ - {URL: *mustParseURL("https://to.example.com")}, - }, - SubPolicies: []config.SubPolicy{ - { - AllowedDomains: []string{"example.com"}, - }, - }, - }, - }, []proto.Message{ - &user.ServiceAccount{Id: "serviceaccount1"}, - &session.Session{ - Id: "session1", - UserId: "example/user1", - }, - &user.User{ - Id: "example/user1", - Email: "a@example.com", - }, - }, &Request{ - Session: RequestSession{ - ID: "session1", - }, - HTTP: RequestHTTP{ - Method: "GET", - URL: "https://from.example.com", - }, - }, true) - assert.True(t, res.Bindings["result"].(M)["allow"].(bool)) - }) - t.Run("denied", func(t *testing.T) { - res := eval(t, []config.Policy{ - { - Source: &config.StringURL{URL: mustParseURL("https://from.example.com")}, - To: config.WeightedURLs{ - {URL: *mustParseURL("https://to.example.com")}, - }, - AllowedDomains: []string{"notexample.com"}, - }, - }, []proto.Message{ - &session.Session{ - Id: "session1", - UserId: "user1", - }, - &user.User{ - Id: "user1", - Email: "a@example.com", - }, - }, &Request{ - Session: RequestSession{ - ID: "session1", - }, - HTTP: RequestHTTP{ - Method: "GET", - URL: "https://from.example.com", - }, - }, true) - assert.False(t, res.Bindings["result"].(M)["allow"].(bool)) - }) - }) - t.Run("impersonate domain", func(t *testing.T) { - t.Run("allowed", func(t *testing.T) { - res := eval(t, []config.Policy{ - { - Source: &config.StringURL{URL: mustParseURL("https://from.example.com")}, - To: config.WeightedURLs{ - {URL: *mustParseURL("https://to.example.com")}, - }, - AllowedDomains: []string{"example.com"}, - }, - }, []proto.Message{ - &session.Session{ - Id: "session1", - UserId: "user1", - ImpersonateEmail: proto.String("a@example.com"), - }, - &user.User{ - Id: "user1", - Email: "a@notexample.com", - }, - }, &Request{ - Session: RequestSession{ - ID: "session1", - }, - HTTP: RequestHTTP{ - Method: "GET", - URL: "https://from.example.com", - }, - }, true) - assert.True(t, res.Bindings["result"].(M)["allow"].(bool)) - }) - t.Run("denied", func(t *testing.T) { - res := eval(t, []config.Policy{ - { - Source: &config.StringURL{URL: mustParseURL("https://from.example.com")}, - To: config.WeightedURLs{ - {URL: *mustParseURL("https://to.example.com")}, - }, - AllowedDomains: []string{"example.com"}, - }, - }, []proto.Message{ - &session.Session{ - Id: "session1", - UserId: "user1", - ImpersonateEmail: proto.String("a@notexample.com"), - }, - &user.User{ - Id: "user1", - Email: "a@example.com", - }, - }, &Request{ - Session: RequestSession{ - ID: "session1", - }, - HTTP: RequestHTTP{ - Method: "GET", - URL: "https://from.example.com", - }, - }, true) - assert.False(t, res.Bindings["result"].(M)["allow"].(bool)) - }) - }) - t.Run("groups", func(t *testing.T) { - t.Run("allowed", func(t *testing.T) { - for _, nm := range []string{"group1", "group1name", "group1@example.com"} { - t.Run(nm, func(t *testing.T) { - res := eval(t, []config.Policy{ - { - Source: &config.StringURL{URL: mustParseURL("https://from.example.com")}, - To: config.WeightedURLs{ - {URL: *mustParseURL("https://to.example.com")}, - }, - AllowedGroups: []string{nm}, - }, - }, []proto.Message{ - &session.Session{ - Id: "session1", - UserId: "user1", - }, - &user.User{ - Id: "user1", - Email: "a@example.com", - }, - &directory.User{ - Id: "user1", - GroupIds: []string{"group1"}, - }, - &directory.Group{ - Id: "group1", - Name: "group1name", - Email: "group1@example.com", - }, - }, &Request{ - Session: RequestSession{ - ID: "session1", - }, - HTTP: RequestHTTP{ - Method: "GET", - URL: "https://from.example.com", - }, - }, true) - assert.True(t, res.Bindings["result"].(M)["allow"].(bool)) - }) - } - }) - t.Run("denied", func(t *testing.T) { - res := eval(t, []config.Policy{ - { - Source: &config.StringURL{URL: mustParseURL("https://from.example.com")}, - To: config.WeightedURLs{ - {URL: *mustParseURL("https://to.example.com")}, - }, - AllowedGroups: []string{"group1"}, - }, - }, []proto.Message{ - &session.Session{ - Id: "session1", - UserId: "user1", - }, - &user.User{ - Id: "user1", - Email: "a@example.com", - }, - &directory.User{ - Id: "user1", - GroupIds: []string{"group2"}, - }, - &directory.Group{ - Id: "group1", - Name: "group-1", - Email: "group1@example.com", - }, - }, &Request{ - Session: RequestSession{ - ID: "session1", - }, - HTTP: RequestHTTP{ - Method: "GET", - URL: "https://from.example.com", - }, - }, true) - assert.False(t, res.Bindings["result"].(M)["allow"].(bool)) - }) - }) - t.Run("impersonate groups", func(t *testing.T) { - res := eval(t, []config.Policy{ - { - Source: &config.StringURL{URL: mustParseURL("https://from.example.com")}, - To: config.WeightedURLs{ - {URL: *mustParseURL("https://to.example.com")}, - }, - AllowedGroups: []string{"group1"}, - }, - }, []proto.Message{ - &session.Session{ - Id: "session1", - UserId: "user1", - ImpersonateEmail: proto.String("a@example.com"), - ImpersonateGroups: []string{"group1"}, - }, - &user.User{ - Id: "user1", - Email: "a@example.com", - }, - &directory.Group{ - Id: "group1", - Name: "group-1", - Email: "group1@example.com", - }, - }, &Request{ - Session: RequestSession{ - ID: "session1", - }, - HTTP: RequestHTTP{ - Method: "GET", - URL: "https://from.example.com", - }, - }, true) - assert.True(t, res.Bindings["result"].(M)["allow"].(bool)) - }) - t.Run("any authenticated user", func(t *testing.T) { - res := eval(t, []config.Policy{ - { - Source: &config.StringURL{URL: mustParseURL("https://from.example.com")}, - To: config.WeightedURLs{ - {URL: *mustParseURL("https://to.example.com")}, - }, - AllowAnyAuthenticatedUser: true, - }, - }, []proto.Message{ - &session.Session{ - Id: "session1", - UserId: "user1", - }, - &user.User{ - Id: "user1", - Email: "a@example.com", - }, - }, &Request{ - Session: RequestSession{ - ID: "session1", - }, - HTTP: RequestHTTP{ - Method: "GET", - URL: "https://from.example.com", - }, - }, true) - assert.True(t, res.Bindings["result"].(M)["allow"].(bool)) - }) - t.Run("databroker versions", func(t *testing.T) { - res := eval(t, nil, []proto.Message{ - wrapperspb.String("test"), - }, &Request{}, false) - serverVersion, recordVersion := getDataBrokerVersions(res.Bindings) - assert.Equal(t, uint64(math.MaxUint64), serverVersion) - assert.NotEqual(t, uint64(0), recordVersion) // random - }) -} diff --git a/authorize/evaluator/policy_evaluator.go b/authorize/evaluator/policy_evaluator.go new file mode 100644 index 000000000..5297bc4a1 --- /dev/null +++ b/authorize/evaluator/policy_evaluator.go @@ -0,0 +1,183 @@ +package evaluator + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/open-policy-agent/opa/rego" + + "github.com/pomerium/pomerium/config" + "github.com/pomerium/pomerium/internal/log" + "github.com/pomerium/pomerium/pkg/policy" +) + +// PolicyRequest is the input to policy evaluation. +type PolicyRequest struct { + HTTP RequestHTTP `json:"http"` + Session RequestSession `json:"session"` + IsValidClientCertificate bool `json:"is_valid_client_certificate"` +} + +// PolicyResponse is the result of evaluating a policy. +type PolicyResponse struct { + Allow bool + Deny *Denial +} + +// Merge merges another PolicyResponse into this PolicyResponse. Access is allowed if either is allowed. Access is denied if +// either is denied. (and denials take precedence) +func (res *PolicyResponse) Merge(other *PolicyResponse) *PolicyResponse { + merged := &PolicyResponse{ + Allow: res.Allow || other.Allow, + Deny: res.Deny, + } + if other.Deny != nil { + merged.Deny = other.Deny + } + return merged +} + +// A Denial indicates the request should be denied (even if otherwise allowed). +type Denial struct { + Status int + Message string +} + +// A PolicyEvaluator evaluates policies. +type PolicyEvaluator struct { + queries []rego.PreparedEvalQuery +} + +// NewPolicyEvaluator creates a new PolicyEvaluator. +func NewPolicyEvaluator(ctx context.Context, store *Store, configPolicy *config.Policy) (*PolicyEvaluator, error) { + e := new(PolicyEvaluator) + + // generate the base rego script for the policy + ppl := configPolicy.ToPPL() + base, err := policy.GenerateRegoFromPolicy(ppl) + if err != nil { + return nil, err + } + + scripts := []string{base} + + // add any custom rego + for _, sp := range configPolicy.SubPolicies { + for _, src := range sp.Rego { + if src == "" { + continue + } + + scripts = append(scripts, src) + } + } + + // for each script, create a rego and prepare a query. + for _, script := range scripts { + log.Debug(ctx). + Str("script", script). + Str("from", configPolicy.From). + Interface("to", configPolicy.To). + Msg("authorize: rego script for policy evaluation") + + r := rego.New( + rego.Store(store), + rego.Module("pomerium.policy", script), + rego.Query("result = data.pomerium.policy"), + getGoogleCloudServerlessHeadersRegoOption, + store.GetDataBrokerRecordOption(), + ) + + q, err := r.PrepareForEval(ctx) + // if no package is in the src, add it + if err != nil && strings.Contains(err.Error(), "package expected") { + r := rego.New( + rego.Store(store), + rego.Module("pomerium.policy", "package pomerium.policy\n\n"+script), + rego.Query("result = data.pomerium.policy"), + getGoogleCloudServerlessHeadersRegoOption, + store.GetDataBrokerRecordOption(), + ) + q, err = r.PrepareForEval(ctx) + } + if err != nil { + return nil, err + } + e.queries = append(e.queries, q) + } + + return e, nil +} + +// Evaluate evaluates the policy rego scripts. +func (e *PolicyEvaluator) Evaluate(ctx context.Context, req *PolicyRequest) (*PolicyResponse, error) { + res := new(PolicyResponse) + // run each query and merge the results + for _, query := range e.queries { + o, err := e.evaluateQuery(ctx, req, query) + if err != nil { + return nil, err + } + res = res.Merge(o) + } + return res, nil +} + +func (e *PolicyEvaluator) evaluateQuery(ctx context.Context, req *PolicyRequest, query rego.PreparedEvalQuery) (*PolicyResponse, error) { + rs, err := safeEval(ctx, query, rego.EvalInput(req)) + if err != nil { + return nil, fmt.Errorf("authorize: error evaluating policy.rego: %w", err) + } + + if len(rs) == 0 { + return nil, fmt.Errorf("authorize: unexpected empty result from evaluating policy.rego") + } + + res := &PolicyResponse{ + Allow: e.getAllow(rs[0].Bindings), + Deny: e.getDeny(ctx, rs[0].Bindings), + } + return res, nil +} + +// getAllow gets the allow var. It expects a boolean. +func (e *PolicyEvaluator) getAllow(vars rego.Vars) bool { + m, ok := vars["result"].(map[string]interface{}) + if !ok { + return false + } + + allow, ok := m["allow"].(bool) + if !ok { + return false + } + + return allow +} + +// getDeny gets the deny var. It expects an (http status code, message) pair. +func (e *PolicyEvaluator) getDeny(ctx context.Context, vars rego.Vars) *Denial { + m, ok := vars["result"].(map[string]interface{}) + if !ok { + return nil + } + + pair, ok := m["deny"].([]interface{}) + if !ok { + return nil + } + + status, err := strconv.Atoi(fmt.Sprint(pair[0])) + if err != nil { + log.Error(ctx).Err(err).Msg("invalid type in deny") + return nil + } + msg := fmt.Sprint(pair[1]) + + return &Denial{ + Status: status, + Message: msg, + } +} diff --git a/authorize/evaluator/policy_evaluator_test.go b/authorize/evaluator/policy_evaluator_test.go new file mode 100644 index 000000000..c2c7200c0 --- /dev/null +++ b/authorize/evaluator/policy_evaluator_test.go @@ -0,0 +1,112 @@ +package evaluator + +import ( + "context" + "math" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "gopkg.in/square/go-jose.v2" + + "github.com/pomerium/pomerium/pkg/grpc/session" + "github.com/pomerium/pomerium/pkg/grpc/user" + + "github.com/pomerium/pomerium/config" + "github.com/pomerium/pomerium/pkg/cryptutil" +) + +func TestPolicyEvaluator(t *testing.T) { + type A = []interface{} + type M = map[string]interface{} + + signingKey, err := cryptutil.NewSigningKey() + require.NoError(t, err) + encodedSigningKey, err := cryptutil.EncodePrivateKey(signingKey) + require.NoError(t, err) + privateJWK, err := cryptutil.PrivateJWKFromBytes(encodedSigningKey, jose.ES256) + require.NoError(t, err) + + eval := func(t *testing.T, policy *config.Policy, data []proto.Message, input *PolicyRequest) (*PolicyResponse, error) { + store := NewStoreFromProtos(math.MaxUint64, data...) + store.UpdateIssuer("authenticate.example.com") + store.UpdateJWTClaimHeaders(config.NewJWTClaimHeaders("email", "groups", "user", "CUSTOM_KEY")) + store.UpdateSigningKey(privateJWK) + e, err := NewPolicyEvaluator(context.Background(), store, policy) + require.NoError(t, err) + return e.Evaluate(context.Background(), input) + } + + p1 := &config.Policy{ + From: "https://from.example.com", + To: config.WeightedURLs{{URL: *mustParseURL("https://to.example.com")}}, + AllowedUsers: []string{"u1@example.com"}, + } + s1 := &session.Session{ + Id: "s1", + UserId: "u1", + } + s2 := &session.Session{ + Id: "s2", + UserId: "u2", + } + u1 := &user.User{ + Id: "u1", + Email: "u1@example.com", + } + u2 := &user.User{ + Id: "u2", + Email: "u2@example.com", + } + + t.Run("allowed", func(t *testing.T) { + output, err := eval(t, + p1, + []proto.Message{s1, u1, s2, u2}, + &PolicyRequest{ + HTTP: RequestHTTP{Method: "GET", URL: "https://from.example.com/path"}, + Session: RequestSession{ID: "s1"}, + + IsValidClientCertificate: true, + }) + require.NoError(t, err) + assert.Equal(t, &PolicyResponse{ + Allow: true, + }, output) + }) + t.Run("invalid cert", func(t *testing.T) { + output, err := eval(t, + p1, + []proto.Message{s1, u1, s2, u2}, + &PolicyRequest{ + HTTP: RequestHTTP{Method: "GET", URL: "https://from.example.com/path"}, + Session: RequestSession{ID: "s1"}, + + IsValidClientCertificate: false, + }) + require.NoError(t, err) + assert.Equal(t, &PolicyResponse{ + Allow: true, + Deny: &Denial{ + Status: 495, + Message: "invalid client certificate", + }, + }, output) + }) + t.Run("forbidden", func(t *testing.T) { + output, err := eval(t, + p1, + []proto.Message{s1, u1, s2, u2}, + &PolicyRequest{ + HTTP: RequestHTTP{Method: "GET", URL: "https://from.example.com/path"}, + Session: RequestSession{ID: "s2"}, + + IsValidClientCertificate: true, + }) + require.NoError(t, err) + assert.Equal(t, &PolicyResponse{ + Allow: false, + }, output) + }) +} diff --git a/authorize/evaluator/request.go b/authorize/evaluator/request.go deleted file mode 100644 index 8c43c9bb6..000000000 --- a/authorize/evaluator/request.go +++ /dev/null @@ -1,24 +0,0 @@ -package evaluator - -type ( - // Request is the request data used for the evaluator. - Request struct { - HTTP RequestHTTP `json:"http"` - Session RequestSession `json:"session"` - CustomPolicies []string - ClientCA string // pem-encoded certificate authority - } - - // RequestHTTP is the HTTP field in the request. - RequestHTTP struct { - Method string `json:"method"` - URL string `json:"url"` - Headers map[string]string `json:"headers"` - ClientCertificate string `json:"client_certificate"` - } - - // RequestSession is the session field in the request. - RequestSession struct { - ID string `json:"id"` - } -) diff --git a/authorize/evaluator/result.go b/authorize/evaluator/result.go deleted file mode 100644 index db1e7b25d..000000000 --- a/authorize/evaluator/result.go +++ /dev/null @@ -1,116 +0,0 @@ -package evaluator - -import ( - "context" - "fmt" - "strconv" - - "github.com/open-policy-agent/opa/rego" - - "github.com/pomerium/pomerium/config" - "github.com/pomerium/pomerium/internal/log" -) - -// Result is the result of evaluation. -type Result struct { - Status int - Message string - Headers map[string]string - MatchingPolicy *config.Policy - - DataBrokerServerVersion, DataBrokerRecordVersion uint64 -} - -func getMatchingPolicy(vars rego.Vars, policies []config.Policy) *config.Policy { - result, ok := vars["result"].(map[string]interface{}) - if !ok { - return nil - } - - idx, err := strconv.Atoi(fmt.Sprint(result["route_policy_idx"])) - if err != nil { - return nil - } - - if idx >= len(policies) { - return nil - } - - return &policies[idx] -} - -func getAllowVar(vars rego.Vars) bool { - result, ok := vars["result"].(map[string]interface{}) - if !ok { - return false - } - - allow, ok := result["allow"].(bool) - if !ok { - return false - } - return allow -} - -func getDenyVar(vars rego.Vars) []Result { - result, ok := vars["result"].(map[string]interface{}) - if !ok { - return nil - } - - denials, ok := result["deny"].([]interface{}) - if !ok { - return nil - } - - results := make([]Result, 0, len(denials)) - for _, denial := range denials { - denial, ok := denial.([]interface{}) - if !ok || len(denial) != 2 { - continue - } - - status, err := strconv.Atoi(fmt.Sprint(denial[0])) - if err != nil { - log.Error(context.TODO()).Err(err).Msg("invalid type in deny") - continue - } - msg := fmt.Sprint(denial[1]) - - results = append(results, Result{ - Status: status, - Message: msg, - }) - } - return results -} - -func getHeadersVar(vars rego.Vars) map[string]string { - headers := make(map[string]string) - - result, ok := vars["result"].(map[string]interface{}) - if !ok { - return headers - } - - m, ok := result["identity_headers"].(map[string]interface{}) - if !ok { - return headers - } - - for k, v := range m { - headers[k] = fmt.Sprint(v) - } - - return headers -} - -func getDataBrokerVersions(vars rego.Vars) (serverVersion, recordVersion uint64) { - result, ok := vars["result"].(map[string]interface{}) - if !ok { - return 0, 0 - } - serverVersion, _ = strconv.ParseUint(fmt.Sprint(result["databroker_server_version"]), 10, 64) - recordVersion, _ = strconv.ParseUint(fmt.Sprint(result["databroker_record_version"]), 10, 64) - return serverVersion, recordVersion -} diff --git a/authorize/evaluator/store.go b/authorize/evaluator/store.go index cbf7dd3f2..82e1e52d1 100644 --- a/authorize/evaluator/store.go +++ b/authorize/evaluator/store.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "sync" + "sync/atomic" "github.com/google/uuid" "github.com/open-policy-agent/opa/ast" @@ -85,6 +86,8 @@ type Store struct { storage.Store dataBrokerData *dataBrokerData + + dataBrokerServerVersion, dataBrokerRecordVersion uint64 } // NewStore creates a new Store. @@ -124,6 +127,12 @@ func (s *Store) ClearRecords() { s.dataBrokerData.clear() } +// GetDataBrokerVersions gets the databroker versions. +func (s *Store) GetDataBrokerVersions() (serverVersion, recordVersion uint64) { + return atomic.LoadUint64(&s.dataBrokerServerVersion), + atomic.LoadUint64(&s.dataBrokerRecordVersion) +} + // GetRecordData gets a record's data from the store. `nil` is returned // if no record exists for the given type and id. func (s *Store) GetRecordData(typeURL, id string) proto.Message { @@ -161,6 +170,8 @@ func (s *Store) UpdateRecord(serverVersion uint64, record *databroker.Record) { } s.write("/databroker_server_version", fmt.Sprint(serverVersion)) s.write("/databroker_record_version", fmt.Sprint(record.GetVersion())) + atomic.StoreUint64(&s.dataBrokerServerVersion, serverVersion) + atomic.StoreUint64(&s.dataBrokerRecordVersion, record.GetVersion()) } // UpdateSigningKey updates the signing key stored in the database. Signing operations diff --git a/authorize/grpc.go b/authorize/grpc.go index f113e6efe..fa6558436 100644 --- a/authorize/grpc.go +++ b/authorize/grpc.go @@ -2,7 +2,6 @@ package authorize import ( "context" - "encoding/base64" "io/ioutil" "net/http" "net/url" @@ -58,26 +57,34 @@ func (a *Authorize) Check(ctx context.Context, in *envoy_service_auth_v3.CheckRe // take the state lock here so we don't update while evaluating a.stateLock.RLock() - reply, err := state.evaluator.Evaluate(ctx, req) + res, err := state.evaluator.Evaluate(ctx, req) a.stateLock.RUnlock() if err != nil { log.Error(ctx).Err(err).Msg("error during OPA evaluation") return nil, err } defer func() { - a.logAuthorizeCheck(ctx, in, out, reply, u) + a.logAuthorizeCheck(ctx, in, out, res, u) }() - switch { - case reply.Status == http.StatusOK: - return a.okResponse(reply), nil - case reply.Status == http.StatusUnauthorized: - if isForwardAuth && hreq.URL.Path == "/verify" { - return a.deniedResponse(ctx, in, http.StatusUnauthorized, "Unauthenticated", nil) - } - return a.redirectResponse(ctx, in) + if res.Deny != nil { + return a.deniedResponse(ctx, in, int32(res.Deny.Status), res.Deny.Message, nil) } - return a.deniedResponse(ctx, in, int32(reply.Status), reply.Message, nil) + + if res.Allow { + return a.okResponse(res), nil + } + + if isForwardAuth && hreq.URL.Path == "/verify" { + return a.deniedResponse(ctx, in, http.StatusUnauthorized, "Unauthenticated", nil) + } + + // if we're logged in, don't redirect, deny with forbidden + if req.Session.ID != "" { + return a.deniedResponse(ctx, in, http.StatusForbidden, http.StatusText(http.StatusForbidden), nil) + } + + return a.redirectResponse(ctx, in) } func getForwardAuthURL(r *http.Request) *url.URL { @@ -132,40 +139,10 @@ func (a *Authorize) getEvaluatorRequestFromCheckRequest( ID: sessionState.ID, } } - p := a.getMatchingPolicy(requestURL) - if p != nil { - for _, sp := range p.SubPolicies { - req.CustomPolicies = append(req.CustomPolicies, sp.Rego...) - } - } - - ca, err := a.getDownstreamClientCA(p) - if err != nil { - return nil, err - } - req.ClientCA = ca - + req.Policy = a.getMatchingPolicy(requestURL) return req, nil } -func (a *Authorize) getDownstreamClientCA(policy *config.Policy) (string, error) { - options := a.currentOptions.Load() - - if policy != nil && policy.TLSDownstreamClientCA != "" { - bs, err := base64.StdEncoding.DecodeString(policy.TLSDownstreamClientCA) - if err != nil { - return "", err - } - return string(bs), nil - } - - ca, err := options.GetClientCA() - if err != nil { - return "", err - } - return string(ca), nil -} - func (a *Authorize) getMatchingPolicy(requestURL url.URL) *config.Policy { options := a.currentOptions.Load() diff --git a/authorize/grpc_test.go b/authorize/grpc_test.go index f52cdf120..4ec2750b3 100644 --- a/authorize/grpc_test.go +++ b/authorize/grpc_test.go @@ -86,6 +86,7 @@ func Test_getEvaluatorRequest(t *testing.T) { ) require.NoError(t, err) expect := &evaluator.Request{ + Policy: &a.currentOptions.Load().Policies[0], Session: evaluator.RequestSession{ ID: "SESSION_ID", }, @@ -98,7 +99,6 @@ func Test_getEvaluatorRequest(t *testing.T) { }, ClientCertificate: certPEM, }, - CustomPolicies: []string{"allow = true"}, } assert.Equal(t, expect, actual) } @@ -294,6 +294,7 @@ func Test_getEvaluatorRequestWithPortInHostHeader(t *testing.T) { }, nil) require.NoError(t, err) expect := &evaluator.Request{ + Policy: &a.currentOptions.Load().Policies[0], Session: evaluator.RequestSession{}, HTTP: evaluator.RequestHTTP{ Method: "GET", @@ -304,7 +305,6 @@ func Test_getEvaluatorRequestWithPortInHostHeader(t *testing.T) { }, ClientCertificate: certPEM, }, - CustomPolicies: []string{"allow = true"}, } assert.Equal(t, expect, actual) } diff --git a/authorize/log.go b/authorize/log.go index a3a5d024b..811fba394 100644 --- a/authorize/log.go +++ b/authorize/log.go @@ -2,7 +2,6 @@ package authorize import ( "context" - "net/http" "strings" envoy_service_auth_v3 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3" @@ -19,7 +18,7 @@ import ( func (a *Authorize) logAuthorizeCheck( ctx context.Context, in *envoy_service_auth_v3.CheckRequest, out *envoy_service_auth_v3.CheckResponse, - reply *evaluator.Result, u *user.User, + res *evaluator.Result, u *user.User, ) { ctx, span := trace.StartSpan(ctx, "authorize.grpc.LogAuthorizeCheck") defer span.End() @@ -34,15 +33,14 @@ func (a *Authorize) logAuthorizeCheck( evt = evt.Str("path", stripQueryString(hattrs.GetPath())) evt = evt.Str("host", hattrs.GetHost()) evt = evt.Str("query", hattrs.GetQuery()) - // reply - if reply != nil { - evt = evt.Bool("allow", reply.Status == http.StatusOK) - evt = evt.Int("reply-status", reply.Status) - evt = evt.Str("reply-message", reply.Message) + // result + if res != nil { + evt = evt.Bool("allow", res.Allow) + evt = evt.Interface("deny", res.Deny) evt = evt.Str("user", u.GetId()) evt = evt.Str("email", u.GetEmail()) - evt = evt.Uint64("databroker_server_version", reply.DataBrokerServerVersion) - evt = evt.Uint64("databroker_record_version", reply.DataBrokerRecordVersion) + evt = evt.Uint64("databroker_server_version", res.DataBrokerServerVersion) + evt = evt.Uint64("databroker_record_version", res.DataBrokerRecordVersion) } // potentially sensitive, only log if debug mode @@ -60,9 +58,9 @@ func (a *Authorize) logAuthorizeCheck( Request: in, Response: out, } - if reply != nil { - record.DatabrokerServerVersion = reply.DataBrokerServerVersion - record.DatabrokerRecordVersion = reply.DataBrokerRecordVersion + if res != nil { + record.DatabrokerServerVersion = res.DataBrokerServerVersion + record.DatabrokerRecordVersion = res.DataBrokerRecordVersion } sealed, err := enc.Encrypt(record) if err != nil { diff --git a/go.sum b/go.sum index 05c685e53..bd822cf32 100644 --- a/go.sum +++ b/go.sum @@ -774,7 +774,6 @@ golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= -golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420 h1:a8jGStKg0XqKDlKqjLrXn0ioF5MH36pT7Z0BRTqLhbk= golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210520170846-37e1c6afe023 h1:ADo5wSpq2gqaCGQWzk7S5vd//0iyyLeAratkEoG5dLE= golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=