ppl: refactor authorize to evaluate PPL (#2224)

* ppl: refactor authorize to evaluate PPL

* remove opa test step

* add log statement

* simplify assignment

* deny with forbidden if logged in

* add safeEval function

* create evaluator-specific config and options

* embed the headers rego file directly
This commit is contained in:
Caleb Doxsey 2021-05-21 09:50:18 -06:00 committed by GitHub
parent 8c56d64f31
commit dad35bcfb0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 1451 additions and 2211 deletions

View file

@ -34,7 +34,6 @@ GOOS = $(shell $(GO) env GOOS)
GOARCH= $(shell $(GO) env GOARCH) GOARCH= $(shell $(GO) env GOARCH)
MISSPELL_VERSION = v0.3.4 MISSPELL_VERSION = v0.3.4
GOLANGCI_VERSION = v1.34.1 GOLANGCI_VERSION = v1.34.1
OPA_VERSION = v0.26.0
GETENVOY_VERSION = v0.2.0 GETENVOY_VERSION = v0.2.0
GORELEASER_VERSION = v0.157.0 GORELEASER_VERSION = v0.157.0
@ -56,7 +55,6 @@ deps-lint: ## Install lint dependencies
.PHONY: deps-build .PHONY: deps-build
deps-build: ## Install build dependencies deps-build: ## Install build dependencies
@echo "==> $@" @echo "==> $@"
@$(GO) install github.com/open-policy-agent/opa@${OPA_VERSION}
@$(GO) install github.com/tetratelabs/getenvoy/cmd/getenvoy@${GETENVOY_VERSION} @$(GO) install github.com/tetratelabs/getenvoy/cmd/getenvoy@${GETENVOY_VERSION}
.PHONY: deps-release .PHONY: deps-release
@ -107,7 +105,6 @@ lint: ## Verifies `golint` passes.
test: ## Runs the go tests. test: ## Runs the go tests.
@echo "==> $@" @echo "==> $@"
@$(GO) test -tags "$(BUILDTAGS)" $(shell $(GO) list ./... | grep -v vendor | grep -v github.com/pomerium/pomerium/integration) @$(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 .PHONY: spellcheck
spellcheck: # Spellcheck docs spellcheck: # Spellcheck docs

View file

@ -88,7 +88,25 @@ func newPolicyEvaluator(opts *config.Options, store *evaluator.Store) (*evaluato
ctx := context.Background() ctx := context.Background()
_, span := trace.StartSpan(ctx, "authorize.newPolicyEvaluator") _, span := trace.StartSpan(ctx, "authorize.newPolicyEvaluator")
defer span.End() 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 // OnConfigChange updates internal structures based on config.Options

View file

@ -8,6 +8,7 @@ import (
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"sort" "sort"
"strings"
envoy_config_core_v3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" 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" 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 { func (a *Authorize) okResponse(reply *evaluator.Result) *envoy_service_auth_v3.CheckResponse {
var requestHeaders []*envoy_config_core_v3.HeaderValueOption var requestHeaders []*envoy_config_core_v3.HeaderValueOption
for k, v := range reply.Headers { for k, vs := range reply.Headers {
requestHeaders = append(requestHeaders, mkHeader(k, v, false)) requestHeaders = append(requestHeaders, mkHeader(k, strings.Join(vs, ","), false))
} }
// ensure request headers are sorted by key for deterministic output // ensure request headers are sorted by key for deterministic output
sort.Slice(requestHeaders, func(i, j int) bool { sort.Slice(requestHeaders, func(i, j int) bool {
return requestHeaders[i].Header.Key < requestHeaders[j].Header.Value return requestHeaders[i].Header.Key < requestHeaders[j].Header.Value
}) })
return &envoy_service_auth_v3.CheckResponse{ 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{ HttpResponse: &envoy_service_auth_v3.CheckResponse_OkResponse{
OkResponse: &envoy_service_auth_v3.OkHttpResponse{ OkResponse: &envoy_service_auth_v3.OkHttpResponse{
Headers: requestHeaders, Headers: requestHeaders,
@ -47,7 +48,6 @@ func (a *Authorize) deniedResponse(
in *envoy_service_auth_v3.CheckRequest, in *envoy_service_auth_v3.CheckRequest,
code int32, reason string, headers map[string]string, code int32, reason string, headers map[string]string,
) (*envoy_service_auth_v3.CheckResponse, error) { ) (*envoy_service_auth_v3.CheckResponse, error) {
var details string var details string
switch code { switch code {
case httputil.StatusInvalidClientCertificate: case httputil.StatusInvalidClientCertificate:

View file

@ -30,6 +30,7 @@ func TestAuthorize_okResponse(t *testing.T) {
AuthenticateURLString: "https://authenticate.example.com", AuthenticateURLString: "https://authenticate.example.com",
Policies: []config.Policy{{ Policies: []config.Policy{{
Source: &config.StringURL{URL: &url.URL{Host: "example.com"}}, Source: &config.StringURL{URL: &url.URL{Host: "example.com"}},
To: mustParseWeightedURLs(t, "https://to.example.com"),
SubPolicies: []config.SubPolicy{{ SubPolicies: []config.SubPolicy{{
Rego: []string{"allow = true"}, Rego: []string{"allow = true"},
}}, }},
@ -62,45 +63,30 @@ func TestAuthorize_okResponse(t *testing.T) {
}{ }{
{ {
"ok reply", "ok reply",
&evaluator.Result{Status: 0, Message: "ok"}, &evaluator.Result{Allow: true},
&envoy_service_auth_v3.CheckResponse{ &envoy_service_auth_v3.CheckResponse{
Status: &status.Status{Code: 0, Message: "ok"}, Status: &status.Status{Code: 0, Message: "OK"},
}, },
}, },
{ {
"ok reply with k8s svc", "ok reply with k8s svc",
&evaluator.Result{ &evaluator.Result{Allow: true},
Status: 0,
Message: "ok",
MatchingPolicy: &config.Policy{
KubernetesServiceAccountToken: "k8s-svc-account",
},
},
&envoy_service_auth_v3.CheckResponse{ &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", "ok reply with k8s svc impersonate",
&evaluator.Result{ &evaluator.Result{Allow: true},
Status: 0,
Message: "ok",
MatchingPolicy: &config.Policy{
KubernetesServiceAccountToken: "k8s-svc-account",
},
},
&envoy_service_auth_v3.CheckResponse{ &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", "ok reply with jwt claims header",
&evaluator.Result{ &evaluator.Result{Allow: true},
Status: 0,
Message: "ok",
},
&envoy_service_auth_v3.CheckResponse{ &envoy_service_auth_v3.CheckResponse{
Status: &status.Status{Code: 0, Message: "ok"}, Status: &status.Status{Code: 0, Message: "OK"},
}, },
}, },
} }

View file

@ -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
}
}

View file

@ -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
}

View file

@ -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)
})
}

View file

@ -1,5 +1,4 @@
// Package evaluator defines a Evaluator interfaces that can be implemented by // Package evaluator contains rego evaluators for evaluating authorize policy.
// a policy evaluator framework.
package evaluator package evaluator
import ( import (
@ -13,133 +12,180 @@ import (
"github.com/pomerium/pomerium/config" "github.com/pomerium/pomerium/config"
"github.com/pomerium/pomerium/internal/log" "github.com/pomerium/pomerium/internal/log"
"github.com/pomerium/pomerium/internal/urlutil"
"github.com/pomerium/pomerium/pkg/cryptutil" "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 { type Evaluator struct {
custom *CustomEvaluator store *Store
rego *rego.Rego policyEvaluators map[uint64]*PolicyEvaluator
query rego.PreparedEvalQuery headersEvaluators *HeadersEvaluator
policies []config.Policy clientCA []byte
store *Store
} }
// New creates a new Evaluator. // New creates a new Evaluator.
func New(options *config.Options, store *Store) (*Evaluator, error) { func New(ctx context.Context, store *Store, options ...Option) (*Evaluator, error) {
e := &Evaluator{ e := &Evaluator{store: store}
custom: NewCustomEvaluator(store),
policies: options.GetAllPolicies(), cfg := getConfig(options...)
store: store,
} err := e.updateStore(cfg)
jwk, err := getJWK(options)
if err != nil { 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 { if err != nil {
return nil, fmt.Errorf("error loading rego policy: %w", err) return nil, err
} }
authenticateURL, err := options.GetAuthenticateURL() e.policyEvaluators = make(map[uint64]*PolicyEvaluator)
if err != nil { for _, configPolicy := range cfg.policies {
return nil, fmt.Errorf("authorize: invalid authenticate URL: %w", err) 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) e.clientCA = cfg.clientCA
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)
}
return e, nil 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) { 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 { 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 { if err != nil {
return nil, fmt.Errorf("error evaluating rego policy: %w", err) return nil, err
} }
deny := getDenyVar(res[0].Bindings.WithoutWildcards()) isValidClientCertificate, err := isValidClientCertificate(clientCA, req.HTTP.ClientCertificate)
if len(deny) > 0 { if err != nil {
return &deny[0], nil return nil, fmt.Errorf("authorize: error validating client certificate: %w", err)
} }
evalResult := &Result{ policyOutput, err := policyEvaluator.Evaluate(ctx, &PolicyRequest{
MatchingPolicy: getMatchingPolicy(res[0].Bindings.WithoutWildcards(), e.policies), HTTP: req.HTTP,
Headers: getHeadersVar(res[0].Bindings.WithoutWildcards()), Session: req.Session,
} IsValidClientCertificate: isValidClientCertificate,
evalResult.DataBrokerServerVersion, evalResult.DataBrokerRecordVersion = getDataBrokerVersions( })
res[0].Bindings, if err != nil {
) return nil, err
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
} }
if req.Session.ID == "" { headersReq := NewHeadersRequestFromPolicy(req.Policy)
evalResult.Status = http.StatusUnauthorized headersReq.Session = req.Session
evalResult.Message = "login required" headersOutput, err := e.headersEvaluators.Evaluate(ctx, headersReq)
return evalResult, nil if err != nil {
return nil, err
} }
evalResult.Status = http.StatusForbidden res := &Result{
if evalResult.Message == "" { Allow: policyOutput.Allow,
evalResult.Message = http.StatusText(http.StatusForbidden) 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 var decodedCert []byte
// if we don't have a signing key, generate one // if we don't have a signing key, generate one
if options.SigningKey == "" { if cfg.signingKey == "" {
key, err := cryptutil.NewSigningKey() key, err := cryptutil.NewSigningKey()
if err != nil { if err != nil {
return nil, fmt.Errorf("couldn't generate signing key: %w", err) return nil, fmt.Errorf("couldn't generate signing key: %w", err)
@ -150,12 +196,12 @@ func getJWK(options *config.Options) (*jose.JSONWebKey, error) {
} }
} else { } else {
var err error var err error
decodedCert, err = base64.StdEncoding.DecodeString(options.SigningKey) decodedCert, err = base64.StdEncoding.DecodeString(cfg.signingKey)
if err != nil { if err != nil {
return nil, fmt.Errorf("bad signing key: %w", err) return nil, fmt.Errorf("bad signing key: %w", err)
} }
} }
signingKeyAlgorithm := options.SigningKeyAlgorithm signingKeyAlgorithm := cfg.signingKeyAlgorithm
if signingKeyAlgorithm == "" { if signingKeyAlgorithm == "" {
signingKeyAlgorithm = string(jose.ES256) signingKeyAlgorithm = string(jose.ES256)
} }
@ -172,16 +218,12 @@ func getJWK(options *config.Options) (*jose.JSONWebKey, error) {
return jwk, nil return jwk, nil
} }
type input struct { func safeEval(ctx context.Context, q rego.PreparedEvalQuery, options ...rego.EvalOption) (resultSet rego.ResultSet, err error) {
HTTP RequestHTTP `json:"http"` defer func() {
Session RequestSession `json:"session"` if e := recover(); e != nil {
IsValidClientCertificate bool `json:"is_valid_client_certificate"` err = fmt.Errorf("%v", e)
} }
}()
func (e *Evaluator) newInput(req *Request, isValidClientCertificate bool) *input { resultSet, err = q.Eval(ctx, options...)
i := new(input) return resultSet, err
i.HTTP = req.HTTP
i.Session = req.Session
i.IsValidClientCertificate = isValidClientCertificate
return i
} }

View file

@ -2,153 +2,457 @@ package evaluator
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"net/http" "math"
"net/url" "net/url"
"testing" "testing"
"github.com/golang/protobuf/ptypes"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb" "google.golang.org/protobuf/types/known/anypb"
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
"gopkg.in/square/go-jose.v2"
"github.com/pomerium/pomerium/config" "github.com/pomerium/pomerium/config"
"github.com/pomerium/pomerium/pkg/cryptutil"
"github.com/pomerium/pomerium/pkg/grpc/databroker" "github.com/pomerium/pomerium/pkg/grpc/databroker"
"github.com/pomerium/pomerium/pkg/grpc/directory" "github.com/pomerium/pomerium/pkg/grpc/directory"
"github.com/pomerium/pomerium/pkg/grpc/session" "github.com/pomerium/pomerium/pkg/grpc/session"
"github.com/pomerium/pomerium/pkg/grpc/user" "github.com/pomerium/pomerium/pkg/grpc/user"
) )
func TestJSONMarshal(t *testing.T) { func TestEvaluator(t *testing.T) {
opt := config.NewDefaultOptions() signingKey, err := cryptutil.NewSigningKey()
opt.AuthenticateURLString = "https://authenticate.example.com" require.NoError(t, err)
e, err := New(opt, NewStoreFromProtos(0, encodedSigningKey, err := cryptutil.EncodePrivateKey(signingKey)
&session.Session{ require.NoError(t, err)
UserId: "user1", privateJWK, err := cryptutil.PrivateJWKFromBytes(encodedSigningKey, jose.ES256)
},
&directory.User{
Id: "user1",
GroupIds: []string{"group1", "group2"},
},
&directory.Group{
Id: "group1",
Name: "admin",
Email: "admin@example.com",
},
&directory.Group{
Id: "group2",
Name: "test",
},
))
require.NoError(t, err) 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) { eval := func(t *testing.T, options []Option, data []proto.Message, req *Request) (*Result, error) {
sessionID := uuid.New().String() store := NewStoreFromProtos(math.MaxUint64, data...)
userID := uuid.New().String() store.UpdateIssuer("authenticate.example.com")
store.UpdateJWTClaimHeaders(config.NewJWTClaimHeaders("email", "groups", "user", "CUSTOM_KEY"))
ctx := context.Background() store.UpdateSigningKey(privateJWK)
allowedPolicy := []config.Policy{{From: "https://foo.com", AllowedUsers: []string{"foo@example.com"}}} e, err := New(context.Background(), store, options...)
forbiddenPolicy := []config.Policy{{From: "https://bar.com", AllowedUsers: []string{"bar@example.com"}}} require.NoError(t, err)
return e.Evaluate(context.Background(), req)
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},
} }
for _, tc := range tests { policies := []config.Policy{
tc := tc {
t.Run(tc.name, func(t *testing.T) { To: config.WeightedURLs{{URL: *mustParseURL("https://to1.example.com")}},
t.Parallel() AllowPublicUnauthenticatedAccess: true,
},
store := NewStoreFromProtos(0) {
data, _ := ptypes.MarshalAny(&session.Session{ To: config.WeightedURLs{{URL: *mustParseURL("https://to2.example.com")}},
Version: "1", AllowPublicUnauthenticatedAccess: true,
Id: sessionID, KubernetesServiceAccountToken: "KUBERNETES",
UserId: userID, },
IdToken: &session.IDToken{ {
Issuer: "TestEvaluatorEvaluate", To: config.WeightedURLs{{URL: *mustParseURL("https://to3.example.com")}},
Subject: userID, AllowPublicUnauthenticatedAccess: true,
IssuedAt: ptypes.TimestampNow(), 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", To: config.WeightedURLs{{URL: *mustParseURL("https://to6.example.com")}},
}, AllowedUsers: []string{"example/1234"},
}) },
store.UpdateRecord(0, &databroker.Record{ {
Version: 1, To: config.WeightedURLs{{URL: *mustParseURL("https://to7.example.com")}},
Type: "type.googleapis.com/session.Session", AllowedDomains: []string{"example.com"},
Id: sessionID, },
Data: data, {
}) To: config.WeightedURLs{{URL: *mustParseURL("https://to8.example.com")}},
data, _ = ptypes.MarshalAny(&user.User{ AllowedGroups: []string{"group1@example.com"},
Version: "1", },
Id: userID, {
Email: "foo@example.com", To: config.WeightedURLs{{URL: *mustParseURL("https://to9.example.com")}},
}) AllowAnyAuthenticatedUser: true,
store.UpdateRecord(0, &databroker.Record{ },
Version: 1, }
Type: "type.googleapis.com/user.User", options := []Option{
Id: userID, WithAuthenticateURL("https://authn.example.com"),
Data: data, WithClientCA([]byte(testCA)),
}) WithPolicies(policies),
}
e, err := New(&config.Options{ t.Run("client certificate", func(t *testing.T) {
AuthenticateURLString: "https://authn.example.com", t.Run("invalid", func(t *testing.T) {
Policies: tc.policies, res, err := eval(t, options, nil, &Request{
}, store) Policy: &policies[0],
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,
}) })
require.NoError(t, err) require.NoError(t, err)
assert.NotNil(t, res) assert.Equal(t, &Denial{Status: 495, Message: "invalid client certificate"}, res.Deny)
assert.Equal(t, tc.expectedStatus, res.Status)
}) })
} 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 { func mustParseURL(str string) *url.URL {
@ -161,9 +465,22 @@ func mustParseURL(str string) *url.URL {
func BenchmarkEvaluator_Evaluate(b *testing.B) { func BenchmarkEvaluator_Evaluate(b *testing.B) {
store := NewStore() store := NewStore()
e, err := New(&config.Options{
AuthenticateURLString: "https://authn.example.com", policies := []config.Policy{
}, store) {
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) { if !assert.NoError(b, err) {
return return
} }
@ -233,7 +550,8 @@ func BenchmarkEvaluator_Evaluate(b *testing.B) {
b.ResetTimer() b.ResetTimer()
ctx := context.Background() ctx := context.Background()
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {
e.Evaluate(ctx, &Request{ _, _ = e.Evaluate(ctx, &Request{
Policy: &policies[0],
HTTP: RequestHTTP{ HTTP: RequestHTTP{
Method: "GET", Method: "GET",
URL: "https://example.com/path", URL: "https://example.com/path",

View file

@ -8,7 +8,6 @@ import (
lru "github.com/hashicorp/golang-lru" lru "github.com/hashicorp/golang-lru"
"github.com/pomerium/pomerium/authorize/evaluator/opa"
"github.com/pomerium/pomerium/internal/log" "github.com/pomerium/pomerium/internal/log"
) )
@ -65,7 +64,3 @@ func parseCertificate(pemStr string) (*x509.Certificate, error) {
} }
return x509.ParseCertificate(block.Bytes) return x509.ParseCertificate(block.Bytes)
} }
func readPolicy() ([]byte, error) {
return opa.FS.ReadFile("policy/authz.rego")
}

View file

@ -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
}

View file

@ -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"])
})
}

View file

@ -2,8 +2,8 @@
// decisions. // decisions.
package opa package opa
import "embed" import _ "embed" // to embed files
// FS is the filesystem for OPA files. // HeadersRego is the headers.rego script.
//go:embed policy //go:embed policy/headers.rego
var FS embed.FS var HeadersRego string

View file

@ -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)
}

View file

@ -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"}
}

View file

@ -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])
}

View file

@ -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
})
}

View file

@ -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,
}
}

View file

@ -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)
})
}

View file

@ -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"`
}
)

View file

@ -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
}

View file

@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"sync" "sync"
"sync/atomic"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/open-policy-agent/opa/ast" "github.com/open-policy-agent/opa/ast"
@ -85,6 +86,8 @@ type Store struct {
storage.Store storage.Store
dataBrokerData *dataBrokerData dataBrokerData *dataBrokerData
dataBrokerServerVersion, dataBrokerRecordVersion uint64
} }
// NewStore creates a new Store. // NewStore creates a new Store.
@ -124,6 +127,12 @@ func (s *Store) ClearRecords() {
s.dataBrokerData.clear() 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 // GetRecordData gets a record's data from the store. `nil` is returned
// if no record exists for the given type and id. // if no record exists for the given type and id.
func (s *Store) GetRecordData(typeURL, id string) proto.Message { 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_server_version", fmt.Sprint(serverVersion))
s.write("/databroker_record_version", fmt.Sprint(record.GetVersion())) 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 // UpdateSigningKey updates the signing key stored in the database. Signing operations

View file

@ -2,7 +2,6 @@ package authorize
import ( import (
"context" "context"
"encoding/base64"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"net/url" "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 // take the state lock here so we don't update while evaluating
a.stateLock.RLock() a.stateLock.RLock()
reply, err := state.evaluator.Evaluate(ctx, req) res, err := state.evaluator.Evaluate(ctx, req)
a.stateLock.RUnlock() a.stateLock.RUnlock()
if err != nil { if err != nil {
log.Error(ctx).Err(err).Msg("error during OPA evaluation") log.Error(ctx).Err(err).Msg("error during OPA evaluation")
return nil, err return nil, err
} }
defer func() { defer func() {
a.logAuthorizeCheck(ctx, in, out, reply, u) a.logAuthorizeCheck(ctx, in, out, res, u)
}() }()
switch { if res.Deny != nil {
case reply.Status == http.StatusOK: return a.deniedResponse(ctx, in, int32(res.Deny.Status), res.Deny.Message, nil)
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)
} }
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 { func getForwardAuthURL(r *http.Request) *url.URL {
@ -132,40 +139,10 @@ func (a *Authorize) getEvaluatorRequestFromCheckRequest(
ID: sessionState.ID, ID: sessionState.ID,
} }
} }
p := a.getMatchingPolicy(requestURL) req.Policy = 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
return req, nil 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 { func (a *Authorize) getMatchingPolicy(requestURL url.URL) *config.Policy {
options := a.currentOptions.Load() options := a.currentOptions.Load()

View file

@ -86,6 +86,7 @@ func Test_getEvaluatorRequest(t *testing.T) {
) )
require.NoError(t, err) require.NoError(t, err)
expect := &evaluator.Request{ expect := &evaluator.Request{
Policy: &a.currentOptions.Load().Policies[0],
Session: evaluator.RequestSession{ Session: evaluator.RequestSession{
ID: "SESSION_ID", ID: "SESSION_ID",
}, },
@ -98,7 +99,6 @@ func Test_getEvaluatorRequest(t *testing.T) {
}, },
ClientCertificate: certPEM, ClientCertificate: certPEM,
}, },
CustomPolicies: []string{"allow = true"},
} }
assert.Equal(t, expect, actual) assert.Equal(t, expect, actual)
} }
@ -294,6 +294,7 @@ func Test_getEvaluatorRequestWithPortInHostHeader(t *testing.T) {
}, nil) }, nil)
require.NoError(t, err) require.NoError(t, err)
expect := &evaluator.Request{ expect := &evaluator.Request{
Policy: &a.currentOptions.Load().Policies[0],
Session: evaluator.RequestSession{}, Session: evaluator.RequestSession{},
HTTP: evaluator.RequestHTTP{ HTTP: evaluator.RequestHTTP{
Method: "GET", Method: "GET",
@ -304,7 +305,6 @@ func Test_getEvaluatorRequestWithPortInHostHeader(t *testing.T) {
}, },
ClientCertificate: certPEM, ClientCertificate: certPEM,
}, },
CustomPolicies: []string{"allow = true"},
} }
assert.Equal(t, expect, actual) assert.Equal(t, expect, actual)
} }

View file

@ -2,7 +2,6 @@ package authorize
import ( import (
"context" "context"
"net/http"
"strings" "strings"
envoy_service_auth_v3 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3" envoy_service_auth_v3 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3"
@ -19,7 +18,7 @@ import (
func (a *Authorize) logAuthorizeCheck( func (a *Authorize) logAuthorizeCheck(
ctx context.Context, ctx context.Context,
in *envoy_service_auth_v3.CheckRequest, out *envoy_service_auth_v3.CheckResponse, 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") ctx, span := trace.StartSpan(ctx, "authorize.grpc.LogAuthorizeCheck")
defer span.End() defer span.End()
@ -34,15 +33,14 @@ func (a *Authorize) logAuthorizeCheck(
evt = evt.Str("path", stripQueryString(hattrs.GetPath())) evt = evt.Str("path", stripQueryString(hattrs.GetPath()))
evt = evt.Str("host", hattrs.GetHost()) evt = evt.Str("host", hattrs.GetHost())
evt = evt.Str("query", hattrs.GetQuery()) evt = evt.Str("query", hattrs.GetQuery())
// reply // result
if reply != nil { if res != nil {
evt = evt.Bool("allow", reply.Status == http.StatusOK) evt = evt.Bool("allow", res.Allow)
evt = evt.Int("reply-status", reply.Status) evt = evt.Interface("deny", res.Deny)
evt = evt.Str("reply-message", reply.Message)
evt = evt.Str("user", u.GetId()) evt = evt.Str("user", u.GetId())
evt = evt.Str("email", u.GetEmail()) evt = evt.Str("email", u.GetEmail())
evt = evt.Uint64("databroker_server_version", reply.DataBrokerServerVersion) evt = evt.Uint64("databroker_server_version", res.DataBrokerServerVersion)
evt = evt.Uint64("databroker_record_version", reply.DataBrokerRecordVersion) evt = evt.Uint64("databroker_record_version", res.DataBrokerRecordVersion)
} }
// potentially sensitive, only log if debug mode // potentially sensitive, only log if debug mode
@ -60,9 +58,9 @@ func (a *Authorize) logAuthorizeCheck(
Request: in, Request: in,
Response: out, Response: out,
} }
if reply != nil { if res != nil {
record.DatabrokerServerVersion = reply.DataBrokerServerVersion record.DatabrokerServerVersion = res.DataBrokerServerVersion
record.DatabrokerRecordVersion = reply.DataBrokerRecordVersion record.DatabrokerRecordVersion = res.DataBrokerRecordVersion
} }
sealed, err := enc.Encrypt(record) sealed, err := enc.Encrypt(record)
if err != nil { if err != nil {

1
go.sum
View file

@ -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-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-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-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-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 h1:ADo5wSpq2gqaCGQWzk7S5vd//0iyyLeAratkEoG5dLE=
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=