pomerium/authorize/evaluator/evaluator_test.go
Kenneth Jenkins ffca3b36a9
authorize: reuse policy evaluators where possible (#4710)
Add a parameter to evaluator.New() for the previous Evaluator (if any).
If the evaluatorConfig is the same, reuse any PolicyEvaluators for
policies that have not changed from the previous Evaluator.

Use the route IDs along with the policy checksums to determine whether a
given policy has changed. Similarly, add a new cacheKey() method to the
evaluatorConfig to compute a checksum used for determine whether the
evaluatorConfig has changed. (Store this checksum on the Evaluator.)
2023-11-06 13:57:59 -08:00

687 lines
19 KiB
Go

package evaluator
import (
"context"
"encoding/base64"
"net/http"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"
"github.com/pomerium/pomerium/authorize/internal/store"
"github.com/pomerium/pomerium/config"
"github.com/pomerium/pomerium/internal/httputil"
"github.com/pomerium/pomerium/pkg/cryptutil"
"github.com/pomerium/pomerium/pkg/grpc/session"
"github.com/pomerium/pomerium/pkg/grpc/user"
"github.com/pomerium/pomerium/pkg/policy/criteria"
"github.com/pomerium/pomerium/pkg/policy/parser"
"github.com/pomerium/pomerium/pkg/storage"
)
func TestEvaluator(t *testing.T) {
signingKey, err := cryptutil.NewSigningKey()
require.NoError(t, err)
encodedSigningKey, err := cryptutil.EncodePrivateKey(signingKey)
require.NoError(t, err)
privateJWK, err := cryptutil.PrivateJWKFromBytes(encodedSigningKey)
require.NoError(t, err)
eval := func(t *testing.T, options []Option, data []proto.Message, req *Request) (*Result, error) {
ctx := context.Background()
ctx = storage.WithQuerier(ctx, storage.NewStaticQuerier(data...))
store := store.New()
store.UpdateJWTClaimHeaders(config.NewJWTClaimHeaders("email", "groups", "user", "CUSTOM_KEY"))
store.UpdateSigningKey(privateJWK)
e, err := New(ctx, store, nil, options...)
require.NoError(t, err)
return e.Evaluate(ctx, req)
}
policies := []config.Policy{
{
To: config.WeightedURLs{{URL: *mustParseURL("https://to1.example.com")}},
AllowPublicUnauthenticatedAccess: true,
},
{
To: config.WeightedURLs{{URL: *mustParseURL("https://to2.example.com")}},
AllowPublicUnauthenticatedAccess: true,
KubernetesServiceAccountToken: "KUBERNETES",
},
{
To: config.WeightedURLs{{URL: *mustParseURL("https://to3.example.com")}},
AllowPublicUnauthenticatedAccess: true,
EnableGoogleCloudServerlessAuthentication: true,
},
{
To: config.WeightedURLs{{URL: *mustParseURL("https://to4.example.com")}},
AllowedUsers: []string{"a@example.com"},
},
{
To: config.WeightedURLs{{URL: *mustParseURL("https://to5.example.com")}},
SubPolicies: []config.SubPolicy{
{
AllowedUsers: []string{"a@example.com"},
},
},
},
{
To: config.WeightedURLs{{URL: *mustParseURL("https://to6.example.com")}},
AllowedUsers: []string{"example/1234"},
},
{
To: config.WeightedURLs{{URL: *mustParseURL("https://to7.example.com")}},
AllowedDomains: []string{"example.com"},
},
{
To: config.WeightedURLs{{URL: *mustParseURL("https://to8.example.com")}},
AllowAnyAuthenticatedUser: true,
},
{
To: config.WeightedURLs{{URL: *mustParseURL("https://to9.example.com")}},
Policy: &config.PPLPolicy{
Policy: &parser.Policy{
Rules: []parser.Rule{{
Action: parser.ActionAllow,
Or: []parser.Criterion{{
Name: "http_method", Data: parser.Object{
"is": parser.String(http.MethodGet),
},
}},
}},
},
},
},
{
To: config.WeightedURLs{{URL: *mustParseURL("https://to10.example.com")}},
Policy: &config.PPLPolicy{
Policy: &parser.Policy{
Rules: []parser.Rule{{
Action: parser.ActionAllow,
Or: []parser.Criterion{{
Name: "http_path", Data: parser.Object{
"is": parser.String("/test"),
},
}},
}},
},
},
},
{
To: config.WeightedURLs{{URL: *mustParseURL("https://to11.example.com")}},
AllowedUsers: []string{"a@example.com"},
TLSDownstreamClientCA: base64.StdEncoding.EncodeToString([]byte(testCA)),
},
{
To: config.WeightedURLs{{URL: *mustParseURL("https://to12.example.com")}},
AllowedUsers: []string{"a@example.com"},
Policy: &config.PPLPolicy{
Policy: &parser.Policy{
Rules: []parser.Rule{{
Action: parser.ActionDeny,
Or: []parser.Criterion{{Name: "invalid_client_certificate"}},
}},
},
},
},
}
options := []Option{
WithAuthenticateURL("https://authn.example.com"),
WithPolicies(policies),
}
validCertInfo := ClientCertificateInfo{
Presented: true,
Leaf: testValidCert,
}
t.Run("client certificate (default CA)", func(t *testing.T) {
// Clone the existing options and add a default client CA.
options := append([]Option(nil), options...)
options = append(options, WithClientCA([]byte(testCA)),
WithAddDefaultClientCertificateRule(true))
t.Run("missing", func(t *testing.T) {
res, err := eval(t, options, nil, &Request{
Policy: &policies[0],
})
require.NoError(t, err)
assert.Equal(t, NewRuleResult(true, criteria.ReasonClientCertificateRequired), res.Deny)
})
t.Run("invalid", func(t *testing.T) {
res, err := eval(t, options, nil, &Request{
Policy: &policies[0],
HTTP: RequestHTTP{
ClientCertificate: ClientCertificateInfo{Presented: true},
},
})
require.NoError(t, err)
assert.Equal(t, NewRuleResult(true, criteria.ReasonInvalidClientCertificate), res.Deny)
})
t.Run("valid", func(t *testing.T) {
res, err := eval(t, options, nil, &Request{
Policy: &policies[0],
HTTP: RequestHTTP{
ClientCertificate: validCertInfo,
},
})
require.NoError(t, err)
assert.False(t, res.Deny.Value)
})
})
t.Run("client certificate (per-policy CA)", func(t *testing.T) {
// Clone existing options and add the default client certificate rule.
options := append([]Option(nil), options...)
options = append(options, WithAddDefaultClientCertificateRule(true))
t.Run("missing", func(t *testing.T) {
res, err := eval(t, options, nil, &Request{
Policy: &policies[10],
})
require.NoError(t, err)
assert.Equal(t, NewRuleResult(true, criteria.ReasonClientCertificateRequired), res.Deny)
})
t.Run("invalid", func(t *testing.T) {
res, err := eval(t, options, nil, &Request{
Policy: &policies[10],
HTTP: RequestHTTP{
ClientCertificate: ClientCertificateInfo{
Presented: true,
Leaf: testUntrustedCert,
},
},
})
require.NoError(t, err)
assert.Equal(t, NewRuleResult(true, criteria.ReasonInvalidClientCertificate), res.Deny)
})
t.Run("valid", func(t *testing.T) {
res, err := eval(t, options, nil, &Request{
Policy: &policies[10],
HTTP: RequestHTTP{
ClientCertificate: validCertInfo,
},
})
require.NoError(t, err)
assert.False(t, res.Deny.Value)
})
})
t.Run("explicit client certificate rule", func(t *testing.T) {
// Clone the existing options and add a default client CA (but no
// default deny rule).
options := append([]Option(nil), options...)
options = append(options, WithClientCA([]byte(testCA)))
t.Run("invalid but allowed", func(t *testing.T) {
res, err := eval(t, options, nil, &Request{
Policy: &policies[0], // no explicit deny rule
HTTP: RequestHTTP{
ClientCertificate: ClientCertificateInfo{
Presented: true,
Leaf: testUntrustedCert,
},
},
})
require.NoError(t, err)
assert.False(t, res.Deny.Value)
})
t.Run("invalid", func(t *testing.T) {
res, err := eval(t, options, nil, &Request{
Policy: &policies[11], // policy has explicit deny rule
HTTP: RequestHTTP{
ClientCertificate: ClientCertificateInfo{
Presented: true,
Leaf: testUntrustedCert,
},
},
})
require.NoError(t, err)
assert.Equal(t, NewRuleResult(true, criteria.ReasonInvalidClientCertificate), 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",
},
&user.User{
Id: "user1",
Email: "a@example.com",
},
}, &Request{
Policy: &policies[1],
Session: RequestSession{
ID: "session1",
},
HTTP: RequestHTTP{
Method: http.MethodGet,
URL: "https://from.example.com",
},
})
require.NoError(t, err)
assert.Equal(t, "a@example.com", res.Headers.Get("Impersonate-User"))
})
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",
},
&user.User{
Id: "user1",
Email: "a@example.com",
},
}, &Request{
Policy: &policies[2],
Session: RequestSession{
ID: "session1",
},
HTTP: RequestHTTP{
Method: http.MethodGet,
URL: "https://from.example.com",
},
})
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: http.MethodGet,
URL: "https://from.example.com",
},
})
require.NoError(t, err)
assert.True(t, res.Allow.Value)
})
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: http.MethodGet,
URL: "https://from.example.com",
},
})
require.NoError(t, err)
assert.True(t, res.Allow.Value)
})
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: http.MethodGet,
URL: "https://from.example.com",
},
})
require.NoError(t, err)
assert.False(t, res.Allow.Value)
})
})
t.Run("impersonate 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",
},
&session.Session{
Id: "session2",
UserId: "user2",
ImpersonateSessionId: proto.String("session1"),
},
&user.User{
Id: "user1",
Email: "a@example.com",
},
}, &Request{
Policy: &policies[3],
Session: RequestSession{
ID: "session2",
},
HTTP: RequestHTTP{
Method: http.MethodGet,
URL: "https://from.example.com",
},
})
require.NoError(t, err)
assert.True(t, res.Allow.Value)
})
})
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: http.MethodGet,
URL: "https://from.example.com",
},
})
require.NoError(t, err)
assert.True(t, res.Allow.Value)
})
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: http.MethodGet,
URL: "https://from.example.com",
},
})
require.NoError(t, err)
assert.True(t, res.Allow.Value)
})
t.Run("impersonate domain", func(t *testing.T) {
res, err := eval(t, options, []proto.Message{
&session.Session{
Id: "session1",
UserId: "user1",
},
&session.Session{
Id: "session2",
UserId: "user2",
ImpersonateSessionId: proto.String("session1"),
},
&user.User{
Id: "user1",
Email: "a@example.com",
},
}, &Request{
Policy: &policies[6],
Session: RequestSession{
ID: "session1",
},
HTTP: RequestHTTP{
Method: http.MethodGet,
URL: "https://from.example.com",
},
})
require.NoError(t, err)
assert.True(t, res.Allow.Value)
})
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[7],
Session: RequestSession{
ID: "session1",
},
HTTP: RequestHTTP{
Method: http.MethodGet,
URL: "https://from.example.com",
},
})
require.NoError(t, err)
assert.True(t, res.Allow.Value)
})
t.Run("carry over assertion header", func(t *testing.T) {
tcs := []struct {
src map[string]string
jwtAssertionFor string
}{
{map[string]string{}, ""},
{map[string]string{
httputil.CanonicalHeaderKey(httputil.HeaderPomeriumJWTAssertion): "identity-a",
}, "identity-a"},
{map[string]string{
httputil.CanonicalHeaderKey(httputil.HeaderPomeriumJWTAssertionFor): "identity-a",
httputil.CanonicalHeaderKey(httputil.HeaderPomeriumJWTAssertion): "identity-b",
}, "identity-a"},
}
for _, tc := range tcs {
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: http.MethodGet,
URL: "https://from.example.com",
Headers: tc.src,
},
})
if assert.NoError(t, err) {
assert.Equal(t, tc.jwtAssertionFor, res.Headers.Get(httputil.HeaderPomeriumJWTAssertionFor))
}
}
})
t.Run("http method", func(t *testing.T) {
res, err := eval(t, options, []proto.Message{}, &Request{
Policy: &policies[8],
HTTP: NewRequestHTTP(
http.MethodGet,
*mustParseURL("https://from.example.com/"),
nil,
ClientCertificateInfo{},
"",
),
})
require.NoError(t, err)
assert.True(t, res.Allow.Value)
})
t.Run("http path", func(t *testing.T) {
res, err := eval(t, options, []proto.Message{}, &Request{
Policy: &policies[9],
HTTP: NewRequestHTTP(
"POST",
*mustParseURL("https://from.example.com/test"),
nil,
ClientCertificateInfo{},
"",
),
})
require.NoError(t, err)
assert.True(t, res.Allow.Value)
})
}
func TestPolicyEvaluatorReuse(t *testing.T) {
ctx := context.Background()
store := store.New()
policies := []config.Policy{
{To: singleToURL("https://to1.example.com")},
{To: singleToURL("https://to2.example.com")},
{To: singleToURL("https://to3.example.com")},
{To: singleToURL("https://to4.example.com")},
}
options := []Option{
WithPolicies(policies),
}
initial, err := New(ctx, store, nil, options...)
require.NoError(t, err)
assertPolicyEvaluatorReused := func(t *testing.T, e *Evaluator, p *config.Policy) {
t.Helper()
routeID, err := p.RouteID()
require.NoError(t, err)
p1 := initial.policyEvaluators[routeID]
require.NotNil(t, p1)
p2 := e.policyEvaluators[routeID]
assert.Same(t, p1, p2, routeID)
}
assertPolicyEvaluatorUpdated := func(t *testing.T, e *Evaluator, p *config.Policy) {
t.Helper()
routeID, err := p.RouteID()
require.NoError(t, err)
p1 := initial.policyEvaluators[routeID]
require.NotNil(t, p1)
p2 := e.policyEvaluators[routeID]
require.NotNil(t, p2)
assert.NotSame(t, p1, p2, routeID)
}
// If the evaluatorConfig is identical, all of the policy evaluators should
// be reused.
t.Run("identical", func(t *testing.T) {
e, err := New(ctx, store, initial, options...)
require.NoError(t, err)
for i := range policies {
assertPolicyEvaluatorReused(t, e, &policies[i])
}
})
assertNoneReused := func(t *testing.T, o Option) {
e, err := New(ctx, store, initial, append(options, o)...)
require.NoError(t, err)
for i := range policies {
assertPolicyEvaluatorUpdated(t, e, &policies[i])
}
}
// If any of the evaluatorConfig fields besides the Policies change, no
// policy evaluators should be reused.
t.Run("ClientCA changed", func(t *testing.T) {
assertNoneReused(t, WithClientCA([]byte("dummy-ca")))
})
t.Run("ClientCRL changed", func(t *testing.T) {
assertNoneReused(t, WithClientCRL([]byte("dummy-crl")))
})
t.Run("AddDefaultClientCertificateRule changed", func(t *testing.T) {
assertNoneReused(t, WithAddDefaultClientCertificateRule(true))
})
t.Run("ClientCertConstraints changed", func(t *testing.T) {
assertNoneReused(t, WithClientCertConstraints(&ClientCertConstraints{MaxVerifyDepth: 3}))
})
t.Run("SigningKey changed", func(t *testing.T) {
signingKey, err := cryptutil.NewSigningKey()
require.NoError(t, err)
encodedSigningKey, err := cryptutil.EncodePrivateKey(signingKey)
require.NoError(t, err)
assertNoneReused(t, WithSigningKey(encodedSigningKey))
})
t.Run("AuthenticateURL changed", func(t *testing.T) {
assertNoneReused(t, WithAuthenticateURL("authenticate.example.com"))
})
t.Run("GoogleCloudServerlessAuthenticationServiceAccount changed", func(t *testing.T) {
assertNoneReused(t, WithGoogleCloudServerlessAuthenticationServiceAccount("dummy-account"))
})
t.Run("JWTClaimsHeaders changed", func(t *testing.T) {
assertNoneReused(t, WithJWTClaimsHeaders(config.JWTClaimHeaders{"dummy": "header"}))
})
// If some policies have changed, but the evaluatorConfig is otherwise
// identical, only evaluators for the changed policies should be updated.
t.Run("policies changed", func(t *testing.T) {
// Make changes to some of the policies.
newPolicies := []config.Policy{
{To: singleToURL("https://to1.example.com")},
{To: singleToURL("https://to2.example.com"),
AllowedUsers: []string{"user-id-1"}}, // change just the policy itself
{To: singleToURL("https://to3.example.com")},
{To: singleToURL("https://foo.example.com"), // change route ID too
AllowAnyAuthenticatedUser: true},
}
e, err := New(ctx, store, initial, WithPolicies(newPolicies))
require.NoError(t, err)
// Only the first and the third policy evaluators should be reused.
assertPolicyEvaluatorReused(t, e, &newPolicies[0])
assertPolicyEvaluatorUpdated(t, e, &newPolicies[1])
assertPolicyEvaluatorReused(t, e, &newPolicies[2])
// The last policy shouldn't correspond with any of the initial policy
// evaluators.
rid, err := newPolicies[3].RouteID()
require.NoError(t, err)
_, exists := initial.policyEvaluators[rid]
assert.False(t, exists, "initial evaluator should not have a policy for route ID", rid)
assert.NotNil(t, e.policyEvaluators[rid])
})
}
func singleToURL(url string) config.WeightedURLs {
return config.WeightedURLs{{URL: *mustParseURL(url)}}
}
func mustParseURL(str string) *url.URL {
u, err := url.Parse(str)
if err != nil {
panic(err)
}
return u
}