diff --git a/authorize/check_response.go b/authorize/check_response.go index 142279edb..29cd3d148 100644 --- a/authorize/check_response.go +++ b/authorize/check_response.go @@ -4,6 +4,7 @@ import ( "bytes" "net/http" "net/url" + "sort" "strings" envoy_api_v2_core "github.com/envoyproxy/go-control-plane/envoy/api/v2/core" @@ -20,22 +21,14 @@ import ( ) func (a *Authorize) okResponse(reply *evaluator.Result) *envoy_service_auth_v2.CheckResponse { - requestHeaders, err := a.getEnvoyRequestHeaders(reply.SignedJWT) - if err != nil { - log.Warn().Err(err).Msg("authorize: error generating new request headers") + var requestHeaders []*envoy_api_v2_core.HeaderValueOption + for k, v := range reply.Headers { + requestHeaders = append(requestHeaders, mkHeader(k, v, false)) } - - requestHeaders = append(requestHeaders, - mkHeader(httputil.HeaderPomeriumJWTAssertion, reply.SignedJWT, false)) - - requestHeaders = append(requestHeaders, getKubernetesHeaders(reply)...) - - if hdrs, err := a.getGoogleCloudServerlessAuthenticationHeaders(reply); err == nil { - requestHeaders = append(requestHeaders, hdrs...) - } else { - log.Warn().Err(err).Msg("error getting google cloud serverless authentication headers") - } - + // ensure request headers are sorted by key for deterministic output + sort.Slice(requestHeaders, func(i, j int) bool { + return requestHeaders[i].Header.Key < requestHeaders[j].Header.Value + }) return &envoy_service_auth_v2.CheckResponse{ Status: &status.Status{Code: int32(codes.OK), Message: reply.Message}, HttpResponse: &envoy_service_auth_v2.CheckResponse_OkResponse{ @@ -181,22 +174,6 @@ func (a *Authorize) redirectResponse(in *envoy_service_auth_v2.CheckRequest) (*e }) } -func getKubernetesHeaders(reply *evaluator.Result) []*envoy_api_v2_core.HeaderValueOption { - var requestHeaders []*envoy_api_v2_core.HeaderValueOption - if reply.MatchingPolicy != nil && (reply.MatchingPolicy.KubernetesServiceAccountTokenFile != "" || reply.MatchingPolicy.KubernetesServiceAccountToken != "") { - requestHeaders = append(requestHeaders, - mkHeader("Authorization", "Bearer "+reply.MatchingPolicy.KubernetesServiceAccountToken, false)) - - if reply.UserEmail != "" { - requestHeaders = append(requestHeaders, mkHeader("Impersonate-User", reply.UserEmail, false)) - } - for i, group := range reply.UserGroups { - requestHeaders = append(requestHeaders, mkHeader("Impersonate-Group", group, i > 0)) - } - } - return requestHeaders -} - func mkHeader(k, v string, shouldAppend bool) *envoy_api_v2_core.HeaderValueOption { return &envoy_api_v2_core.HeaderValueOption{ Header: &envoy_api_v2_core.HeaderValue{ diff --git a/authorize/check_response_test.go b/authorize/check_response_test.go index f0d7c08c0..893089182 100644 --- a/authorize/check_response_test.go +++ b/authorize/check_response_test.go @@ -3,10 +3,8 @@ package authorize import ( "html/template" "net/http" - "net/http/httptest" "net/url" "testing" - "time" envoy_api_v2_core "github.com/envoyproxy/go-control-plane/envoy/api/v2/core" envoy_service_auth_v2 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v2" @@ -55,29 +53,6 @@ func TestAuthorize_okResponse(t *testing.T) { pe, err := newPolicyEvaluator(opt, a.store) require.NoError(t, err) a.state.Load().evaluator = pe - validJWT, _ := pe.SignedJWT(pe.JWTPayload(&evaluator.Request{ - HTTP: evaluator.RequestHTTP{URL: "https://example.com"}, - Session: evaluator.RequestSession{ - ID: "SESSION_ID", - }, - })) - - originalGCPIdentityDocURL := gcpIdentityDocURL - defer func() { - gcpIdentityDocURL = originalGCPIdentityDocURL - gcpIdentityNow = time.Now - }() - - now := time.Date(2020, 1, 1, 1, 0, 0, 0, time.UTC) - gcpIdentityNow = func() time.Time { - return now - } - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(now.Format(time.RFC3339))) - })) - defer srv.Close() - gcpIdentityDocURL = srv.URL tests := []struct { name string @@ -86,107 +61,45 @@ func TestAuthorize_okResponse(t *testing.T) { }{ { "ok reply", - &evaluator.Result{Status: 0, Message: "ok", SignedJWT: "valid-signed-jwt"}, + &evaluator.Result{Status: 0, Message: "ok"}, &envoy_service_auth_v2.CheckResponse{ Status: &status.Status{Code: 0, Message: "ok"}, - HttpResponse: &envoy_service_auth_v2.CheckResponse_OkResponse{ - OkResponse: &envoy_service_auth_v2.OkHttpResponse{ - Headers: []*envoy_api_v2_core.HeaderValueOption{ - mkHeader("x-pomerium-jwt-assertion", "valid-signed-jwt", false), - }, - }, - }, }, }, { "ok reply with k8s svc", &evaluator.Result{ - Status: 0, - Message: "ok", - SignedJWT: "valid-signed-jwt", + Status: 0, + Message: "ok", MatchingPolicy: &config.Policy{ KubernetesServiceAccountToken: "k8s-svc-account", }, }, &envoy_service_auth_v2.CheckResponse{ Status: &status.Status{Code: 0, Message: "ok"}, - HttpResponse: &envoy_service_auth_v2.CheckResponse_OkResponse{ - OkResponse: &envoy_service_auth_v2.OkHttpResponse{ - Headers: []*envoy_api_v2_core.HeaderValueOption{ - mkHeader("x-pomerium-jwt-assertion", "valid-signed-jwt", false), - mkHeader("Authorization", "Bearer k8s-svc-account", false), - }, - }, - }, }, }, { "ok reply with k8s svc impersonate", &evaluator.Result{ - Status: 0, - Message: "ok", - SignedJWT: "valid-signed-jwt", + Status: 0, + Message: "ok", MatchingPolicy: &config.Policy{ KubernetesServiceAccountToken: "k8s-svc-account", }, - UserEmail: "foo@example.com", - UserGroups: []string{"admin", "test"}, }, &envoy_service_auth_v2.CheckResponse{ Status: &status.Status{Code: 0, Message: "ok"}, - HttpResponse: &envoy_service_auth_v2.CheckResponse_OkResponse{ - OkResponse: &envoy_service_auth_v2.OkHttpResponse{ - Headers: []*envoy_api_v2_core.HeaderValueOption{ - mkHeader("x-pomerium-jwt-assertion", "valid-signed-jwt", false), - mkHeader("Authorization", "Bearer k8s-svc-account", false), - mkHeader("Impersonate-User", "foo@example.com", false), - mkHeader("Impersonate-Group", "admin", false), - mkHeader("Impersonate-Group", "test", true), - }, - }, - }, - }, - }, - { - "ok reply with google cloud serverless", - &evaluator.Result{ - Status: 0, - Message: "ok", - SignedJWT: "valid-signed-jwt", - MatchingPolicy: &config.Policy{ - EnableGoogleCloudServerlessAuthentication: true, - To: mustParseWeightedURLs(t, "https://example.com"), - }, - }, - &envoy_service_auth_v2.CheckResponse{ - Status: &status.Status{Code: 0, Message: "ok"}, - HttpResponse: &envoy_service_auth_v2.CheckResponse_OkResponse{ - OkResponse: &envoy_service_auth_v2.OkHttpResponse{ - Headers: []*envoy_api_v2_core.HeaderValueOption{ - mkHeader("x-pomerium-jwt-assertion", "valid-signed-jwt", false), - mkHeader("Authorization", "Bearer 2020-01-01T01:00:00Z", false), - }, - }, - }, }, }, { "ok reply with jwt claims header", &evaluator.Result{ - Status: 0, - Message: "ok", - SignedJWT: validJWT, + Status: 0, + Message: "ok", }, &envoy_service_auth_v2.CheckResponse{ Status: &status.Status{Code: 0, Message: "ok"}, - HttpResponse: &envoy_service_auth_v2.CheckResponse_OkResponse{ - OkResponse: &envoy_service_auth_v2.OkHttpResponse{ - Headers: []*envoy_api_v2_core.HeaderValueOption{ - mkHeader("x-pomerium-claim-email", "foo@example.com", false), - mkHeader("x-pomerium-jwt-assertion", validJWT, false), - }, - }, - }, }, }, } diff --git a/authorize/evaluator/custom.go b/authorize/evaluator/custom.go index bd9ba1fd9..f8688c8ec 100644 --- a/authorize/evaluator/custom.go +++ b/authorize/evaluator/custom.go @@ -24,6 +24,7 @@ type CustomEvaluatorResponse struct { Allowed bool Denied bool Reason string + Headers map[string]string } // A CustomEvaluator evaluates custom rego policies. @@ -65,7 +66,9 @@ func (ce *CustomEvaluator) Evaluate(ctx context.Context, req *CustomEvaluatorReq vars = make(map[string]interface{}) } - res := &CustomEvaluatorResponse{} + res := &CustomEvaluatorResponse{ + Headers: getHeadersVar(resultSet[0].Bindings.WithoutWildcards()), + } res.Allowed, _ = vars["allow"].(bool) if v, ok := vars["deny"]; ok { // support `deny = true` diff --git a/authorize/evaluator/evaluator.go b/authorize/evaluator/evaluator.go index 983d1e2b7..8514e8142 100644 --- a/authorize/evaluator/evaluator.go +++ b/authorize/evaluator/evaluator.go @@ -5,8 +5,6 @@ package evaluator import ( "context" "encoding/base64" - "encoding/json" - "errors" "fmt" "net/http" "strconv" @@ -26,22 +24,16 @@ type Evaluator struct { query rego.PreparedEvalQuery policies []config.Policy store *Store - - authenticateHost string - jwk *jose.JSONWebKey - signer jose.Signer } // New creates a new Evaluator. func New(options *config.Options, store *Store) (*Evaluator, error) { e := &Evaluator{ - custom: NewCustomEvaluator(store.opaStore), - authenticateHost: options.AuthenticateURL.Host, - policies: options.GetAllPolicies(), - store: store, + custom: NewCustomEvaluator(store.opaStore), + policies: options.GetAllPolicies(), + store: store, } - var err error - e.signer, e.jwk, err = newSigner(options) + jwk, err := getJWK(options) if err != nil { return nil, fmt.Errorf("authorize: couldn't create signer: %w", err) } @@ -51,12 +43,17 @@ func New(options *config.Options, store *Store) (*Evaluator, error) { return nil, fmt.Errorf("error loading rego policy: %w", err) } + store.UpdateIssuer(options.AuthenticateURL.Host) + store.UpdateGoogleCloudServerlessAuthenticationServiceAccount(options.GoogleCloudServerlessAuthenticationServiceAccount) + store.UpdateJWTClaimHeaders(options.JWTClaimsHeaders) store.UpdateRoutePolicies(options.GetAllPolicies()) + store.UpdateSigningKey(jwk) e.rego = rego.New( rego.Store(store.opaStore), rego.Module("pomerium.authz", string(authzPolicy)), rego.Query("result = data.pomerium.authz"), + getGoogleCloudServerlessHeadersRegoOption, ) e.query, err = e.rego.PrepareForEval(context.Background()) @@ -84,25 +81,12 @@ func (e *Evaluator) Evaluate(ctx context.Context, req *Request) (*Result, error) return &deny[0], nil } - payload := e.JWTPayload(req) - - signedJWT, err := e.SignedJWT(payload) - if err != nil { - return nil, fmt.Errorf("error signing JWT: %w", err) - } - evalResult := &Result{ MatchingPolicy: getMatchingPolicy(res[0].Bindings.WithoutWildcards(), e.policies), - SignedJWT: signedJWT, - } - if e, ok := payload["email"].(string); ok { - evalResult.UserEmail = e - } - if gs, ok := payload["groups"].([]string); ok { - evalResult.UserGroups = gs + Headers: getHeadersVar(res[0].Bindings.WithoutWildcards()), } - allow := allowed(res[0].Bindings.WithoutWildcards()) + allow := getAllowVar(res[0].Bindings.WithoutWildcards()) // evaluate any custom policies if allow { for _, src := range req.CustomPolicies { @@ -118,6 +102,9 @@ func (e *Evaluator) Evaluate(ctx context.Context, req *Request) (*Result, error) if cres.Reason != "" { evalResult.Message = cres.Reason } + for k, v := range cres.Headers { + evalResult.Headers[k] = v + } } } if allow { @@ -139,41 +126,23 @@ func (e *Evaluator) Evaluate(ctx context.Context, req *Request) (*Result, error) return evalResult, nil } -// ParseSignedJWT parses the input signature and return its payload. -func (e *Evaluator) ParseSignedJWT(signature string) ([]byte, error) { - object, err := jose.ParseSigned(signature) - if err != nil { - return nil, err - } - return object.Verify(e.jwk.Public()) -} - -// JWTPayload returns the JWT payload for a request. -func (e *Evaluator) JWTPayload(req *Request) map[string]interface{} { - payload := map[string]interface{}{ - "iss": e.authenticateHost, - } - req.fillJWTPayload(e.store, payload) - return payload -} - -func newSigner(options *config.Options) (jose.Signer, *jose.JSONWebKey, error) { +func getJWK(options *config.Options) (*jose.JSONWebKey, error) { var decodedCert []byte // if we don't have a signing key, generate one if options.SigningKey == "" { key, err := cryptutil.NewSigningKey() if err != nil { - return nil, nil, fmt.Errorf("couldn't generate signing key: %w", err) + return nil, fmt.Errorf("couldn't generate signing key: %w", err) } decodedCert, err = cryptutil.EncodePrivateKey(key) if err != nil { - return nil, nil, fmt.Errorf("bad signing key: %w", err) + return nil, fmt.Errorf("bad signing key: %w", err) } } else { var err error decodedCert, err = base64.StdEncoding.DecodeString(options.SigningKey) if err != nil { - return nil, nil, fmt.Errorf("bad signing key: %w", err) + return nil, fmt.Errorf("bad signing key: %w", err) } } signingKeyAlgorithm := options.SigningKeyAlgorithm @@ -183,41 +152,14 @@ func newSigner(options *config.Options) (jose.Signer, *jose.JSONWebKey, error) { jwk, err := cryptutil.PrivateJWKFromBytes(decodedCert, jose.SignatureAlgorithm(signingKeyAlgorithm)) if err != nil { - return nil, nil, fmt.Errorf("couldn't generate signing key: %w", err) + return nil, fmt.Errorf("couldn't generate signing key: %w", err) } log.Info().Str("Algorithm", jwk.Algorithm). Str("KeyID", jwk.KeyID). Interface("Public Key", jwk.Public()). Msg("authorize: signing key") - signerOpt := &jose.SignerOptions{} - signer, err := jose.NewSigner(jose.SigningKey{ - Algorithm: jose.SignatureAlgorithm(jwk.Algorithm), - Key: jwk, - }, signerOpt.WithHeader("kid", jwk.KeyID)) - if err != nil { - return nil, nil, fmt.Errorf("couldn't create signer: %w", err) - } - return signer, jwk, nil -} - -// SignedJWT returns the signature of given request. -func (e *Evaluator) SignedJWT(payload map[string]interface{}) (string, error) { - if e.signer == nil { - return "", errors.New("evaluator: signer cannot be nil") - } - - bs, err := json.Marshal(payload) - if err != nil { - return "", err - } - - jws, err := e.signer.Sign(bs) - if err != nil { - return "", err - } - - return jws.CompactSerialize() + return jwk, nil } type input struct { @@ -238,11 +180,8 @@ func (e *Evaluator) newInput(req *Request, isValidClientCertificate bool) *input type Result struct { Status int Message string - SignedJWT string + Headers map[string]string MatchingPolicy *config.Policy - - UserEmail string - UserGroups []string } func getMatchingPolicy(vars rego.Vars, policies []config.Policy) *config.Policy { @@ -263,7 +202,7 @@ func getMatchingPolicy(vars rego.Vars, policies []config.Policy) *config.Policy return &policies[idx] } -func allowed(vars rego.Vars) bool { +func getAllowVar(vars rego.Vars) bool { result, ok := vars["result"].(map[string]interface{}) if !ok { return false @@ -308,3 +247,23 @@ func getDenyVar(vars rego.Vars) []Result { } 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 +} diff --git a/authorize/evaluator/evaluator_test.go b/authorize/evaluator/evaluator_test.go index 8b6045dd7..fc577d03c 100644 --- a/authorize/evaluator/evaluator_test.go +++ b/authorize/evaluator/evaluator_test.go @@ -12,8 +12,6 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "google.golang.org/protobuf/proto" - "gopkg.in/square/go-jose.v2/jwt" "github.com/pomerium/pomerium/config" "github.com/pomerium/pomerium/pkg/grpc/databroker" @@ -73,212 +71,6 @@ func TestJSONMarshal(t *testing.T) { }`, string(bs)) } -func TestEvaluator_SignedJWT(t *testing.T) { - opt := config.NewDefaultOptions() - opt.AuthenticateURL = mustParseURL("https://authenticate.example.com") - e, err := New(opt, NewStore()) - require.NoError(t, err) - req := &Request{ - HTTP: RequestHTTP{ - Method: http.MethodGet, - URL: "https://example.com", - }, - } - signedJWT, err := e.SignedJWT(e.JWTPayload(req)) - require.NoError(t, err) - assert.NotEmpty(t, signedJWT) - - payload, err := e.ParseSignedJWT(signedJWT) - require.NoError(t, err) - assert.NotEmpty(t, payload) -} - -func TestEvaluator_JWTWithKID(t *testing.T) { - opt := config.NewDefaultOptions() - opt.AuthenticateURL = mustParseURL("https://authenticate.example.com") - opt.SigningKey = "LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSUpCMFZkbko1VjEvbVlpYUlIWHhnd2Q0Yzd5YWRTeXMxb3Y0bzA1b0F3ekdvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFVUc1eENQMEpUVDFINklvbDhqS3VUSVBWTE0wNENnVzlQbEV5cE5SbVdsb29LRVhSOUhUMwpPYnp6aktZaWN6YjArMUt3VjJmTVRFMTh1dy82MXJVQ0JBPT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo=" - e, err := New(opt, NewStore()) - require.NoError(t, err) - req := &Request{ - HTTP: RequestHTTP{ - Method: http.MethodGet, - URL: "https://example.com", - }, - } - signedJWT, err := e.SignedJWT(e.JWTPayload(req)) - require.NoError(t, err) - assert.NotEmpty(t, signedJWT) - - tok, err := jwt.ParseSigned(signedJWT) - require.NoError(t, err) - require.Len(t, tok.Headers, 1) - assert.Equal(t, "5b419ade1895fec2d2def6cd33b1b9a018df60db231dc5ecb85cbed6d942813c", tok.Headers[0].KeyID) -} - -func TestEvaluator_JWTPayload(t *testing.T) { - nowPb := ptypes.TimestampNow() - now, _ := ptypes.Timestamp(nowPb) - tests := []struct { - name string - store *Store - req *Request - want map[string]interface{} - }{ - { - "iss and aud", - NewStore(), - &Request{ - HTTP: RequestHTTP{URL: "https://example.com"}, - }, - map[string]interface{}{ - "iss": "authn.example.com", - "aud": "example.com", - }, - }, - { - "with session", - NewStoreFromProtos(&session.Session{ - Id: "SESSION_ID", - IdToken: &session.IDToken{ - ExpiresAt: nowPb, - IssuedAt: nowPb, - }, - ExpiresAt: nowPb, - }), - &Request{ - HTTP: RequestHTTP{URL: "https://example.com"}, - Session: RequestSession{ - ID: "SESSION_ID", - }, - }, - map[string]interface{}{ - "iss": "authn.example.com", - "jti": "SESSION_ID", - "aud": "example.com", - "exp": now.Unix(), - "iat": now.Unix(), - }, - }, - { - "with service account", - NewStoreFromProtos(&user.ServiceAccount{ - Id: "SERVICE_ACCOUNT_ID", - IssuedAt: nowPb, - ExpiresAt: nowPb, - }), - &Request{ - HTTP: RequestHTTP{URL: "https://example.com"}, - Session: RequestSession{ - ID: "SERVICE_ACCOUNT_ID", - }, - }, - map[string]interface{}{ - "iss": "authn.example.com", - "jti": "SERVICE_ACCOUNT_ID", - "aud": "example.com", - "exp": now.Unix(), - "iat": now.Unix(), - }, - }, - { - "with user", - NewStoreFromProtos(&session.Session{ - Id: "SESSION_ID", - UserId: "USER_ID", - }, &user.User{ - Id: "USER_ID", - Name: "foo", - Email: "foo@example.com", - }), - &Request{ - HTTP: RequestHTTP{URL: "https://example.com"}, - Session: RequestSession{ - ID: "SESSION_ID", - }, - }, - map[string]interface{}{ - "iss": "authn.example.com", - "jti": "SESSION_ID", - "aud": "example.com", - "sub": "USER_ID", - "user": "USER_ID", - "email": "foo@example.com", - }, - }, - { - "with directory user", - NewStoreFromProtos( - &session.Session{ - Id: "SESSION_ID", - UserId: "USER_ID", - }, - &directory.User{ - Id: "USER_ID", - GroupIds: []string{"group1", "group2"}, - }, - &directory.Group{ - Id: "group1", - Name: "admin", - Email: "admin@example.com", - }, - &directory.Group{ - Id: "group2", - Name: "test", - Email: "test@example.com", - }, - ), - &Request{ - HTTP: RequestHTTP{URL: "https://example.com"}, - Session: RequestSession{ - ID: "SESSION_ID", - }, - }, - map[string]interface{}{ - "iss": "authn.example.com", - "jti": "SESSION_ID", - "aud": "example.com", - "groups": []string{"group1", "group2", "admin", "test"}, - }, - }, - { - "with impersonate", - NewStoreFromProtos( - &session.Session{ - Id: "SESSION_ID", - UserId: "USER_ID", - ImpersonateEmail: proto.String("user@example.com"), - ImpersonateGroups: []string{"admin", "test"}, - }, - ), - &Request{ - HTTP: RequestHTTP{URL: "https://example.com"}, - Session: RequestSession{ - ID: "SESSION_ID", - }, - }, - map[string]interface{}{ - "iss": "authn.example.com", - "jti": "SESSION_ID", - "aud": "example.com", - "email": "user@example.com", - "groups": []string{"admin", "test"}, - }, - }, - } - - for _, tc := range tests { - tc := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - e, err := New(&config.Options{ - AuthenticateURL: mustParseURL("https://authn.example.com"), - }, tc.store) - require.NoError(t, err) - assert.Equal(t, tc.want, e.JWTPayload(tc.req)) - }) - } -} - func TestEvaluator_Evaluate(t *testing.T) { sessionID := uuid.New().String() userID := uuid.New().String() diff --git a/authorize/google_cloud_serverless.go b/authorize/evaluator/google_cloud_serverless.go similarity index 61% rename from authorize/google_cloud_serverless.go rename to authorize/evaluator/google_cloud_serverless.go index 2c4cfd5d5..08889ded5 100644 --- a/authorize/google_cloud_serverless.go +++ b/authorize/evaluator/google_cloud_serverless.go @@ -1,4 +1,4 @@ -package authorize +package evaluator import ( "context" @@ -12,19 +12,49 @@ import ( "sync" "time" - envoy_api_v2_core "github.com/envoyproxy/go-control-plane/envoy/api/v2/core" + "github.com/open-policy-agent/opa/ast" + "github.com/open-policy-agent/opa/rego" + "github.com/open-policy-agent/opa/types" "golang.org/x/oauth2" "golang.org/x/sync/singleflight" "google.golang.org/api/idtoken" - - "github.com/pomerium/pomerium/authorize/evaluator" ) +// GCP pre-defined values. var ( - gpcIdentityTokenExpiration = time.Minute * 45 // tokens expire after one hour according to the GCP docs - gcpIdentityDocURL = "http://metadata/computeMetadata/v1/instance/service-accounts/default/identity" - gcpIdentityNow = time.Now - gcpIdentityMaxBodySize int64 = 1024 * 1024 * 10 + GCPIdentityTokenExpiration = time.Minute * 45 // tokens expire after one hour according to the GCP docs + GCPIdentityDocURL = "http://metadata/computeMetadata/v1/instance/service-accounts/default/identity" + GCPIdentityNow = time.Now + GCPIdentityMaxBodySize int64 = 1024 * 1024 * 10 + + getGoogleCloudServerlessHeadersRegoOption = rego.Function2(®o.Function{ + Name: "get_google_cloud_serverless_headers", + Decl: types.NewFunction( + types.Args(types.S, types.S), + types.NewObject(nil, types.NewDynamicProperty(types.S, types.S)), + ), + }, func(bctx rego.BuiltinContext, op1 *ast.Term, op2 *ast.Term) (*ast.Term, error) { + serviceAccount, ok := op1.Value.(ast.String) + if !ok { + return nil, fmt.Errorf("invalid service account type: %T", op1) + } + + audience, ok := op2.Value.(ast.String) + if !ok { + return nil, fmt.Errorf("invalid audience type: %T", op2) + } + + headers, err := getGoogleCloudServerlessHeaders(string(serviceAccount), string(audience)) + if err != nil { + return nil, fmt.Errorf("failed to get google cloud serverless headers: %w", err) + } + var kvs [][2]*ast.Term + for k, v := range headers { + kvs = append(kvs, [2]*ast.Term{ast.StringTerm(k), ast.StringTerm(v)}) + } + + return ast.ObjectTerm(kvs...), nil + }) ) type gcpIdentityTokenSource struct { @@ -34,7 +64,7 @@ type gcpIdentityTokenSource struct { func (src *gcpIdentityTokenSource) Token() (*oauth2.Token, error) { res, err, _ := src.singleflight.Do("", func() (interface{}, error) { - req, err := http.NewRequestWithContext(context.Background(), "GET", gcpIdentityDocURL+"?"+url.Values{ + req, err := http.NewRequestWithContext(context.Background(), "GET", GCPIdentityDocURL+"?"+url.Values{ "format": {"full"}, "audience": {src.audience}, }.Encode(), nil) @@ -49,7 +79,7 @@ func (src *gcpIdentityTokenSource) Token() (*oauth2.Token, error) { } defer func() { _ = res.Body.Close() }() - bs, err := ioutil.ReadAll(io.LimitReader(res.Body, gcpIdentityMaxBodySize)) + bs, err := ioutil.ReadAll(io.LimitReader(res.Body, GCPIdentityMaxBodySize)) if err != nil { return nil, err } @@ -62,7 +92,7 @@ func (src *gcpIdentityTokenSource) Token() (*oauth2.Token, error) { return &oauth2.Token{ AccessToken: strings.TrimSpace(res.(string)), TokenType: "bearer", - Expiry: gcpIdentityNow().Add(gpcIdentityTokenExpiration), + Expiry: GCPIdentityNow().Add(GCPIdentityTokenExpiration), }, nil } @@ -127,18 +157,7 @@ func getGoogleCloudServerlessTokenSource(serviceAccount, audience string) (oauth return src, nil } -func (a *Authorize) getGoogleCloudServerlessAuthenticationHeaders(reply *evaluator.Result) ([]*envoy_api_v2_core.HeaderValueOption, error) { - if reply.MatchingPolicy == nil || !reply.MatchingPolicy.EnableGoogleCloudServerlessAuthentication { - return nil, nil - } - - serviceAccount := a.currentOptions.Load().GoogleCloudServerlessAuthenticationServiceAccount - var hostname string - if len(reply.MatchingPolicy.To) > 0 { - hostname = reply.MatchingPolicy.To[0].URL.Hostname() - } - audience := fmt.Sprintf("https://%s", hostname) - +func getGoogleCloudServerlessHeaders(serviceAccount, audience string) (map[string]string, error) { src, err := getGoogleCloudServerlessTokenSource(serviceAccount, audience) if err != nil { return nil, err @@ -149,7 +168,7 @@ func (a *Authorize) getGoogleCloudServerlessAuthenticationHeaders(reply *evaluat return nil, err } - return []*envoy_api_v2_core.HeaderValueOption{ - mkHeader("Authorization", "Bearer "+tok.AccessToken, false), + return map[string]string{ + "Authorization": "Bearer " + tok.AccessToken, }, nil } diff --git a/authorize/google_cloud_serverless_test.go b/authorize/evaluator/google_cloud_serverless_test.go similarity index 75% rename from authorize/google_cloud_serverless_test.go rename to authorize/evaluator/google_cloud_serverless_test.go index 61077cb67..6e5006c96 100644 --- a/authorize/google_cloud_serverless_test.go +++ b/authorize/evaluator/google_cloud_serverless_test.go @@ -1,4 +1,4 @@ -package authorize +package evaluator import ( "net/http" @@ -9,34 +9,38 @@ import ( "github.com/stretchr/testify/assert" ) -func TestGCPIdentityTokenSource(t *testing.T) { - originalGCPIdentityDocURL := gcpIdentityDocURL +func withMockGCP(t *testing.T, f func()) { + originalGCPIdentityDocURL := GCPIdentityDocURL defer func() { - gcpIdentityDocURL = originalGCPIdentityDocURL - gcpIdentityNow = time.Now + GCPIdentityDocURL = originalGCPIdentityDocURL + GCPIdentityNow = time.Now }() now := time.Date(2020, 1, 1, 1, 0, 0, 0, time.UTC) - gcpIdentityNow = func() time.Time { + GCPIdentityNow = func() time.Time { return now } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "Google", r.Header.Get("Metadata-Flavor")) assert.Equal(t, "full", r.URL.Query().Get("format")) - assert.Equal(t, "example", r.URL.Query().Get("audience")) _, _ = w.Write([]byte(now.Format(time.RFC3339))) })) defer srv.Close() - gcpIdentityDocURL = srv.URL + GCPIdentityDocURL = srv.URL + f() +} - src, err := getGoogleCloudServerlessTokenSource("", "example") - assert.NoError(t, err) +func TestGCPIdentityTokenSource(t *testing.T) { + withMockGCP(t, func() { + src, err := getGoogleCloudServerlessTokenSource("", "example") + assert.NoError(t, err) - token, err := src.Token() - assert.NoError(t, err) - assert.Equal(t, "2020-01-01T01:00:00Z", token.AccessToken) + token, err := src.Token() + assert.NoError(t, err) + assert.Equal(t, "2020-01-01T01:00:00Z", token.AccessToken) + }) } func Test_normalizeServiceAccount(t *testing.T) { diff --git a/authorize/evaluator/opa/policy/authz.rego b/authorize/evaluator/opa/policy/authz.rego index e7e38f5cb..c90ef1e89 100644 --- a/authorize/evaluator/opa/policy/authz.rego +++ b/authorize/evaluator/opa/policy/authz.rego @@ -37,7 +37,7 @@ directory_user = du { } group_ids = gs { - gs = session.impersonate_group_ids + gs = session.impersonate_groups gs != null } else = gs { gs = directory_user.group_ids @@ -145,6 +145,138 @@ deny[reason] { 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 = session.expires_at.seconds +} 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)) +} else = [] { + true +} + +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], +] + +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 + available := data.jwt_claim_headers[_] + available == claim_key + + # create the header key and value + k := concat("", ["x-pomerium-claim-", claim_key]) + 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] @@ -195,12 +327,20 @@ allowed_route_regex(input_url_obj, policy) { re_match(policy.regex, input_url_obj.path) } -parse_url(str) = {"scheme": scheme, "host": host, "path": 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 == "" } @@ -255,6 +395,13 @@ get_databroker_group_emails(ids) = gs { gs := [email | id := ids[i]; group := data.databroker_data["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 { diff --git a/authorize/evaluator/opa/policy/authz_test.rego b/authorize/evaluator/opa/policy/authz_test.rego index 36c8a24f1..fccc0cdc1 100644 --- a/authorize/evaluator/opa/policy/authz_test.rego +++ b/authorize/evaluator/opa/policy/authz_test.rego @@ -1,5 +1,11 @@ package pomerium.authz +get_google_cloud_serverless_headers(serviceAccount, audience) = h { + h := { + "Authorization": "Bearer xxx" + } +} + test_email_allowed { allow with data.route_policies as [{ "source": "example.com", diff --git a/authorize/evaluator/opa/policy/statik.go b/authorize/evaluator/opa/policy/statik.go index 2237d8d8e..0aef1578f 100644 --- a/authorize/evaluator/opa/policy/statik.go +++ b/authorize/evaluator/opa/policy/statik.go @@ -9,6 +9,6 @@ import ( const Rego = "rego" // static asset namespace func init() { - data := "PK\x03\x04\x14\x00\x08\x00\x08\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\n\x00 \x00authz.regoUT\x05\x00\x01\x80Cm8\xc4XA\x97\x9b8\x12>\x9b_Q\xcb\x1c\x06v\x08\x9dNz\x0e\xeb&\xbe\xde54^\x0b\xb1\xae(i\x98\x8a\x0bQ\xfbi\xe2\xb7\x8a\xca\xf8\x03\x95[V\xd0\x9b\xa2\x10-\xd7~\x1a\x81e\xda\xd9\x89Y\x19\x01o\xab*D[\x7fY\x99g\xef\x11h\xa5\xe8\xb7\xe0\xd0\x9b\xf9`\xff\x9fj\xff\xe1\x11 h\xd9R\xef\xd1\xf3\xd0!XA\x8bc\xed3H\x99\xc0|TT\"\x9d=\x91\xba\xa1R N4\xcd\x10\x909\xcc\xdacf/C\xe2\x0c\xc3\xc3\x90\x94L\xd2B\x0b\xb9\xcb\xba\xe0\x94\x86X\xf9\x1cf{\x9dO\x8aQ9\xc1\xf5E\xc9\x9cC`\x18\xac\xb5\x14m\x93\xb1\x12\x13zmR{\x8d\x8fS\xde\xed\xa1\x06s\xa4\xd7\x91\x1e\xce@\xfc\x05\xc1$=\"\xa4\xb0:\x10)\xc9..\x04/\x88\x0e\xf6:\xa2\xd18\xd5\x99\x137\x0b\xe3\xa4\xa6\xea \x12F0 \xa35a\x95\x8b\x0bC\xcf#U\xb5\xafh\xa5\xa8 \xe3\x86\x0c*\x18\x0d\x07n\xa9\x1aI\x1e\xbcp\x05\xed\xe8)9\x8c\xd7\x91\x98\x19<%\xc5\xca&+*\xc2\xea#\xd1\xc3\x97\xb1\x15\xd4\x1b\xbe\xbbq\x05>\x9a\"\xd7\x87`T p\xb5\xf9\xbek0\xdf\x19\x0b`f\xf4`\x87\x0b\x0d\xe3\x94\xf0\x16f\xf9\x1a$Z8J\xd4$K\xa7T\xb3\xf2,\xbd\xac\xce\x96\xe7\xaf\xf7\xdb\x16\xb5\xb3\x02\xddA\x17\xc6\xa5\x8c\xf1\xae \x06\x87l\x89`\xa2\x8c&\xf6?\x0dODa\xcc\xe2\x89\x0cf\x03\xff$BD\xe6LK\"w\xc0\xca\x06l\xf9s\xf0a\x97W_\xc2Y\xff$\xd5\xad\xe4\n\xf4\x86\xdaV\x03j\xa2\x8b\x0dnV\xa6,z\xe7\xf4\x1f\x19\xb6\x1e\xd0\xf7*N\xff\xf2\xe0-\x8e\xc6\x96+H\xf0\xff3\x984c\xe5}\x04\xf6\xf3\xbb\xee\x1f\xa6\xbb\x16lT\xde\xc1\x80\xc9Q\xe0\xbb\xad7M^\xa7\xfb4\x18\x823\x17g\xd2\xbf\x1f\xcdD\xfe;\xb2k\x88T\x14\x07\x1c\xe7\xcc:w\"\xa0D+\x0bG#\xca\xee\xb5\x8e\xc1\xb8\xdd\xb2\xfbs\xc1Do\xce\x84J\xba\xa6\xb3j\xc7\xde\x9f\xa6\x8c\x81p\xf6X;\x1a\x81o\x85\xfc\x08|?4\x1b\xbd\xff\x12\x8a\xed\x06\xba\xb0c\xd3S\xd1\xed\xce\x16\x12\x8ef-\xde\x08e\x8e(C\x0df\xf8\x98\xef\xc9\xe9\x98\xe3k\x85N\x07\xe2\xf9\x8a\xfb@h\"\xb5\xfa\xc4\xc6\x99\x10cr\xf4\x1ac+91\xd3'Rh\x96\x05\xd1\x9b/8\xf7,\xa5\x9dc=s\xa27h\xe7\xd8\xb9c\xbb\xa7\x92|\xce\xb0\x919\xed\xces\xd5v\x0eI\x9a\x99z\xd9\xe7\xa7\x81D\x13\x8e\x99i:\xa4\xb3\xd2\x12\x0b\xe6\x83\xaf\x8a\x0d\xad\xa9\xbf\x04\xfb\x10\x81\x8fI\xeb/\x01\xff\xfa\x18.\x01\xffLO\x99d\xd1\x1ej!\x92|\xc2\xafx\xe60\xd6\xe3[\xc6K,\xd8\x99\xd2\x92\xf1u\xa6\xda\xdcp\xccx\xf0[p\xbd\x0c\x82\xeb\xa5.\x9a\xe4\x874\xbc\xc6\xc2\x99\xa8\xf4:\\^\\\x84\xd7A\xf2\xebE\xfaC\x18$\xbf^\x7f\x97\xfe5\xfc-\x02\xa5e\x04\x97!VSoag\x0d\xb8\x905\xa9\xd8\x1fv\x99\x99\xb4\xe8(\x18\x1f'>w\xce\xfa\x17>z\xa0\xb4j9W\x90\xa8&\x1e\x8e}\x06\xd5\x98J\xd7\xd5\xb76?lxY\x9az\x8b-~}\xc0\xed\xf2\x1eF\xfdz'3P\x18A\xd2\xc2gh\xe7u\x0e\xf1h$L\xb2\xf4q\xecS\xdf\x80\x8f\xbc:\x9fN\xa7\xe0 \x84\xfa#\xe3\x1c\xa5\xae\xb5\xffjFV\xfe \x84\xf6\xe7\xf9i>N\xe3?\xc5i@%\x19q9\xc8\xa6O \xe4H\xd9\xba?q\xba\x8d \x0f\xbbc\xbd-b\x01 \xdd\xb7\x1c\xcf\x11[{\xfb\x93\x90\xbb\xd4[\xe4\xf6\xcd)yy\x04\xe4\xae\xbf\xe12\xc2\xc6\x99\x80lU\xe8\xbc\xe6\xe6\x95lm\xcb\xb3\x82|\xdbw\x96'\xae\x8cX\xa9B\xf7*\x0b\xcf\x83\xf8\x05>\x03+\xf1\x8d\x95*a\xe9;\xdbA\xed\xaf\xb0\x9f~q\xf7O\x94\xf7\xd3\x84\x95\xe9;0\x16\x96][\x16\xe3\xdb<\xcf\xee\xcej\x8a\xa8\xed\xd3_\x94\xa95\xb1\xa7j^\xbbn\xfdp\x99 LA\xc5\xee\xa83i\x11\xe4\xad\x06\xd56\x8d\x90ZA!\xf8\x16\x0f\xfb|m\n/\xe3pGw\n\xb4\x00E\x1b\"\xb1\x11\xac\x84\xb8k\x1be\xba\x80\xb5\x80RP\xc5\xbf\xdf\xab\x00I\x8bVbc\x15\x81\x12\xf0\x89\xc2\x86\xc8\x12\nQR P\xb1\x9aiZ\x02o\xeb\x9cJ\x10\xb7p\xf1\xbd\xf2\x9c\xdbV\xb3\xa1\xde\xd1]\x04%\xbd5\x0b\x83T-5\x85\x91\xae\xb1\xd4\xaaC\xa960\xff\xe2P\xa8{H_\xaf\xc5\xe5(A\x8d\xf6\x1e\x95\xbcN#xx\x0c\xbd\x85\xb51D^:\xc0\xcb\xd4\xd29\\\x8c~5\xab\xb7\xe7\xb3\x12o\xc6\xb81\xa7Y\xf2o\x1c\xe0\x9boG\xfe\xea\x9b\x93\x17o\xc7\xb01\xf5Y\x1f\xdf:\xc0\xb7\xdf\xce\xc7\x1f\xff,\x1f\xc5\xd5\x186\xf6p6\x14W\x0e\xf0j>\x14S\xa2\x83\xe5\xe6=z\xff\x0f\x00\x00\xff\xffPK\x07\x08KK\xb1\xe5\x0b\x07\x00\x00{\x1c\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0f\x00 \x00authz_test.regoUT\x05\x00\x01\x80Cm8\xecYMo\xdb8\x13>K\xbfB\xe0)y_GF\xd2\xee\x1e\x02\x04\xdb\xa0X\x14{\xd8M\xd1\xb4\xa7 \x10hijs+\x89*E\xa1Q\x02\xff\xf7\xc5\x90\x94,\xcbTC9\xb67\xed&\x87D\"\xe7\xeb\x99gF\xfcHA\xe3/t\x0eA\xc13\x10\xac\xcaBZ\xc9\xc5\xbd\xefK(e\x04\x19eiD\xd3\x94\x7f\x83$x\xf0=\xf5\x18|cr\x11$T\xd2P\xf0JBT\xf0\x94\xc5\x0c\xca\x80\x96\xc1\xcd\x83\xefy\xa4\xe4\x95\x88\x81\x9c\x07\x04\xeehV\xa4\x10\xc6<#\x13\x9c2\xd6\xa2\xaa\x04Q\x92\xf3\xe0\x86\xdc\xbd\xe9\n\xddN|oy\xeb{^\xc7\x0d\xfe\x9a \xfe\x05D\x84\x8f\xe8\xe7\x81\xc8\xba\x80p\xce\xf9<\x05Z\xb0R)\x9fc\x90\xe8\x1f\xca\x92\xf1<\xbc\xd6\x7fq\xbc\x19;U/,\xc1\xe0\xda\xa1I@0\x9eH\x0f\xe3\xe3)Y.'\xca\x16\xbe\x85\x9fJ\x10JQ\xcf\xadL\xe8\xf7I@T\xaepd\x1d\x8e\xb6\xb2\\\xb6\x80X^T2\\HYh\x18\x95PZ8p>\x9d\xae\xa9\xf6tL\xb4Z\xad\x07`\xe9/\x0dg,+@\x94<\xa7\x12\x0c\x7f9\x97]\x0es.\x83\xff\x12\x8f\x93\x80l\xe4\x04gk\x0bO?\x01\xdb\xfb\xee\xd6\xfa\x85\xe5\x83\xb3<\x17\xbc*\xf6\xc4\xac\xb2\xad\xa9U\x8f\xa7\xcf\x87\xd3]\xb2\xe5\x91\x84 \x88%\x17\xb5\x83)\x9dp\x96\xac\xe7e\xd3\xd2;\x9cQ\x06\x8c\xcc\xca\x96\x198x\xadt\xbb@s\xbb\xe7\x05\xe0\x19\x17\x90\xdbG\xa1'\xd5\xc7s\xb6\"\xfe\xa5\x10\x9fV\x88\x87\xf9\x82\x9d\xbd\x14\xe0K\x01\x962JxFY\xbe\xa7\xa2\xd3\xc6uV\x9e\xe5\x86h\x97%s@\xd2\xba\x8d`\x08\xdc\xef\xf2\xf5\xec\x89\x1c\xfe\x86\xe4\\\xfe\x04\x9b[\x0b\xe3\xfbo\xd9^\xea^\xc8>\x14\xd9I\x11\xc5)e\xd9\xbe6\x03+\x07:\xff<\x83P\xbd+\xda)\xe6bFn\x97O\xe4{\x1b\xae\x87\xa3\xc2\x80\xf0\xe7`,\x18\x0b#\xbe\xa6\xbe\xe7\x99\"o)\xb0D\xa3K\xb2\xa0r\x81\x02Sj\x06\\>\xb5\x9e\xaa\xc4\xf1>f=\x1fU{Q\x91s\x9e\xc3\x9b\xf6^s\xcd\xd1\xed\xf8TOg\x1b\xc9F_\xddL\xafw\xda\xdf\x1c\xd6{m\x95~uQ\x93@\xce\xf6\xb2\xa2\xadR0\xe3\xb3\xef\xdd\xd68c\xdf\x19\xf2\xa2\x9a\xa5,\xdeq\xeb_\xa2\x89\xf7\xca\xf2\xa7\x9cVr\x01\xb9d1\x95\x90\\\xc61\x94\x98\n)*\xd8\x1e{/\xfa\xd1\xc4Y\n\xdbR\xd1\x02>\xb3;]\xd3\xf5 &x\xb0\xb2m\xb4\xda\xfbg\xd3\x8d{\xb2\xb6o\x14\x13~'q\xa6 wL\xbc[f\xb6!}\x1a6!O;0b.\xca\x08yJ\xd9|!\xff\x150\x1ey{\xf5\xe1Z\x93\xd8\x04\xe2R\xdf\x8a\xfb\x0c\xe4\x82\xab~\xbdz\xff\xf1\x8f\xab\xbf\xaeMM\x0c\xa7\xc1\x08,\x80&:*e\xc8#W\x82\xcdY\xae\xa2\xc4\xb5\x8c\xebW\x15 \xd6\x98\xaa\xa4\x93\xb7<\x97\x82\xa7'\x1f\xe0k\x05\xa5<\xf9\xb3q\x7fC\xde\xfd\xfe\xd1H\xeb\x0d\xc8P\x8aG\xb7\xdan\xb2\xfc\xe3\xe7\xb1\xa0\xa2\x84\xa8\x12)z\xc2?\xe7\x17A;vd\x0b\x11\xfdOqY\xfd\xedkI\x8e\x95RX\xc6\x0b\xc8 \xb8\xb8\xd0\xa0\x88\x1e]\xf0R\xaa\xb1n\xda\xf4\x14\xea\xab\xa9\x959\xd2\x8bI\xc6\xc5p\\2.\xfe?>\xb6F\xeb\x89\xf15\xb5\xa1\xcbJ\x17Q\xdb\xdc\xcd\xb8-wd\xa2\xf6t\xb6\xa2[\x1e\x8f\xd7\xb7\xadA[\x9a)\xb7\xb13\xdd\x95\xa1\xc7\xedL\xdd\x0c9D\xa4-\xb5_\x89Ak\\\xcc\xbfO\x97\xbd\x1a\xf4\xea\xec^\x0d\x9d\xd5\xdc\x11\"M2\x96\xab\xb2TU\xd93\xa2f\x1d!Zbh\xd5\x07\xd0a[\xb8ck\xf6\xde\x8e\xc8,JN \xac)i\xcf\x16\xdb$dC\xd9\x9e\x0e\x01s\x18\xc1\xb5\x12G\xbb\xe1\xff\x1c3b\x01\xd6\x1a1\x93\xc6\x96;\xb8\xd6\xc0\xcd]}\x7f\xdb\x05WV3\xbdH\xd6\x88\xe9\x0e?\xb9sXaVK\xe1\xd1\xf8\x85\xb3\xd27\x18gf_\xd2za\xa0\xe6qQz\x18V{\xa5\x0f\xe1\x032gJ\xe6\xb5\x91QK\xf2\xb1\x8f\xb1_\\\xe0\xb6\xad\xf1\xac\x0di\xd1\xa5\xef{u\x1f\x9b9q:\xa3\xeb\x9eP\x13\xe5%q\xc3gQ\xb4 \\\x93R\xe1'\x1b\x18k\x8d\xb1\xf5\xaeMiQ\xc4x\xdf\xc7\xa8\xff]\xe0\x0c\xb1\xfb\xdf\x05}\xc3\xef\x86pS\xcf\x02\xb0+\xa4B\x9fo\xe0\xbb\xd7\xf8Z\xdf\xda\x92\x16]\xed_\xa8\x00\xdb=\xd1\xc6\xe8\xd1\x03\xa1\xca\xdd)\xfa \xbaoM\xf7\x0c\xeb\x9cYt\x1eq2 \x88\n\xf9\x95\xcd\xdd\x88\xf0\xbav\x1e\x0f\xf45\x8a\xfe\x82\xbf~\x1d\x11r8\x0b\xe3F\x0c\xeb'\x84\xf0\xf3\x1a\xee\xf5\x91\xce\xd70\xaf\xa3\xb5\xa3\xa1j\xcd\x1d\x9fw\xd4\x19\xe62\xaf/\xbb\x8e\xccE\xe6\xe6qf\xf4\x0d\xdd\xcend\x7f\xd0\x8b\xd6\x01\x0ew~\xa0\x1aG\xe3\xfeP\xff\x13\x00\x00\xff\xffPK\x07\x08\xcc\xf8\x88\xe2\xe0\x04\x00\x00\xc8(\x00\x00PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\x00\x00!(KK\xb1\xe5\x0b\x07\x00\x00{\x1c\x00\x00\n\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xb4\x81\x00\x00\x00\x00authz.regoUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\x00\x00!(\xcc\xf8\x88\xe2\xe0\x04\x00\x00\xc8(\x00\x00\x0f\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xb4\x81L\x07\x00\x00authz_test.regoUT\x05\x00\x01\x80Cm8PK\x05\x06\x00\x00\x00\x00\x02\x00\x02\x00\x87\x00\x00\x00r\x0c\x00\x00\x00\x00" + data := "PK\x03\x04\x14\x00\x08\x00\x08\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\n\x00 \x00authz.regoUT\x05\x00\x01\x80Cm8\xc4ZKs\xdb8\xf2?\x93\x9f\xa2\xff\xcca\xc4\xff\xd0t\x1c{\x0e\xab\x946\x9b\x9d\xda\x9a\x9d}ej\x92\xd4\x1eX\x1c\x0eD\xc2\x12,\x8a\xe0\x02\xa0,\xc5\xc9w\xdfj\x80\x0f\x88\x0fEv\x92\xda\x8bE\x02\xfd\xf8u\x03ht7]\x92tCV\x14J\xbe\xa5\x82U\xdb\x90Tj\xfd\xc1u3zK\xaa\\\x01\xc9s~\x0f\x0b\xb8%\xb9\xa4\xae+x\xa5hR\xf2\x9c\xa5\x87\x84e{\x98/\xe0\x96 \xa9\x12MH\xb3\xa4O1cEY\xa9p\xadT\x19V\"\xf7\x8fe \x7fF\x14 \xadAFe\xd4\x97\x12\xbb\xae\xa4R2^\xc0\x02$<\xb8\x8e\x84\x05\xf0\xe5\x1dMU\xb2\xa2j\xa6\x85\xe0\x9f\xa5\xe0\x1b*\x12|\x8c\xcaF\xb0~S0\xe7\x008v\xd6J\xf0\xaaLX\x86\x1bz\xa5\xb7\xf6\n\x1f\xc7\xac\xd3\xa4R\x13\x0c\x84Z\xac\xc7\xee\x0f[\x05\xe3\x8cQ<@#14\x10!\xc8!Ly\x91\x125ke\x04\xbdq\xaa\x12\xcbi\x86\xac [*;\x16?\x80Q2\xba%,\xb7\xe9|\xdfuI\x9e\xb7\xe1,\xe3[\xc2\n\x0d\x06\x05\xf4\x86gv\x9c\xeaqvV\xd8\x8cf\xf4\x14\x1f\xfak\xc0\xa6\x07Oq\xb1\xacL\xd2\x9c\xb0\xed\x80\xb5\x9b\xe9\xf33\x99t+\xcb\x8a\x15r\xea`8\x1b[x\xed*\x1f\xfe\x08\xcf]\xf7Y}/\x94\xd52g\xa9k^\x1e\\\xc7V\x10\xbe\xc6\xd1_4\xc5\xfb\x02\xaf\x15Z(\x96\x12E\xb3\xd7iJ\xa5\x84\xc5\x02\x9a%o\x04\xa6\\H(\x05\xbd\xcd\xd9j\xad&\x04\xff\xf8\xe6\xd7\xb7FxC\xd8\x8ar\xac\xdbfK\xd5\x9ag8\xe5\xbd\xf9\xe5\xdd\xcfo\xfe\xf5\xd6s\x1dc\x9f9s!\x9e9\x8baMIF\x85\x0c\xc03\x00/~\xe4\x85\x12<\xbf\xf8\x95\xfe\xa7\xa2R]\xfcSK\xf4\x02\x88b\xdfx\xe2Lyo\x04[\xb1\xc2f\xb4l&\xc5\x01\x8e\xdc\x03\xb8\xda\xa7\x9c\xfa\xba8\xbc\xb6\x19\xde\xeb\x08\xd7\xb8\xa0\x17\x06\xf0\xb4y\x9e\xadpy\xd0\x1a@\xafh\xa7\xa7\xe0\n\xfa[\xc2u\xf4\xf1\xd5\x94\xa8a\xb0Q\xa3$\x1e\x13\xcd\xb2\xb3\xe4\xb2\xec\x1c\xa1\xfa\xcc|N\x9e\xe4[ZS:\xe6\x90EI\x8c1\xc9\x0c\x0dO\xa6=}l\x81\xb5\xeb\xfb>\x1a\xea\x1d\x03?\x11:\x8d\xaci]=;O\xdb8\x1d\x9b\x9fl\xb7 jg9\xba&u\xb4I +\xea\x808\xebvK\x00#a42\xbf\xb1\x7f\xc2\x0b}\x14\x8fD0\xe9\xf8G\x01\"b\xc9\x94 \xe2\x00,+\xc1\x84\xcf\x0e\x12\x11\xb4\x0e\xa9\x8d\xbc\xd9x,\x8e\x12\xebZ6CZ\xd3\x13%i\xefZb\xda0\\'\xefP\x89\xdc\x82\x99\xf2B\xe9[\xea8\x05\x0f\xc0\xbb\x0c\x1b\x96KOK\xcahq\x88\x04%\x92\x17\xfa*6\x8fx3\xdf\xfc\xe1\x87\x00.\x92\x13y?w\xe7\xee\xe2\xfdp\xb5\xdb\xf5\xec\x91\xfe\x84\xeb\xe8\x993a\x0cM\xa4\x12\xe8\x07\xed\xdf\xd9p\xcd\xb5\xc2x\xb2\xc6\xd5\xf5|\x92\xe6\xbc\xca\x12\x8c\x85T\xe4T\xca\xc4*\x04\x18/\x92:J&u\x94\xec\x1aOM\xdf\xecir\xa6\xe2\xc4\x94\xb4SK\xfb\x97\x82,s\xfa\x93\xe6\xfc\x11\x19\xdf\xb6|\xaf\x8f@\xb8N\xd4\xdc\x9e\x01$z\xab\x9a{\x16G\x93\x92\x0buT\xa0\x86\xefx\xf4<\x0e\xdf\xff\xfa\x8f\xf0\xaf\\*\xdfuH\x951Z\xa4\xd4\xd4\xa9fC\xe8\xfd\x80\xd7\xb3\x9c_^z\x014\x1ab\xbf\xdeO\xb8`\x9f1k\xf64'\x06\xd0\x00\xf2\xa7\xfa*,Cvuh\x1d8\x1f\x9c\xfb\xf5\x95\xde\xf5\x91\xb7\xbfhr\xb2\x8b\xbb{uA\xa4\xc4dH\xef\xfe\xee\xc4\xc6\xb1\xeb\xac_\x18\x8eM\x00\xbb\x18E8\x91>\xee\x89\x8e\x03\xe6q2\x1a8\xd6|\x1b\x14\\\xc7y\x06\xbc\xc8\x0f\xc0\x8a4\xaf2\xbc\x86\xb9\xa4\xd0\xa0\x16\xa6\xfe\xa5\x19\xa6\xc6xC\xeb\x02\xd5q\xc8\x8e\xb0\x1cW\xbf\xdd\x8e\xad\xb6\xc6b\xa3\xb4#\\,\xa0\x05k\xf4\xa6\x82b\xda\x8fb\x0d\x0fl\xe8\x01H\x91\x19\x1f\xb9\x8e\xb3\x19,\xb8\xe5+-\xed\xc2\x0b:\xb1\xb8\xf4\xce\xaeY\xfb\xb1\xc3j\xf9\xc0\xc73\xea:\xebk\xa4\x1f\x063\xd7Y\xdf\x1cy\x1b\x8c\xe0\xd3\x1b*\xda\xc4Z\xe8\xa0\x855\xfd\xb2\xbe\n`\xfd\xc2\x0f`}\x8d\x7fn\xfca\\_\xb7e\xb1\xa0\xaa\x12\x85\xd4>\xd3\xddw\xd8\x12\x95\xaeY\xb12\x11\xd1=\xa7%\x8f\xe9\xad\x0fM\xfb\xdej\xe9?\xb8\xce`\x0c]\x80\xbf\x1fA\x17_,\xdb\x07`\xa6_\xd6\xbf0\xde\xc8g\xd9>~ GH\x06\xe5H\xdd\x90\x8a\xa3\xe7q[\x1c\x1d\x13'6\x9d.\n\x9b\xd1\x84/\xef\xbaH\xd2f\xec\xda8]\xfdZ\x1e\x90\xbc\x12\xa9%\x11y[\xa9}\xe2R\xd0[\xb6?\x97\x98\xa8\xf5\x99\xa4\x82\xae\xe8\xa4\xd8\xbe\xf5\xa7!\xa3#\xac\xce\x93\x19\x0d\xc03L^\x00\x9e\xe7\xeb\xf6\x97\xf7-\x04\xd7\x97\xb6\x19\x1b_\x8a:\x90\x1b\x12\xbf\xb7j\xba\x94Bx\xc7\x12\xf4\xf0\x10\xef\xc9\xe5\x98\xc2k\x98N;\xe2\xcb\x057\x8ePD(y\xcf\xfa;!\xc4\xcd\xd1H\x0c\x0d\xe7\xc8J\x9f\xd8B\x93(\x88Z\x7f\xc6\xb8/\x12Z\x1b\xd6 'j\x8dz\x86\xc6\x0d\xf5\x9e\xda\xe4S\x8a5\xcfis\xbeTlm\x90\xa0\x89\x8e\x97\xcd\xfe\xd4$\xc1\x88az\x99\xba\xed,\x95\xc0\x80\xf9\xe0\xc9tM\xb7\xd4\x9b\x83y\x08\xc0\xc3M\xeb\xcdu\xf6Q\xbfa\x12R\x8f\x98\x8c\xc7xv\x0e\xf8\xa3\x93\x84( Z\x01\x86Q\x90{\x9c\x8da\x01\x1aSx\xcb\x8a\x0c\xc3xs}\xc9j\xa9\x91'\xc5\xec\xf7\xd9\xab\xf9l\xf6j\xae\xd22\xfa>\xf6_a8\x8dd\xfc\xca\x9f_^\xfa\xaff\xd1o\x97\xf1\xf7\xfe,\xfa\xed\xd5\xb3\xf8\xff\xfd\xdf\x03\x90J\x04p\xe5c\x8c\xed\xe5a\xc34lm\xf2-\xb3\xe2Pp\xb1%9\xfb`\x8e\xa8\xdeR5P\xcb?\x1ds\xed\xa5\xc8\x98\x84C:\xf1m\x9bQ\x1a\x887\xf7\xfc\x1aGC\xb3\x00Y\xe6LY\xf3]\xea\xdc\x93e\"\x07\x9a\x84\xbbS\xe0\xb3wss\xadw\xcc\x08\xda\x1a\x91w\xa9s]\xa9D\xb7\xbf\xa6\xa9\x91\xac\xa6n\x1b\xe8\xfd^c\xddW4oz\x07\xea\x9b\xd2\xd8QOz\x7fBK\xcdg\x82\xbd\xde\xd9/\\g\x1f]\xc5\xf8X71QtN\xb7\xb4P(\xf9Mw\xef\x98\xf2\xca\"\xbc\x8a\x0d\x1c\xabm\xfcTT\xd7\xe7\xa3\xe2/\xfat}L\x93\xe0_X\x84/\xbe\x1e\xf8\x9b\xaf\x0e\x9e_\xf7\xc9\xfa\xd0'm\xbc\xb6\x08\xaf\xbf\x9e\x8d?\xfc\xafl\xe47}\xb2\xbe\x85\x93\xae\xb8\xb1\x08o\xa6]1\xc6zt\xdc\xdcO\xee\x7f\x03\x00\x00\xff\xffPK\x07\x08\xa5\xe6\x0dy\xba\n\x00\x00\xcb(\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0f\x00 \x00authz_test.regoUT\x05\x00\x01\x80Cm8\xecY]o\xdb6\x17\xbe\x96~\x05\xc1\xab\xe4}\x1d\x19I\xbb]\x180V\xaf\x18\x8a]l)\xfaq\x15\x04\x02-\x9dZ\\%Q%\xa9\xd5\x8e\xe1\xff>\x1cR\x92eYj%\xd7\xf6\xd2.\xbe\xb0%\xf2|=\xe79\xfct\xc6\x82\x8fl\x01$\x13 H\x9e'\x1e\xcbu\xf4\xe0\xba\x0b\xd0\xfeB\x88E\x0c~\x10\x8b<\xf4\x15\xc8\xbfA\xc6\xa0\x94\x1f\x01\x0bA\xaa\x0bl\xe2\x01\xcc\x82@\xe4\xa9\x1e\x11\x96\x87\x1c\xd2\x00.\xc9\x94Dd\xed\x12BHD&\xd3\xe2\x11?t\x96\xebHH\xfe\xc04\x17)\x9d\x10\xfa+0 \x92,\x97Kj\xa46\xee\xc6u5(\xedC\xc2x\xec\xb38\x16\x9f!$k\xd71\x8f\xe43\xd7\x11 \x99f\x9e\x14\xb9\x06?\x131\x0f8(\xc2\x14\xb9[\xbb\x8eC\x95\xc8e\x00h\x1c\x96,\xc9b\xf0\x02\x91\xd0\x11v\x15\xd6\xfc\\\x81TtB\xee\xe8\xf2E]\xe8~\xe4:\x9b{\xd7qjn\xf0k.\xc5G\x90>>\xa2\x9f5\xd5\xab\x0c<\x9b \x96qe\x94'\x18$\xfa\x07\xa5\xb8H\xbd\xb7\xf6\x17\xdb\xcb\xb6k\xf3\xc2C\x0c\xaej\x1a\x11\x8a\xf1\xf8\xb6\x19\x1f\xaf\xe9f32\xb6\xf0\xcd{\xaf@\x1aE\xdb\xb75a\xdfG\x84\x9a\\a\xcb.\x1cke\xb3\xa9\x00\xf14\xcb\xb5\x17i\x9dY\x18\xb94Z\xd80\x19\x8fwT\x1b:E\xb4V\xad\x01`\xcb\x19O2\x90J\xa4LC\xc1_*t\x9d\xc3Th\xf2_\xe2qD\xe8^N\xb0w\xd5\xc2\xd3\x0f\xc0\xf6\xa9G\xeb\xea\x89\xe5\xb3\xb3\xbc\x90\"\xcfN\xc4\xac\xb1m\xa95\x8f\xd7\x8f\x87\xd3c\xb2\xe5\xd0\x90K\x08\xb4\x90\xab\x1e\xa6l\xc2y\xb8\x9b\x97}K\xaf\xb0\xc7\x18(d\xb6\xb6\x8a\x86\xb3\xd7J}\x14XnO\xbc\x00<\xe2\x02\xea7)4\xa4\x9axn\xb6\xc4?\x15\xe2\xb7\x15\xe2yf\xb0\x9b\xa7\x02|*@\xa5\xfdP$\x8c\xa7'*:k\xdcf\xe5Qn\x88\x8eY2g$\xad>\x10\n\x02O\xbb|=z\"\xbb\xe7\x90T\xe8\x1f`s\xdb\xc2\xf8\xe9\x87l#uOd\x9f\x8b\xec0\xf3\x83\x98\xf1\xe4T\x9b\x81\xad\x03\x9b\x7f\x91\x80g\xde\x0d\xed\x0cs1\xa7\xf7\x9bo\xe4\xfb\x10\xae\xbb\xa3\xc2\x80\xf0s6\x16\n\x0b\x03fS\xd7q\x8a\"\xaf(h\x89\xc6\x96d\xc6t\x84\x02cV4\xf4\x99j\x1dS\x89\xc3}\xcc\x1b>\xf2\xea\xa2\"\x15\"\x85\x17\xd5\xad\xea\x8e\xa3\xfb\xe1\xa9\x1e\xcf\xf7\x92\x8d\xbe\xea\x99\xde\x1di\x7f \xd8\x1dk\x8dk\xd5\x10R~\x92\x15m\x9b\x82\xb9\x98\x7f\xe9\xb6\xa67\xf6\xa3!\xcf\xf2y\xcc\x83#\x0f\xfd\x19\x9axm,\xbfOY\xae#H5\x0f\x98\x86p\x16\x04\xa00\x15Z\xe6p8\xf6F\xf4\x83\x89k)\xec\x96\x8a\x96\xf0\x81/mM\xaf\xae0\xc1\x9d\x95\xddFk\xfb\xf8\xd9w\xd3?Y\x87\x0f\x94\"\xfcZ\xe2\x8aAxd\xe2\xfbe\xe6\x10\xd2\xc7^\x19\xf2\xb8\x06#\x10R\xf9\xc8S\xcc\x17\x91\xfeW\xc08\xf4\xe5\xed\x9b\xb7\x96\xc42\x90>\xf5m\xb8O@G\xc2\x8c\xd7\xdb\xd7\xef~\xbf\xfd\xf3mQ\x13\xddi(\x04\x8a\xbf\x9b\xca\x9d\x8eCo%_\xf0\xd4D\x89k\x99\xb0\xaf&@\xac1SIW/E\xaa\xa5\x88\xaf\xde\xc0\xa7\x1c\x94\xbe\xfa\xa3t\x7fG_\xfd\xf6\xae\x90\xb6\x1b\x90\xae\x14\x0f\x1ej\xc7\xc9\xf2\xf7\x9f\xc7\x8cI\x05~.c\xf4\x84?\x93)\xa9\xda.\xdaBD\xffc\\V\x7f\xf9\xa4\xe8\xa5Q\xf2T\x10A\x02d:\xb5\xa0\xa8m\x8d\x84\xd2\xa6\xad\x9e6\xdb\x85\xfa\xa6kk\x8e6b\xd2A\xd6\x1d\x97\x0e\xb2\xff\x0f\x8f\xad\xd4\xfa\xc6\xf8\xca\xda\xb0ee\x8b\xa8\x1a\xdce{[\xee\xe8\xc8\xec\xe9\xda\x8ans9\\\xbfm\x0d:\xd0\x8c:\xc4\xce\xf8X\x86\xbeng\xdc\xcfP\x8f\x88\xac\xa5j\x96\xe8\xb4&\xe4\xe2\xcbt\xb5W\x83]\x9d\xfbWCm5\xef \x91\x85 OMY\x9a\xaal\x181\xbd=!\xb6\xc4P\xa9w\xa0\xc3a\xd1\x1f[\xb9\xf7\xee\x89\xacE\xa9\x17\x88\xd6\x94Tg\x8bC\x12\xb2\xa7\xdc\x9e\x0e \x0b\x18\xc0\xb5\x11G\xbb\xde\xffzf\xa4\x05Xe\xa4\xe8,l\xf5\x07W\x19\xb8[\xae\x1e\xee\xeb\xe0T>\xb7\x8b\xe4\n1-q\xca]\xc0\x16\xb3Y\n/\x86/\x9c\xb9\xbd\xc1\xb8)\xf6%\x95\x17\x0e\xa6\x1f\x17\xa5u\xb7\xda3{\x08\xef\x90\xb912\xcf\x0b\x19\xb3$_\xba\x18\xfbt\x8a\xdb\xb6\xd2\xb35dE7\xae\xeb\xac\x9a\xd8\x8a\x13got\xf5\x13jh\xbc\x84\xfd\xf0\xb5(\xb6 \xdc\x912\xe1\x87{\x18W\x16c\xe5\xdd\x9a\xb2\xa2\x88\xf1\xa1\x89\xd1\xfe]\xd0\x1bb\xfd\xdf\x05{\xc3\xdf\x0f\xe1\xbe^\x0b\xc0\xba\x90 }\xb1\x87\xef\xc1\xe2\xab|[KVt\xbb\x7fa\x12\xda\xee\x89\xf6Z/\xd6\x94\x19w\xd7\xe8\x87\xd4\xdf\xca\xd1\xd3\xads\xd3\xa2\xf3\x15'#BM\xc8\xcf\xda\xdc\x0d\x08\xafn\xe7\xeb\x81>G\xd1\x9f\xf0\xeb\xe7\x01!{s/(\xc5\xb0~<\xf0>\xec\xe0\xdem\xa9\xcd\x86\xe9\xca\xdf9\x1a\x9a\xa1y\xe4\xf3\x8e9\xc3\xcc\xd2\xd5\xac\xee\xa8\xb8\xc8\xdc?\xce\x0c\xbe\xa1;\xda\x8d\xecwz\xd1\xda\xc1\xe1\xd1\x0fT\xc3h<\x1d\xea\x7f\x02\x00\x00\xff\xffPK\x07\x08\x08\x1b\xb1\x1d*\x05\x00\x00F)\x00\x00PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\x00\x00!(\xa5\xe6\x0dy\xba\n\x00\x00\xcb(\x00\x00\n\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xb4\x81\x00\x00\x00\x00authz.regoUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\x00\x00!(\x08\x1b\xb1\x1d*\x05\x00\x00F)\x00\x00\x0f\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xb4\x81\xfb\n\x00\x00authz_test.regoUT\x05\x00\x01\x80Cm8PK\x05\x06\x00\x00\x00\x00\x02\x00\x02\x00\x87\x00\x00\x00k\x10\x00\x00\x00\x00" fs.RegisterWithNamespace("rego", data) } diff --git a/authorize/evaluator/opa_test.go b/authorize/evaluator/opa_test.go index c065de378..5d3777025 100644 --- a/authorize/evaluator/opa_test.go +++ b/authorize/evaluator/opa_test.go @@ -4,13 +4,18 @@ import ( "context" "encoding/json" "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/timestamppb" + "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" @@ -20,15 +25,28 @@ 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(policies []config.Policy, data []proto.Message, req *Request, isValidClientCertificate bool) rego.Result { authzPolicy, err := readPolicy("/authz.rego") require.NoError(t, err) store := NewStoreFromProtos(data...) + store.UpdateIssuer("authenticate.example.com") + store.UpdateJWTClaimHeaders([]string{"email", "groups", "user"}) store.UpdateRoutePolicies(policies) + store.UpdateSigningKey(privateJWK) r := rego.New( rego.Store(store.opaStore), rego.Module("pomerium.authz", string(authzPolicy)), rego.Query("result = data.pomerium.authz"), + getGoogleCloudServerlessHeadersRegoOption, ) q, err := r.PrepareForEval(context.Background()) require.NoError(t, err) @@ -46,11 +64,168 @@ func TestOPA(t *testing.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([]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([]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([]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) + 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", + }, + ) + 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)), + }, + }, + &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", + }, + ) + assert.Equal(t, M{ + "aud": "from.example.com", + "iss": "authenticate.example.com", + "jti": "session1", + "exp": 1609462861.0, + "iat": 1612141261.0, + "sub": "user1", + "user": "user1", + "email": "a@example.com", + "groups": A{"group1", "group1name"}, + }, payload) + }) + }) t.Run("email", func(t *testing.T) { t.Run("allowed", func(t *testing.T) { res := eval([]config.Policy{ { - Source: &config.StringURL{URL: mustParseURL("https://from.example.com")}, + Source: &config.StringURL{URL: mustParseURL("https://from.example.com:8000")}, To: config.WeightedURLs{ {URL: *mustParseURL("https://to.example.com")}, }, @@ -71,7 +246,7 @@ func TestOPA(t *testing.T) { }, HTTP: RequestHTTP{ Method: "GET", - URL: "https://from.example.com", + URL: "https://from.example.com:8000", }, }, true) assert.True(t, res.Bindings["result"].(M)["allow"].(bool)) diff --git a/authorize/evaluator/request.go b/authorize/evaluator/request.go index bf41ca1cc..8c43c9bb6 100644 --- a/authorize/evaluator/request.go +++ b/authorize/evaluator/request.go @@ -1,15 +1,5 @@ package evaluator -import ( - "net/url" - - "google.golang.org/protobuf/types/known/timestamppb" - - "github.com/pomerium/pomerium/internal/directory" - "github.com/pomerium/pomerium/pkg/grpc/session" - "github.com/pomerium/pomerium/pkg/grpc/user" -) - type ( // Request is the request data used for the evaluator. Request struct { @@ -32,69 +22,3 @@ type ( ID string `json:"id"` } ) - -type sessionOrServiceAccount interface { - GetId() string - GetExpiresAt() *timestamppb.Timestamp - GetIssuedAt() *timestamppb.Timestamp - GetUserId() string - GetImpersonateEmail() string - GetImpersonateGroups() []string - GetImpersonateUserId() string -} - -func (req *Request) fillJWTPayload(store *Store, payload map[string]interface{}) { - if u, err := url.Parse(req.HTTP.URL); err == nil { - payload["aud"] = u.Hostname() - } - - if s, ok := store.GetRecordData("type.googleapis.com/session.Session", req.Session.ID).(*session.Session); ok { - req.fillJWTPayloadSessionOrServiceAccount(store, payload, s) - } - - if sa, ok := store.GetRecordData("type.googleapis.com/user.ServiceAccount", req.Session.ID).(*user.ServiceAccount); ok { - req.fillJWTPayloadSessionOrServiceAccount(store, payload, sa) - } -} - -func (req *Request) fillJWTPayloadSessionOrServiceAccount(store *Store, payload map[string]interface{}, s sessionOrServiceAccount) { - payload["jti"] = s.GetId() - if s.GetExpiresAt().IsValid() { - payload["exp"] = s.GetExpiresAt().AsTime().Unix() - } - if s.GetIssuedAt().IsValid() { - payload["iat"] = s.GetIssuedAt().AsTime().Unix() - } - - userID := s.GetUserId() - if s.GetImpersonateUserId() != "" { - userID = s.GetImpersonateUserId() - } - if u, ok := store.GetRecordData("type.googleapis.com/user.User", userID).(*user.User); ok { - payload["sub"] = u.GetId() - payload["user"] = u.GetId() - payload["email"] = u.GetEmail() - } - if du, ok := store.GetRecordData("type.googleapis.com/directory.User", userID).(*directory.User); ok { - if du.GetEmail() != "" { - payload["email"] = du.GetEmail() - } - var groupNames []string - for _, groupID := range du.GetGroupIds() { - if dg, ok := store.GetRecordData("type.googleapis.com/directory.Group", groupID).(*directory.Group); ok { - groupNames = append(groupNames, dg.Name) - } - } - var groups []string - groups = append(groups, du.GetGroupIds()...) - groups = append(groups, groupNames...) - payload["groups"] = groups - } - - if s.GetImpersonateEmail() != "" { - payload["email"] = s.GetImpersonateEmail() - } - if len(s.GetImpersonateGroups()) > 0 { - payload["groups"] = s.GetImpersonateGroups() - } -} diff --git a/authorize/evaluator/store.go b/authorize/evaluator/store.go index acbc367a9..d18f0b8a6 100644 --- a/authorize/evaluator/store.go +++ b/authorize/evaluator/store.go @@ -11,6 +11,7 @@ import ( "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/anypb" "google.golang.org/protobuf/types/known/timestamppb" + "gopkg.in/square/go-jose.v2" "github.com/pomerium/pomerium/config" "github.com/pomerium/pomerium/internal/log" @@ -89,6 +90,22 @@ func (s *Store) GetRecordData(typeURL, id string) proto.Message { return msg } +// UpdateIssuer updates the issuer in the store. The issuer is used as part of JWT construction. +func (s *Store) UpdateIssuer(issuer string) { + s.write("/issuer", issuer) +} + +// UpdateGoogleCloudServerlessAuthenticationServiceAccount updates the google cloud serverless authentication +// service account in the store. +func (s *Store) UpdateGoogleCloudServerlessAuthenticationServiceAccount(serviceAccount string) { + s.write("/google_cloud_serverless_authentication_service_account", serviceAccount) +} + +// UpdateJWTClaimHeaders updates the jwt claim headers in the store. +func (s *Store) UpdateJWTClaimHeaders(jwtClaimHeaders []string) { + s.write("/jwt_claim_headers", jwtClaimHeaders) +} + // UpdateRoutePolicies updates the route policies in the store. func (s *Store) UpdateRoutePolicies(routePolicies []config.Policy) { s.write("/route_policies", routePolicies) @@ -144,6 +161,12 @@ func (s *Store) delete(rawPath string) { } } +// UpdateSigningKey updates the signing key stored in the database. Signing operations +// in rego use JWKs, so we take in that format. +func (s *Store) UpdateSigningKey(signingKey *jose.JSONWebKey) { + s.write("/signing_key", signingKey) +} + func (s *Store) get(rawPath string) (value interface{}) { p, ok := storage.ParsePath(rawPath) if !ok { diff --git a/authorize/grpc.go b/authorize/grpc.go index 055dffe92..d3e3ccdc0 100644 --- a/authorize/grpc.go +++ b/authorize/grpc.go @@ -23,7 +23,6 @@ import ( "github.com/pomerium/pomerium/pkg/grpc/session" "github.com/pomerium/pomerium/pkg/grpc/user" - envoy_api_v2_core "github.com/envoyproxy/go-control-plane/envoy/api/v2/core" envoy_service_auth_v2 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v2" ) @@ -58,7 +57,8 @@ func (a *Authorize) Check(ctx context.Context, in *envoy_service_auth_v2.CheckRe rawJWT, _ := loadRawSession(hreq, a.currentOptions.Load(), state.encoder) sessionState, _ := loadSession(state.encoder, rawJWT) - if err := a.forceSync(ctx, sessionState); err != nil { + u, err := a.forceSync(ctx, sessionState) + if err != nil { log.Warn().Err(err).Msg("clearing session due to force sync failed") sessionState = nil } @@ -74,7 +74,7 @@ func (a *Authorize) Check(ctx context.Context, in *envoy_service_auth_v2.CheckRe log.Error().Err(err).Msg("error during OPA evaluation") return nil, err } - logAuthorizeCheck(ctx, in, reply) + logAuthorizeCheck(ctx, in, reply, u) switch { case reply.Status == http.StatusOK: @@ -88,18 +88,18 @@ func (a *Authorize) Check(ctx context.Context, in *envoy_service_auth_v2.CheckRe return a.deniedResponse(in, int32(reply.Status), reply.Message, nil) } -func (a *Authorize) forceSync(ctx context.Context, ss *sessions.State) error { +func (a *Authorize) forceSync(ctx context.Context, ss *sessions.State) (*user.User, error) { ctx, span := trace.StartSpan(ctx, "authorize.forceSync") defer span.End() if ss == nil { - return nil + return nil, nil } s := a.forceSyncSession(ctx, ss.ID) if s == nil { - return errors.New("session not found") + return nil, errors.New("session not found") } - a.forceSyncUser(ctx, s.GetUserId()) - return nil + u := a.forceSyncUser(ctx, s.GetUserId()) + return u, nil } func (a *Authorize) forceSyncSession(ctx context.Context, sessionID string) interface{ GetUserId() string } { @@ -163,20 +163,6 @@ func (a *Authorize) forceSyncUser(ctx context.Context, userID string) *user.User return u } -func (a *Authorize) getEnvoyRequestHeaders(signedJWT string) ([]*envoy_api_v2_core.HeaderValueOption, error) { - var hvos []*envoy_api_v2_core.HeaderValueOption - - hdrs, err := a.getJWTClaimHeaders(a.currentOptions.Load(), signedJWT) - if err != nil { - return nil, err - } - for k, v := range hdrs { - hvos = append(hvos, mkHeader(k, v, false)) - } - - return hvos, nil -} - func getForwardAuthURL(r *http.Request) *url.URL { urqQuery := r.URL.Query().Get("uri") u, _ := urlutil.ParseAndValidateURL(urqQuery) @@ -334,6 +320,7 @@ func logAuthorizeCheck( ctx context.Context, in *envoy_service_auth_v2.CheckRequest, reply *evaluator.Result, + u *user.User, ) { hdrs := getCheckRequestHeaders(in) hattrs := in.GetAttributes().GetRequest().GetHttp() @@ -350,8 +337,8 @@ func logAuthorizeCheck( evt = evt.Bool("allow", reply.Status == http.StatusOK) evt = evt.Int("status", reply.Status) evt = evt.Str("message", reply.Message) - evt = evt.Str("user", reply.UserEmail) - evt = evt.Strs("groups", reply.UserGroups) + evt = evt.Str("user", u.GetId()) + evt = evt.Str("email", u.GetEmail()) } // potentially sensitive, only log if debug mode diff --git a/authorize/grpc_test.go b/authorize/grpc_test.go index edcfb39a2..9bc87bde3 100644 --- a/authorize/grpc_test.go +++ b/authorize/grpc_test.go @@ -437,7 +437,8 @@ func TestSync(t *testing.T) { a, err := New(&config.Config{Options: o}) require.NoError(t, err) a.state.Load().dataBrokerClient = dbdClient - assert.True(t, (a.forceSync(ctx, tc.sessionState) != nil) == tc.wantErr) + _, err = a.forceSync(ctx, tc.sessionState) + assert.True(t, (err != nil) == tc.wantErr) }) } } diff --git a/authorize/session.go b/authorize/session.go index 5422492c7..e6e466904 100644 --- a/authorize/session.go +++ b/authorize/session.go @@ -1,12 +1,10 @@ package authorize import ( - "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" - "strings" "github.com/pomerium/pomerium/config" "github.com/pomerium/pomerium/internal/encoding" @@ -85,41 +83,3 @@ func getJWTSetCookieHeaders(cookieStore sessions.SessionStore, rawjwt []byte) (m } return hdrs, nil } - -func (a *Authorize) getJWTClaimHeaders(options *config.Options, signedJWT string) (map[string]string, error) { - if len(signedJWT) == 0 { - return make(map[string]string), nil - } - - state := a.state.Load() - - var claims map[string]interface{} - payload, err := state.evaluator.ParseSignedJWT(signedJWT) - if err != nil { - return nil, err - } - if err := json.Unmarshal(payload, &claims); err != nil { - return nil, err - } - - hdrs := make(map[string]string) - for _, name := range options.JWTClaimsHeaders { - if claim, ok := claims[name]; ok { - switch value := claim.(type) { - case string: - hdrs["x-pomerium-claim-"+name] = value - case []interface{}: - hdrs["x-pomerium-claim-"+name] = strings.Join(toSliceStrings(value), ",") - } - } - } - return hdrs, nil -} - -func toSliceStrings(sliceIfaces []interface{}) []string { - sliceStrings := make([]string, 0, len(sliceIfaces)) - for _, e := range sliceIfaces { - sliceStrings = append(sliceStrings, fmt.Sprint(e)) - } - return sliceStrings -} diff --git a/authorize/session_test.go b/authorize/session_test.go index 9f31712dd..798495487 100644 --- a/authorize/session_test.go +++ b/authorize/session_test.go @@ -7,15 +7,10 @@ import ( envoy_service_auth_v2 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v2" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/pomerium/pomerium/authorize/evaluator" "github.com/pomerium/pomerium/config" - "github.com/pomerium/pomerium/internal/directory" "github.com/pomerium/pomerium/internal/encoding/jws" "github.com/pomerium/pomerium/internal/sessions" - "github.com/pomerium/pomerium/pkg/grpc/session" - "github.com/pomerium/pomerium/pkg/grpc/user" ) func TestLoadSession(t *testing.T) { @@ -105,71 +100,3 @@ func TestLoadSession(t *testing.T) { assert.NotNil(t, sess) }) } - -func TestAuthorize_getJWTClaimHeaders(t *testing.T) { - opt := &config.Options{ - AuthenticateURL: mustParseURL("https://authenticate.example.com"), - Policies: []config.Policy{{ - Source: &config.StringURL{URL: &url.URL{Host: "example.com"}}, - SubPolicies: []config.SubPolicy{{ - Rego: []string{"allow = true"}, - }}, - }}, - } - a := &Authorize{currentOptions: config.NewAtomicOptions(), state: newAtomicAuthorizeState(new(authorizeState))} - encoder, _ := jws.NewHS256Signer([]byte{0, 0, 0, 0}) - a.state.Load().encoder = encoder - a.currentOptions.Store(opt) - a.store = evaluator.NewStoreFromProtos( - &session.Session{ - Id: "SESSION_ID", - UserId: "USER_ID", - }, - &user.User{ - Id: "USER_ID", - Name: "foo", - Email: "foo@example.com", - }, - &directory.User{ - Id: "USER_ID", - GroupIds: []string{"admin_id", "test_id"}, - }, - &directory.Group{ - Id: "admin_id", - Name: "admin", - }, - &directory.Group{ - Id: "test_id", - Name: "test", - }, - ) - pe, err := newPolicyEvaluator(opt, a.store) - require.NoError(t, err) - a.state.Load().evaluator = pe - signedJWT, _ := pe.SignedJWT(pe.JWTPayload(&evaluator.Request{ - HTTP: evaluator.RequestHTTP{URL: "https://example.com"}, - Session: evaluator.RequestSession{ - ID: "SESSION_ID", - }, - })) - - tests := []struct { - name string - signedJWT string - jwtHeaders []string - expectedHeaders map[string]string - }{ - {"good with email", signedJWT, []string{"email"}, map[string]string{"x-pomerium-claim-email": "foo@example.com"}}, - {"good with groups", signedJWT, []string{"groups"}, map[string]string{"x-pomerium-claim-groups": "admin_id,test_id,admin,test"}}, - {"empty signed JWT", "", nil, make(map[string]string)}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - opt.JWTClaimsHeaders = tc.jwtHeaders - gotHeaders, err := a.getJWTClaimHeaders(opt, tc.signedJWT) - require.NoError(t, err) - assert.Equal(t, tc.expectedHeaders, gotHeaders) - }) - } -}