diff --git a/Makefile b/Makefile index 37814be26..c46278de6 100644 --- a/Makefile +++ b/Makefile @@ -42,7 +42,6 @@ all: clean build-deps test lint spellcheck build ## Runs a clean, build, fmt, li generate-mocks: ## Generate mocks @echo "==> $@" @go run github.com/golang/mock/mockgen -destination authorize/evaluator/mock_evaluator/mock.go github.com/pomerium/pomerium/authorize/evaluator Evaluator - @go run github.com/golang/mock/mockgen -destination internal/grpc/cache/mock/mock_cacher.go github.com/pomerium/pomerium/internal/grpc/cache Cacher .PHONY: build-deps build-deps: ## Install build dependencies @@ -111,7 +110,7 @@ clean: ## Cleanup any build binaries or packages. snapshot: ## Builds the cross-compiled binaries, naming them in such a way for release (eg. binary-GOOS-GOARCH) @echo "+ $@" @cd /tmp; GO111MODULE=on go get github.com/goreleaser/goreleaser - goreleaser release --rm-dist -f .github/goreleaser.yaml --snapshot + goreleaser release --rm-dist -f .github/goreleaser.yaml --snapshot .PHONY: help help: diff --git a/authenticate/authenticate.go b/authenticate/authenticate.go index d38454d4b..02050dd2e 100644 --- a/authenticate/authenticate.go +++ b/authenticate/authenticate.go @@ -19,8 +19,9 @@ import ( "github.com/pomerium/pomerium/internal/encoding/jws" "github.com/pomerium/pomerium/internal/frontend" "github.com/pomerium/pomerium/internal/grpc" - "github.com/pomerium/pomerium/internal/grpc/cache" - "github.com/pomerium/pomerium/internal/grpc/cache/client" + "github.com/pomerium/pomerium/internal/grpc/databroker" + "github.com/pomerium/pomerium/internal/grpc/session" + "github.com/pomerium/pomerium/internal/grpc/user" "github.com/pomerium/pomerium/internal/httputil" "github.com/pomerium/pomerium/internal/identity" "github.com/pomerium/pomerium/internal/identity/oauth" @@ -94,8 +95,14 @@ type Authenticate struct { // provider is the interface to interacting with the identity provider (IdP) provider identity.Authenticator - // cacheClient is the interface for setting and getting sessions from a cache - cacheClient cache.Cacher + // dataBrokerClient is used to retrieve sessions + dataBrokerClient databroker.DataBrokerServiceClient + + // sessionClient is used to create sessions + sessionClient session.SessionServiceClient + + // userClient is used to update users + userClient user.UserServiceClient jwk *jose.JSONWebKeySet @@ -133,9 +140,9 @@ func New(opts config.Options) (*Authenticate, error) { return nil, err } - cacheConn, err := grpc.NewGRPCClientConn( + dataBrokerConn, err := grpc.NewGRPCClientConn( &grpc.Options{ - Addr: opts.CacheURL, + Addr: opts.DataBrokerURL, OverrideCertificateName: opts.OverrideCertificateName, CA: opts.CA, CAFile: opts.CAFile, @@ -147,7 +154,9 @@ func New(opts config.Options) (*Authenticate, error) { return nil, err } - cacheClient := client.New(cacheConn) + dataBrokerClient := databroker.NewDataBrokerServiceClient(dataBrokerConn) + sessionClient := session.NewSessionServiceClient(dataBrokerConn) + userClient := user.NewUserServiceClient(dataBrokerConn) qpStore := queryparam.NewStore(encryptedEncoder, urlutil.QueryProgrammaticToken) headerStore := header.NewStore(encryptedEncoder, httputil.AuthorizationTypePomerium) @@ -186,9 +195,11 @@ func New(opts config.Options) (*Authenticate, error) { // IdP provider: provider, // grpc client for cache - cacheClient: cacheClient, - jwk: &jose.JSONWebKeySet{}, - templates: template.Must(frontend.NewTemplates()), + dataBrokerClient: dataBrokerClient, + sessionClient: sessionClient, + userClient: userClient, + jwk: &jose.JSONWebKeySet{}, + templates: template.Must(frontend.NewTemplates()), } if opts.SigningKey != "" { diff --git a/authenticate/authenticate_test.go b/authenticate/authenticate_test.go index 2a520c27e..fb6db1c29 100644 --- a/authenticate/authenticate_test.go +++ b/authenticate/authenticate_test.go @@ -90,7 +90,7 @@ func TestNew(t *testing.T) { badProvider.Provider = "" badProvider.CookieName = "C" badGRPCConn := newTestOptions(t) - badGRPCConn.CacheURL = nil + badGRPCConn.DataBrokerURL = nil badGRPCConn.CookieName = "D" emptyProviderURL := newTestOptions(t) diff --git a/authenticate/handlers.go b/authenticate/handlers.go index c657ac944..2c6cd9c01 100644 --- a/authenticate/handlers.go +++ b/authenticate/handlers.go @@ -11,20 +11,25 @@ import ( "strings" "time" + "github.com/golang/protobuf/ptypes" + "github.com/google/uuid" + "github.com/gorilla/mux" "github.com/pomerium/csrf" + "github.com/rs/cors" + "golang.org/x/oauth2" + "github.com/pomerium/pomerium/internal/cryptutil" - "github.com/pomerium/pomerium/internal/hashutil" + "github.com/pomerium/pomerium/internal/grpc/directory" + "github.com/pomerium/pomerium/internal/grpc/session" + "github.com/pomerium/pomerium/internal/grpc/user" "github.com/pomerium/pomerium/internal/httputil" + "github.com/pomerium/pomerium/internal/identity/manager" "github.com/pomerium/pomerium/internal/identity/oidc" "github.com/pomerium/pomerium/internal/log" "github.com/pomerium/pomerium/internal/middleware" "github.com/pomerium/pomerium/internal/sessions" "github.com/pomerium/pomerium/internal/telemetry/trace" "github.com/pomerium/pomerium/internal/urlutil" - - "github.com/gorilla/mux" - "github.com/rs/cors" - "golang.org/x/oauth2" ) // Handler returns the authenticate service's handler chain. @@ -66,12 +71,11 @@ func (a *Authenticate) Mount(r *mux.Router) { AllowedHeaders: []string{"*"}, }) v.Use(c.Handler) - v.Use(middleware.ValidateSignature(a.sharedKey)) v.Use(sessions.RetrieveSession(a.sessionLoaders...)) v.Use(a.VerifySession) + v.Path("/").Handler(httputil.HandlerFunc(a.Dashboard)) v.Path("/sign_in").Handler(httputil.HandlerFunc(a.SignIn)) v.Path("/sign_out").Handler(httputil.HandlerFunc(a.SignOut)) - v.Path("/refresh").Handler(httputil.HandlerFunc(a.Refresh)).Methods(http.MethodGet) wk := r.PathPrefix("/.well-known/pomerium").Subrouter() wk.Path("/jwks.json").Handler(httputil.HandlerFunc(a.jwks)).Methods(http.MethodGet) @@ -82,7 +86,6 @@ func (a *Authenticate) Mount(r *mux.Router) { // programmatic access api endpoint api := r.PathPrefix("/api").Subrouter() api.Use(sessions.RetrieveSession(a.sessionLoaders...)) - api.Path("/v1/refresh").Handler(httputil.HandlerFunc(a.RefreshAPI)) } // Well-Known Uniform Resource Identifiers (URIs) @@ -128,58 +131,25 @@ func (a *Authenticate) VerifySession(next http.Handler) http.Handler { return httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { ctx, span := trace.StartSpan(r.Context(), "authenticate.VerifySession") defer span.End() - s, err := a.getSessionFromCtx(ctx) + sessionState, err := a.getSessionFromCtx(ctx) if err != nil { log.FromRequest(r).Info().Err(err).Msg("authenticate: session load error") return a.reauthenticateOrFail(w, r, err) } - if s.IsExpired() { - ctx, err = a.refresh(w, r, s) + + if a.sessionClient != nil { + _, err = session.Get(r.Context(), a.dataBrokerClient, sessionState.ID) if err != nil { - log.FromRequest(r).Info().Err(err).Msg("authenticate: verify session, refresh") + log.FromRequest(r).Info().Err(err).Str("id", sessionState.ID).Msg("authenticate: session not found in databroker") return a.reauthenticateOrFail(w, r, err) } } + next.ServeHTTP(w, r.WithContext(ctx)) return nil }) } -func (a *Authenticate) refresh(w http.ResponseWriter, r *http.Request, s *sessions.State) (context.Context, error) { - ctx, span := trace.StartSpan(r.Context(), "authenticate.VerifySession/refresh") - defer span.End() - accessToken, err := a.getAccessToken(ctx, s) - if err != nil { - return nil, err - } - - // we are going to keep the same audiences for the refreshed token - // otherwise this will be rewritten to be the ClientID of the provider - oldAudience := s.Audience - - newAccessToken, err := a.provider.Refresh(ctx, accessToken, s) - if err != nil { - return nil, fmt.Errorf("authenticate: refresh failed: %w", err) - } - - newSession := sessions.NewSession(s, a.RedirectURL.Hostname(), oldAudience, newAccessToken) - - encSession, err := a.sharedEncoder.Marshal(newSession) - if err != nil { - return nil, err - } - - if err := a.sessionStore.SaveSession(w, r, newSession); err != nil { - return nil, fmt.Errorf("authenticate: error saving new session: %w", err) - } - - if err := a.setAccessToken(ctx, newAccessToken); err != nil { - return nil, fmt.Errorf("authenticate: error saving refreshed access token: %w", err) - } - // return the new session and add it to the current request context - return sessions.NewContext(ctx, string(encSession), err), nil -} - // RobotsTxt handles the /robots.txt route. func (a *Authenticate) RobotsTxt(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") @@ -224,7 +194,6 @@ func (a *Authenticate) SignIn(w http.ResponseWriter, r *http.Request) error { a.sessionStore.ClearSession(w, r) return err } - accessToken, err := a.getAccessToken(ctx, s) if err != nil { a.sessionStore.ClearSession(w, r) return err @@ -233,7 +202,7 @@ func (a *Authenticate) SignIn(w http.ResponseWriter, r *http.Request) error { if impersonate := r.FormValue(urlutil.QueryImpersonateAction); impersonate != "" { s.SetImpersonation(r.FormValue(urlutil.QueryImpersonateEmail), r.FormValue(urlutil.QueryImpersonateGroups)) } - newSession := sessions.NewSession(s, a.RedirectURL.Host, jwtAudience, accessToken) + newSession := sessions.NewSession(s, a.RedirectURL.Host, jwtAudience) // re-persist the session, useful when session was evicted from session if err := a.sessionStore.SaveSession(w, r, s); err != nil { @@ -244,7 +213,13 @@ func (a *Authenticate) SignIn(w http.ResponseWriter, r *http.Request) error { if r.FormValue(urlutil.QueryIsProgrammatic) == "true" { newSession.Programmatic = true - encSession, err := a.encryptedEncoder.Marshal(accessToken) + + pbSession, err := session.Get(ctx, a.dataBrokerClient, s.ID) + if err != nil { + return httputil.NewError(http.StatusBadRequest, err) + } + + encSession, err := a.encryptedEncoder.Marshal(pbSession.GetOauthToken()) if err != nil { return httputil.NewError(http.StatusBadRequest, err) } @@ -281,6 +256,14 @@ func (a *Authenticate) SignOut(w http.ResponseWriter, r *http.Request) error { ctx, span := trace.StartSpan(r.Context(), "authenticate.SignOut") defer span.End() + sessionState, err := a.getSessionFromCtx(ctx) + if err == nil { + err = a.deleteSession(ctx, sessionState.ID) + if err != nil { + log.Warn().Err(err).Msg("failed to delete session from session store") + } + } + // no matter what happens, we want to clear the session store a.sessionStore.ClearSession(w, r) redirectString := r.FormValue(urlutil.QueryRedirectURI) @@ -296,24 +279,6 @@ func (a *Authenticate) SignOut(w http.ResponseWriter, r *http.Request) error { httputil.Redirect(w, r, redirectString, http.StatusFound) - s, err := a.getSessionFromCtx(ctx) - if err != nil { - log.Warn().Err(err).Msg("authenticate.SignOut: failed getting session") - return nil - } - - accessToken, err := a.getAccessToken(ctx, s) - if err != nil { - log.Warn().Err(err).Msg("authenticate.SignOut: failed getting access token") - return nil - } - - // first, try to revoke the session if implemented - err = a.provider.Revoke(ctx, accessToken) - if err != nil && !errors.Is(err, oidc.ErrRevokeNotImplemented) { - log.Warn().Err(err).Msg("authenticate.SignOut: failed revoking token") - return nil - } return nil } @@ -386,17 +351,21 @@ func (a *Authenticate) getOAuthCallback(w http.ResponseWriter, r *http.Request) // Successful Authentication Response: rfc6749#section-4.1.2 & OIDC#3.1.2.5 // // Exchange the supplied Authorization Code for a valid user session. - var s sessions.State + s := sessions.State{ID: uuid.New().String()} accessToken, err := a.provider.Authenticate(ctx, code, &s) if err != nil { return nil, fmt.Errorf("error redeeming authenticate code: %w", err) } + err = a.saveSessionToDataBroker(r.Context(), &s, accessToken) + if err != nil { + return nil, httputil.NewError(http.StatusInternalServerError, err) + } + newState := sessions.NewSession( &s, a.RedirectURL.Hostname(), - []string{a.RedirectURL.Hostname()}, - accessToken) + []string{a.RedirectURL.Hostname()}) // state includes a csrf nonce (validated by middleware) and redirect uri bytes, err := base64.URLEncoding.DecodeString(r.FormValue("state")) @@ -430,11 +399,6 @@ func (a *Authenticate) getOAuthCallback(w http.ResponseWriter, r *http.Request) return nil, httputil.NewError(http.StatusBadRequest, err) } - // Ok -- We've got a valid session here. Let's now persist the access - // token to cache ... - if err := a.setAccessToken(ctx, accessToken); err != nil { - return nil, fmt.Errorf("failed saving access token: %w", err) - } // ... and the user state to local storage. if err := a.sessionStore.SaveSession(w, r, &newState); err != nil { return nil, fmt.Errorf("failed saving new session: %w", err) @@ -442,123 +406,6 @@ func (a *Authenticate) getOAuthCallback(w http.ResponseWriter, r *http.Request) return redirectURL, nil } -// RefreshAPI loads a global state, and attempts to refresh the session's access -// tokens and state with the identity provider. If successful, a new signed JWT -// and refresh token (`refresh_token`) are returned as JSON -func (a *Authenticate) RefreshAPI(w http.ResponseWriter, r *http.Request) error { - ctx, span := trace.StartSpan(r.Context(), "authenticate.RefreshAPI") - defer span.End() - - s, err := a.getSessionFromCtx(ctx) - if err != nil { - return err - } - - accessToken, err := a.getAccessToken(ctx, s) - if err != nil { - return err - } - - newAccessToken, err := a.provider.Refresh(ctx, accessToken, s) - if err != nil { - return err - } - - routeNewSession := sessions.NewSession(s, a.RedirectURL.Hostname(), s.Audience, newAccessToken) - - encSession, err := a.encryptedEncoder.Marshal(accessToken) - if err != nil { - return err - } - - signedJWT, err := a.sharedEncoder.Marshal(routeNewSession) - if err != nil { - return err - } - var response struct { - JWT string `json:"jwt"` - RefreshToken string `json:"refresh_token"` - } - response.RefreshToken = string(encSession) - response.JWT = string(signedJWT) - - jsonResponse, err := json.Marshal(&response) - if err != nil { - return httputil.NewError(http.StatusBadRequest, err) - } - w.Header().Set("Content-Type", "application/json") - w.Write(jsonResponse) - return nil -} - -// Refresh is called by the proxy service to handle backend session refresh. -// -// NOTE: The actual refresh is handled as part of the "VerifySession" -// middleware. This handler is simply responsible for returning that jwt. -func (a *Authenticate) Refresh(w http.ResponseWriter, r *http.Request) error { - ctx, span := trace.StartSpan(r.Context(), "authenticate.Refresh") - defer span.End() - jwt, err := sessions.FromContext(ctx) - if err != nil { - return fmt.Errorf("authenticate.Refresh: %w", err) - } - w.Header().Set("Content-Type", "application/jwt") // RFC 7519 : 10.3.1 - fmt.Fprint(w, jwt) - return nil -} - -// getAccessToken gets an associated oauth2 access token from a session state -func (a *Authenticate) getAccessToken(ctx context.Context, s *sessions.State) (*oauth2.Token, error) { - ctx, span := trace.StartSpan(ctx, "authenticate.getAccessToken") - defer span.End() - - var accessToken oauth2.Token - tokenBytes, err := a.cacheClient.Get(ctx, s.AccessTokenHash) - if err != nil { - return nil, err - } - if err := a.encryptedEncoder.Unmarshal(tokenBytes, &accessToken); err != nil { - return nil, err - } - if accessToken.Valid() { - return &accessToken, nil // this token is still valid, use it! - } - tokenBytes, err = a.cacheClient.Get(ctx, a.timestampedHash(accessToken.RefreshToken)) - if err == nil { - // we found another possibly newer access token associated with the - // existing refresh token so let's try that. - if err := a.encryptedEncoder.Unmarshal(tokenBytes, &accessToken); err != nil { - return nil, err - } - } - - return &accessToken, nil -} - -func (a *Authenticate) setAccessToken(ctx context.Context, accessToken *oauth2.Token) error { - encToken, err := a.encryptedEncoder.Marshal(accessToken) - if err != nil { - return err - } - // set this specific access token - key := fmt.Sprintf("%x", hashutil.Hash(accessToken)) - if err := a.cacheClient.Set(ctx, key, encToken); err != nil { - return fmt.Errorf("authenticate: setAccessToken failed key: %s :%w", key, err) - } - - // set this as the "latest" token for this access token - key = a.timestampedHash(accessToken.RefreshToken) - if err := a.cacheClient.Set(ctx, key, encToken); err != nil { - return fmt.Errorf("authenticate: setAccessToken failed key: %s :%w", key, err) - } - - return nil -} - -func (a *Authenticate) timestampedHash(s string) string { - return fmt.Sprintf("%x-%v", hashutil.Hash(s), time.Now().Truncate(time.Minute).Unix()) -} - func (a *Authenticate) getSessionFromCtx(ctx context.Context) (*sessions.State, error) { jwt, err := sessions.FromContext(ctx) if err != nil { @@ -570,3 +417,130 @@ func (a *Authenticate) getSessionFromCtx(ctx context.Context) (*sessions.State, } return &s, nil } + +func (a *Authenticate) deleteSession(ctx context.Context, sessionID string) error { + if a.sessionClient == nil { + return nil + } + + _, err := a.sessionClient.Add(ctx, &session.AddRequest{ + Session: &session.Session{ + Id: sessionID, + DeletedAt: ptypes.TimestampNow(), + }, + }) + return err +} + +// Dashboard renders the /.pomerium/ user dashboard. +func (a *Authenticate) Dashboard(w http.ResponseWriter, r *http.Request) error { + s, err := a.getSessionFromCtx(r.Context()) + if err != nil { + s.ID = uuid.New().String() + } + + pbSession, err := session.Get(r.Context(), a.dataBrokerClient, s.ID) + if err != nil { + pbSession = &session.Session{ + Id: s.ID, + } + } + pbUser, err := user.Get(r.Context(), a.dataBrokerClient, pbSession.GetUserId()) + if err != nil { + pbUser = &user.User{ + Id: pbSession.GetUserId(), + } + } + pbDirectoryUser, err := directory.Get(r.Context(), a.dataBrokerClient, pbSession.GetUserId()) + if err != nil { + pbDirectoryUser = &directory.User{ + Id: pbSession.GetUserId(), + } + } + + input := map[string]interface{}{ + "State": s, + "Session": pbSession, + "User": pbUser, + "DirectoryUser": pbDirectoryUser, + "csrfField": csrf.TemplateField(r), + "ImpersonateAction": urlutil.QueryImpersonateAction, + "ImpersonateEmail": urlutil.QueryImpersonateEmail, + "ImpersonateGroups": urlutil.QueryImpersonateGroups, + "RedirectURL": r.URL.Query().Get(urlutil.QueryRedirectURI), + } + + if redirectURL, err := url.Parse(r.URL.Query().Get(urlutil.QueryRedirectURI)); err == nil { + input["RedirectURL"] = redirectURL.String() + signOutURL := redirectURL.ResolveReference(new(url.URL)) + signOutURL.Path = "/.pomerium/sign_out" + input["SignOutURL"] = signOutURL.String() + } else { + input["SignOutURL"] = "/.pomerium/sign_out" + } + + err = a.templates.ExecuteTemplate(w, "dashboard.html", input) + if err != nil { + log.Warn().Err(err).Interface("input", input).Msg("proxy: error rendering dashboard") + } + return nil +} + +func (a *Authenticate) saveSessionToDataBroker(ctx context.Context, sessionState *sessions.State, accessToken *oauth2.Token) error { + if a.sessionClient == nil || a.userClient == nil { + return nil + } + + sessionExpiry, _ := ptypes.TimestampProto(time.Now().Add(time.Hour)) + idTokenExpiry, _ := ptypes.TimestampProto(sessionState.Expiry.Time()) + idTokenIssuedAt, _ := ptypes.TimestampProto(sessionState.IssuedAt.Time()) + oauthTokenExpiry, _ := ptypes.TimestampProto(accessToken.Expiry) + + s := &session.Session{ + Id: sessionState.ID, + UserId: sessionState.Issuer + "/" + sessionState.Subject, + ExpiresAt: sessionExpiry, + IdToken: &session.IDToken{ + Issuer: sessionState.Issuer, + Subject: sessionState.Subject, + ExpiresAt: idTokenExpiry, + IssuedAt: idTokenIssuedAt, + }, + OauthToken: &session.OAuthToken{ + AccessToken: accessToken.AccessToken, + TokenType: accessToken.TokenType, + ExpiresAt: oauthTokenExpiry, + RefreshToken: accessToken.RefreshToken, + }, + } + + // if no user exists yet, create a new one + currentUser, _ := user.Get(ctx, a.dataBrokerClient, s.GetUserId()) + if currentUser == nil { + mu := manager.User{ + User: &user.User{ + Id: s.GetUserId(), + }, + } + err := a.provider.UpdateUserInfo(ctx, accessToken, &mu) + if err != nil { + return fmt.Errorf("authenticate: error retrieving uesr info: %w", err) + } + _, err = a.userClient.Add(ctx, &user.AddRequest{ + User: mu.User, + }) + if err != nil { + return fmt.Errorf("authenticate: error saving user: %w", err) + } + } + + res, err := a.sessionClient.Add(ctx, &session.AddRequest{ + Session: s, + }) + if err != nil { + return fmt.Errorf("authenticate: error saving session: %w", err) + } + sessionState.Version = res.GetServerVersion() + + return nil +} diff --git a/authenticate/handlers_test.go b/authenticate/handlers_test.go index 242d8ab2d..6746a779f 100644 --- a/authenticate/handlers_test.go +++ b/authenticate/handlers_test.go @@ -1,6 +1,7 @@ package authenticate import ( + "context" "encoding/base64" "errors" "fmt" @@ -11,13 +12,15 @@ import ( "testing" "time" - "github.com/pomerium/pomerium/config" + "google.golang.org/grpc" + "github.com/pomerium/pomerium/internal/cryptutil" "github.com/pomerium/pomerium/internal/encoding" "github.com/pomerium/pomerium/internal/encoding/jws" "github.com/pomerium/pomerium/internal/encoding/mock" "github.com/pomerium/pomerium/internal/frontend" - mock_cache "github.com/pomerium/pomerium/internal/grpc/cache/mock" + "github.com/pomerium/pomerium/internal/grpc/databroker" + "github.com/pomerium/pomerium/internal/grpc/session" "github.com/pomerium/pomerium/internal/httputil" "github.com/pomerium/pomerium/internal/identity" "github.com/pomerium/pomerium/internal/identity/oidc" @@ -27,8 +30,8 @@ import ( "github.com/pomerium/pomerium/internal/urlutil" "github.com/golang/mock/gomock" + "github.com/golang/protobuf/ptypes" "github.com/google/go-cmp/cmp" - "github.com/gorilla/mux" "github.com/stretchr/testify/assert" "golang.org/x/crypto/chacha20poly1305" "golang.org/x/oauth2" @@ -120,27 +123,25 @@ func TestAuthenticate_SignIn(t *testing.T) { encoder encoding.MarshalUnmarshaler wantCode int }{ - {"good", "https", "corp.example.example", map[string]string{urlutil.QueryRedirectURI: "https://dst.some.example/"}, &mstore.Store{Session: &sessions.State{Email: "user@pomerium.io"}}, identity.MockProvider{}, &mock.Encoder{}, http.StatusFound}, - {"good alternate port", "https", "corp.example.example:8443", map[string]string{urlutil.QueryRedirectURI: "https://dst.some.example/"}, &mstore.Store{Session: &sessions.State{Email: "user@pomerium.io"}}, identity.MockProvider{}, &mock.Encoder{}, http.StatusFound}, - {"session not valid", "https", "corp.example.example", map[string]string{urlutil.QueryRedirectURI: "https://dst.some.example/"}, &mstore.Store{Session: &sessions.State{Email: "user@pomerium.io"}}, identity.MockProvider{}, &mock.Encoder{}, http.StatusFound}, - {"bad redirect uri query", "", "corp.example.example", map[string]string{urlutil.QueryRedirectURI: "^^^"}, &mstore.Store{Session: &sessions.State{Email: "user@pomerium.io"}}, identity.MockProvider{}, &mock.Encoder{}, http.StatusBadRequest}, - {"bad marshal", "https", "corp.example.example", map[string]string{urlutil.QueryRedirectURI: "https://dst.some.example/"}, &mstore.Store{Session: &sessions.State{Email: "user@pomerium.io"}}, identity.MockProvider{}, &mock.Encoder{MarshalError: errors.New("error")}, http.StatusBadRequest}, + {"good", "https", "corp.example.example", map[string]string{urlutil.QueryRedirectURI: "https://dst.some.example/"}, &mstore.Store{Session: &sessions.State{}}, identity.MockProvider{}, &mock.Encoder{}, http.StatusFound}, + {"good alternate port", "https", "corp.example.example:8443", map[string]string{urlutil.QueryRedirectURI: "https://dst.some.example/"}, &mstore.Store{Session: &sessions.State{}}, identity.MockProvider{}, &mock.Encoder{}, http.StatusFound}, + {"session not valid", "https", "corp.example.example", map[string]string{urlutil.QueryRedirectURI: "https://dst.some.example/"}, &mstore.Store{Session: &sessions.State{}}, identity.MockProvider{}, &mock.Encoder{}, http.StatusFound}, + {"bad redirect uri query", "", "corp.example.example", map[string]string{urlutil.QueryRedirectURI: "^^^"}, &mstore.Store{Session: &sessions.State{}}, identity.MockProvider{}, &mock.Encoder{}, http.StatusBadRequest}, + {"bad marshal", "https", "corp.example.example", map[string]string{urlutil.QueryRedirectURI: "https://dst.some.example/"}, &mstore.Store{Session: &sessions.State{}}, identity.MockProvider{}, &mock.Encoder{MarshalError: errors.New("error")}, http.StatusBadRequest}, {"session error", "https", "corp.example.example", map[string]string{urlutil.QueryRedirectURI: "https://dst.some.example/"}, &mstore.Store{LoadError: errors.New("error")}, identity.MockProvider{}, &mock.Encoder{}, http.StatusBadRequest}, - {"good with different programmatic redirect", "https", "corp.example.example", map[string]string{urlutil.QueryRedirectURI: "https://dst.some.example/", urlutil.QueryCallbackURI: "https://some.example"}, &mstore.Store{Session: &sessions.State{Email: "user@pomerium.io"}}, identity.MockProvider{}, &mock.Encoder{}, http.StatusFound}, - {"encrypted encoder error", "https", "corp.example.example", map[string]string{urlutil.QueryRedirectURI: "https://dst.some.example/", urlutil.QueryCallbackURI: "https://some.example"}, &mstore.Store{Session: &sessions.State{Email: "user@pomerium.io"}}, identity.MockProvider{}, &mock.Encoder{MarshalError: errors.New("error")}, http.StatusBadRequest}, - {"good with callback uri set", "https", "corp.example.example", map[string]string{urlutil.QueryCallbackURI: "https://some.example/", urlutil.QueryRedirectURI: "https://dst.some.example/"}, &mstore.Store{Session: &sessions.State{Email: "user@pomerium.io"}}, identity.MockProvider{}, &mock.Encoder{}, http.StatusFound}, - {"bad callback uri set", "https", "corp.example.example", map[string]string{urlutil.QueryCallbackURI: "^", urlutil.QueryRedirectURI: "https://dst.some.example/"}, &mstore.Store{Session: &sessions.State{Email: "user@pomerium.io"}}, identity.MockProvider{}, &mock.Encoder{}, http.StatusBadRequest}, - {"good programmatic request", "https", "corp.example.example", map[string]string{urlutil.QueryIsProgrammatic: "true", urlutil.QueryRedirectURI: "https://dst.some.example/"}, &mstore.Store{Session: &sessions.State{Email: "user@pomerium.io"}}, identity.MockProvider{}, &mock.Encoder{}, http.StatusFound}, - {"good additional audience", "https", "corp.example.example", map[string]string{urlutil.QueryForwardAuth: "x.y.z", urlutil.QueryRedirectURI: "https://dst.some.example/"}, &mstore.Store{Session: &sessions.State{Email: "user@pomerium.io"}}, identity.MockProvider{}, &mock.Encoder{}, http.StatusFound}, - {"good user impersonate", "https", "corp.example.example", map[string]string{urlutil.QueryImpersonateAction: "set", urlutil.QueryRedirectURI: "https://dst.some.example/"}, &mstore.Store{Session: &sessions.State{Email: "user@pomerium.io"}}, identity.MockProvider{}, &mock.Encoder{}, http.StatusFound}, - {"bad user impersonate save failure", "https", "corp.example.example", map[string]string{urlutil.QueryImpersonateAction: "set", urlutil.QueryRedirectURI: "https://dst.some.example/"}, &mstore.Store{SaveError: errors.New("err"), Session: &sessions.State{Email: "user@pomerium.io"}}, identity.MockProvider{}, &mock.Encoder{}, http.StatusBadRequest}, + {"good with different programmatic redirect", "https", "corp.example.example", map[string]string{urlutil.QueryRedirectURI: "https://dst.some.example/", urlutil.QueryCallbackURI: "https://some.example"}, &mstore.Store{Session: &sessions.State{}}, identity.MockProvider{}, &mock.Encoder{}, http.StatusFound}, + {"encrypted encoder error", "https", "corp.example.example", map[string]string{urlutil.QueryRedirectURI: "https://dst.some.example/", urlutil.QueryCallbackURI: "https://some.example"}, &mstore.Store{Session: &sessions.State{}}, identity.MockProvider{}, &mock.Encoder{MarshalError: errors.New("error")}, http.StatusBadRequest}, + {"good with callback uri set", "https", "corp.example.example", map[string]string{urlutil.QueryCallbackURI: "https://some.example/", urlutil.QueryRedirectURI: "https://dst.some.example/"}, &mstore.Store{Session: &sessions.State{}}, identity.MockProvider{}, &mock.Encoder{}, http.StatusFound}, + {"bad callback uri set", "https", "corp.example.example", map[string]string{urlutil.QueryCallbackURI: "^", urlutil.QueryRedirectURI: "https://dst.some.example/"}, &mstore.Store{Session: &sessions.State{}}, identity.MockProvider{}, &mock.Encoder{}, http.StatusBadRequest}, + {"good programmatic request", "https", "corp.example.example", map[string]string{urlutil.QueryIsProgrammatic: "true", urlutil.QueryRedirectURI: "https://dst.some.example/"}, &mstore.Store{Session: &sessions.State{}}, identity.MockProvider{}, &mock.Encoder{}, http.StatusFound}, + {"good additional audience", "https", "corp.example.example", map[string]string{urlutil.QueryForwardAuth: "x.y.z", urlutil.QueryRedirectURI: "https://dst.some.example/"}, &mstore.Store{Session: &sessions.State{}}, identity.MockProvider{}, &mock.Encoder{}, http.StatusFound}, + {"good user impersonate", "https", "corp.example.example", map[string]string{urlutil.QueryImpersonateAction: "set", urlutil.QueryRedirectURI: "https://dst.some.example/"}, &mstore.Store{Session: &sessions.State{}}, identity.MockProvider{}, &mock.Encoder{}, http.StatusFound}, + {"bad user impersonate save failure", "https", "corp.example.example", map[string]string{urlutil.QueryImpersonateAction: "set", urlutil.QueryRedirectURI: "https://dst.some.example/"}, &mstore.Store{SaveError: errors.New("err"), Session: &sessions.State{}}, identity.MockProvider{}, &mock.Encoder{}, http.StatusBadRequest}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - mc := mock_cache.NewMockCacher(ctrl) - mc.EXPECT().Get(gomock.Any(), gomock.Any()).Return([]byte("hi"), nil).AnyTimes() a := &Authenticate{ sessionStore: tt.session, @@ -154,7 +155,25 @@ func TestAuthenticate_SignIn(t *testing.T) { Name: "cookie", Domain: "foo", }, - cacheClient: mc, + dataBrokerClient: mockDataBrokerServiceClient{ + get: func(ctx context.Context, in *databroker.GetRequest, opts ...grpc.CallOption) (*databroker.GetResponse, error) { + data, err := ptypes.MarshalAny(&session.Session{ + Id: "SESSION_ID", + }) + if err != nil { + return nil, err + } + + return &databroker.GetResponse{ + Record: &databroker.Record{ + Version: "0001", + Type: data.GetTypeUrl(), + Id: "SESSION_ID", + Data: data, + }, + }, nil + }, + }, } uri := &url.URL{Scheme: tt.scheme, Host: tt.host} @@ -202,24 +221,21 @@ func TestAuthenticate_SignOut(t *testing.T) { wantCode int wantBody string }{ - {"good post", http.MethodPost, nil, "https://corp.pomerium.io/", "sig", "ts", identity.MockProvider{LogOutResponse: (*uriParseHelper("https://microsoft.com"))}, &mstore.Store{Encrypted: true, Session: &sessions.State{Email: "user@pomerium.io"}}, http.StatusFound, ""}, - {"failed revoke", http.MethodPost, nil, "https://corp.pomerium.io/", "sig", "ts", identity.MockProvider{RevokeError: errors.New("OH NO")}, &mstore.Store{Encrypted: true, Session: &sessions.State{Email: "user@pomerium.io"}}, http.StatusFound, ""}, - {"load session error", http.MethodPost, errors.New("error"), "https://corp.pomerium.io/", "sig", "ts", identity.MockProvider{RevokeError: errors.New("OH NO")}, &mstore.Store{Encrypted: true, Session: &sessions.State{Email: "user@pomerium.io"}}, http.StatusFound, ""}, - {"bad redirect uri", http.MethodPost, nil, "corp.pomerium.io/", "sig", "ts", identity.MockProvider{LogOutError: oidc.ErrSignoutNotImplemented}, &mstore.Store{Encrypted: true, Session: &sessions.State{Email: "user@pomerium.io"}}, http.StatusFound, ""}, + {"good post", http.MethodPost, nil, "https://corp.pomerium.io/", "sig", "ts", identity.MockProvider{LogOutResponse: (*uriParseHelper("https://microsoft.com"))}, &mstore.Store{Encrypted: true, Session: &sessions.State{}}, http.StatusFound, ""}, + {"failed revoke", http.MethodPost, nil, "https://corp.pomerium.io/", "sig", "ts", identity.MockProvider{RevokeError: errors.New("OH NO")}, &mstore.Store{Encrypted: true, Session: &sessions.State{}}, http.StatusFound, ""}, + {"load session error", http.MethodPost, errors.New("error"), "https://corp.pomerium.io/", "sig", "ts", identity.MockProvider{RevokeError: errors.New("OH NO")}, &mstore.Store{Encrypted: true, Session: &sessions.State{}}, http.StatusFound, ""}, + {"bad redirect uri", http.MethodPost, nil, "corp.pomerium.io/", "sig", "ts", identity.MockProvider{LogOutError: oidc.ErrSignoutNotImplemented}, &mstore.Store{Encrypted: true, Session: &sessions.State{}}, http.StatusFound, ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - mc := mock_cache.NewMockCacher(ctrl) - mc.EXPECT().Get(gomock.Any(), gomock.Any()).Return([]byte("hi"), nil).AnyTimes() a := &Authenticate{ sessionStore: tt.sessionStore, provider: tt.provider, encryptedEncoder: mock.Encoder{}, templates: template.Must(frontend.NewTemplates()), sharedEncoder: mock.Encoder{}, - cacheClient: mc, } u, _ := url.Parse("/sign_out") params, _ := url.ParseQuery(u.RawQuery) @@ -292,9 +308,6 @@ func TestAuthenticate_OAuthCallback(t *testing.T) { t.Run(tt.name, func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - mc := mock_cache.NewMockCacher(ctrl) - mc.EXPECT().Get(gomock.Any(), gomock.Any()).Return([]byte("hi"), nil).AnyTimes() - mc.EXPECT().Set(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() aead, err := chacha20poly1305.NewX(cryptutil.NewKey()) if err != nil { t.Fatal(err) @@ -309,7 +322,6 @@ func TestAuthenticate_OAuthCallback(t *testing.T) { sessionStore: tt.session, provider: tt.provider, cookieCipher: aead, - cacheClient: mc, encryptedEncoder: signer, } u, _ := url.Parse("/oauthGet") @@ -363,20 +375,59 @@ func TestAuthenticate_SessionValidatorMiddleware(t *testing.T) { wantStatus int }{ - {"good", nil, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, nil, identity.MockProvider{RefreshResponse: oauth2.Token{Expiry: time.Now().Add(10 * time.Minute)}}, http.StatusOK}, - {"invalid session", nil, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, errors.New("hi"), identity.MockProvider{}, http.StatusFound}, - {"good refresh expired", nil, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(-10 * time.Minute))}}, nil, identity.MockProvider{RefreshResponse: oauth2.Token{Expiry: time.Now().Add(10 * time.Minute)}}, http.StatusOK}, - {"expired,refresh error", nil, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(-10 * time.Minute))}}, sessions.ErrExpired, identity.MockProvider{RefreshError: errors.New("error")}, http.StatusFound}, - {"expired,save error", nil, &mstore.Store{SaveError: errors.New("error"), Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(-10 * time.Minute))}}, sessions.ErrExpired, identity.MockProvider{RefreshResponse: oauth2.Token{Expiry: time.Now().Add(10 * time.Minute)}}, http.StatusFound}, - {"expired XHR,refresh error", map[string]string{"X-Requested-With": "XmlHttpRequest"}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(-10 * time.Minute))}}, sessions.ErrExpired, identity.MockProvider{RefreshError: errors.New("error")}, http.StatusUnauthorized}, + { + "good", + nil, + &mstore.Store{Session: &sessions.State{Version: "v1", ID: "xyz", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, + nil, + identity.MockProvider{RefreshResponse: oauth2.Token{Expiry: time.Now().Add(10 * time.Minute)}}, + http.StatusOK, + }, + { + "invalid session", + nil, + &mstore.Store{Session: &sessions.State{Version: "v1", ID: "xyz", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, + errors.New("hi"), + identity.MockProvider{}, + http.StatusFound, + }, + { + "good refresh expired", + nil, + &mstore.Store{Session: &sessions.State{Version: "v1", ID: "xyz", Expiry: jwt.NewNumericDate(time.Now().Add(-10 * time.Minute))}}, + nil, + identity.MockProvider{RefreshResponse: oauth2.Token{Expiry: time.Now().Add(10 * time.Minute)}}, + http.StatusOK, + }, + { + "expired,refresh error", + nil, + &mstore.Store{Session: &sessions.State{Version: "v1", ID: "xyz", Expiry: jwt.NewNumericDate(time.Now().Add(-10 * time.Minute))}}, + sessions.ErrExpired, + identity.MockProvider{RefreshError: errors.New("error")}, + http.StatusFound, + }, + { + "expired,save error", + nil, + &mstore.Store{SaveError: errors.New("error"), Session: &sessions.State{Version: "v1", ID: "xyz", Expiry: jwt.NewNumericDate(time.Now().Add(-10 * time.Minute))}}, + sessions.ErrExpired, + identity.MockProvider{RefreshResponse: oauth2.Token{Expiry: time.Now().Add(10 * time.Minute)}}, + http.StatusFound, + }, + { + "expired XHR,refresh error", + map[string]string{"X-Requested-With": "XmlHttpRequest"}, + &mstore.Store{Session: &sessions.State{Version: "v1", ID: "xyz", Expiry: jwt.NewNumericDate(time.Now().Add(-10 * time.Minute))}}, + sessions.ErrExpired, + identity.MockProvider{RefreshError: errors.New("error")}, + http.StatusUnauthorized, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - mc := mock_cache.NewMockCacher(ctrl) - mc.EXPECT().Get(gomock.Any(), gomock.Any()).Return([]byte("hi"), nil).AnyTimes() - mc.EXPECT().Set(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() aead, err := chacha20poly1305.NewX(cryptutil.NewKey()) if err != nil { @@ -394,8 +445,7 @@ func TestAuthenticate_SessionValidatorMiddleware(t *testing.T) { provider: tt.provider, cookieCipher: aead, encryptedEncoder: signer, - cacheClient: mc, - sharedEncoder: mock.Encoder{}, + sharedEncoder: signer, } r := httptest.NewRequest("GET", "/", nil) state, err := tt.session.LoadSession(r) @@ -417,178 +467,7 @@ func TestAuthenticate_SessionValidatorMiddleware(t *testing.T) { got := a.VerifySession(fn) got.ServeHTTP(w, r) if status := w.Code; status != tt.wantStatus { - t.Errorf("VerifySession() error = %v, wantErr %v\n%v", w.Result().StatusCode, tt.wantStatus, w.Body.String()) - } - }) - } -} - -func TestAuthenticate_RefreshAPI(t *testing.T) { - t.Parallel() - tests := []struct { - name string - - session sessions.SessionStore - ctxError error - - provider identity.Authenticator - secretEncoder encoding.MarshalUnmarshaler - sharedEncoder encoding.MarshalUnmarshaler - - wantStatus int - }{ - {"good", &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, nil, identity.MockProvider{RefreshResponse: oauth2.Token{Expiry: time.Now().Add(10 * time.Minute)}}, mock.Encoder{MarshalResponse: []byte("ok")}, mock.Encoder{MarshalResponse: []byte("ok")}, http.StatusOK}, - {"refresh error", &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, nil, identity.MockProvider{RefreshError: errors.New("error")}, mock.Encoder{MarshalResponse: []byte("ok")}, mock.Encoder{MarshalResponse: []byte("ok")}, http.StatusInternalServerError}, - {"session is not refreshable error", &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, errors.New("session error"), identity.MockProvider{RefreshResponse: oauth2.Token{Expiry: time.Now().Add(10 * time.Minute)}}, mock.Encoder{MarshalResponse: []byte("ok")}, mock.Encoder{MarshalResponse: []byte("ok")}, http.StatusBadRequest}, - {"secret encoder failed", &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, nil, identity.MockProvider{RefreshResponse: oauth2.Token{Expiry: time.Now().Add(10 * time.Minute)}}, mock.Encoder{MarshalError: errors.New("error")}, mock.Encoder{MarshalResponse: []byte("ok")}, http.StatusInternalServerError}, - {"shared encoder failed", &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, nil, identity.MockProvider{RefreshResponse: oauth2.Token{Expiry: time.Now().Add(10 * time.Minute)}}, mock.Encoder{MarshalResponse: []byte("ok")}, mock.Encoder{MarshalError: errors.New("error")}, http.StatusInternalServerError}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - mc := mock_cache.NewMockCacher(ctrl) - mc.EXPECT().Get(gomock.Any(), gomock.Any()).Return([]byte("hi"), nil).AnyTimes() - mc.EXPECT().Set(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() - - aead, err := chacha20poly1305.NewX(cryptutil.NewKey()) - if err != nil { - t.Fatal(err) - } - a := Authenticate{ - sharedKey: cryptutil.NewBase64Key(), - cookieSecret: cryptutil.NewKey(), - RedirectURL: uriParseHelper("https://authenticate.corp.beyondperimeter.com"), - encryptedEncoder: tt.secretEncoder, - sharedEncoder: tt.sharedEncoder, - sessionStore: tt.session, - provider: tt.provider, - cookieCipher: aead, - cacheClient: mc, - } - r := httptest.NewRequest("GET", "/", nil) - state, _ := tt.session.LoadSession(r) - ctx := r.Context() - ctx = sessions.NewContext(ctx, state, tt.ctxError) - r = r.WithContext(ctx) - - r.Header.Set("Accept", "application/json") - - w := httptest.NewRecorder() - httputil.HandlerFunc(a.RefreshAPI).ServeHTTP(w, r) - if status := w.Code; status != tt.wantStatus { - t.Errorf("VerifySession() error = %v, wantErr %v\n%v", w.Result().StatusCode, tt.wantStatus, w.Body.String()) - - } - }) - } -} - -func TestAuthenticate_Refresh(t *testing.T) { - t.Parallel() - tests := []struct { - name string - - session *sessions.State - at *oauth2.Token - - provider identity.Authenticator - secretEncoder encoding.MarshalUnmarshaler - - wantStatus int - }{ - {"good", - &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}, - &oauth2.Token{AccessToken: "mock", Expiry: time.Now().Add(10 * time.Minute)}, - identity.MockProvider{RefreshResponse: oauth2.Token{Expiry: time.Now().Add(10 * time.Minute)}}, - mock.Encoder{MarshalResponse: []byte("ok")}, - 200}, - {"session and oauth2 expired", - &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(-10 * time.Minute))}, - &oauth2.Token{AccessToken: "mock", Expiry: time.Now().Add(-10 * time.Minute)}, - identity.MockProvider{RefreshResponse: oauth2.Token{Expiry: time.Now().Add(10 * time.Minute)}}, - mock.Encoder{MarshalResponse: []byte("ok")}, - 200}, - {"session expired", - &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(-10 * time.Minute))}, - &oauth2.Token{AccessToken: "mock", Expiry: time.Now().Add(10 * time.Minute)}, - identity.MockProvider{RefreshResponse: oauth2.Token{Expiry: time.Now().Add(10 * time.Minute)}}, - mock.Encoder{MarshalResponse: []byte("ok")}, - 200}, - {"failed refresh", - &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(-10 * time.Minute))}, - &oauth2.Token{AccessToken: "mock", Expiry: time.Now().Add(10 * time.Minute)}, - identity.MockProvider{RefreshError: errors.New("oh no")}, - mock.Encoder{MarshalResponse: []byte("ok")}, - 302}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - mc := mock_cache.NewMockCacher(ctrl) - // just enough is stubbed out here so we can use our own mock provider - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(200) - w.Header().Set("Content-Type", "application/json") - out := fmt.Sprintf(`{"issuer":"http://%s"}`, r.Host) - fmt.Fprintln(w, out) - })) - defer ts.Close() - rURL := ts.URL - a, err := New(config.Options{ - SharedKey: cryptutil.NewBase64Key(), - CookieSecret: cryptutil.NewBase64Key(), - AuthenticateURL: uriParseHelper("https://authenticate.corp.beyondperimeter.com"), - Provider: "oidc", - ClientID: "mock", - ClientSecret: "mock", - ProviderURL: rURL, - AuthenticateCallbackPath: "mock", - CookieName: "pomerium", - Addr: ":0", - CacheURL: uriParseHelper("https://authenticate.corp.beyondperimeter.com"), - AuthorizeURL: uriParseHelper("https://authorize.corp.beyondperimeter.com"), - }) - if err != nil { - t.Fatal(err) - } - a.cacheClient = mc - a.provider = tt.provider - - u, _ := url.Parse("/oauthGet") - params, _ := url.ParseQuery(u.RawQuery) - destination := urlutil.NewSignedURL(a.sharedKey, - &url.URL{ - Scheme: "https", - Host: "example.com", - Path: "/.pomerium/refresh"}) - - u.RawQuery = params.Encode() - - r := httptest.NewRequest(http.MethodGet, destination.String(), nil) - - jwt, err := a.sharedEncoder.Marshal(tt.session) - if err != nil { - t.Fatal(err) - } - rawToken, err := a.encryptedEncoder.Marshal(tt.at) - if err != nil { - t.Fatal(err) - } - mc.EXPECT().Get(gomock.Any(), gomock.Any()).Return(rawToken, nil).AnyTimes() - mc.EXPECT().Set(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() - a.cacheClient = mc - - r.Header.Set("Authorization", fmt.Sprintf("Pomerium %s", jwt)) - r.Header.Set("Accept", "application/json") - - w := httptest.NewRecorder() - router := mux.NewRouter() - a.Mount(router) - router.ServeHTTP(w, r) - if status := w.Code; status != tt.wantStatus { - t.Errorf("Refresh() error = %v, wantErr %v\n%v", w.Result().StatusCode, tt.wantStatus, w.Body.String()) + t.Errorf("VerifySession() error = %v, wantErr %v\n%v\n%v", w.Result().StatusCode, tt.wantStatus, w.Header(), w.Body.String()) } }) } @@ -629,3 +508,13 @@ func TestJwksEndpoint(t *testing.T) { expected := `{"keys":[{"use":"sig","kty":"EC","kid":"5b419ade1895fec2d2def6cd33b1b9a018df60db231dc5ecb85cbed6d942813c","crv":"P-256","alg":"ES256","x":"UG5xCP0JTT1H6Iol8jKuTIPVLM04CgW9PlEypNRmWlo","y":"KChF0fR09zm884ymInM29PtSsFdnzExNfLsP-ta1AgQ"}]}` assert.Equal(t, body, expected) } + +type mockDataBrokerServiceClient struct { + databroker.DataBrokerServiceClient + + get func(ctx context.Context, in *databroker.GetRequest, opts ...grpc.CallOption) (*databroker.GetResponse, error) +} + +func (m mockDataBrokerServiceClient) Get(ctx context.Context, in *databroker.GetRequest, opts ...grpc.CallOption) (*databroker.GetResponse, error) { + return m.get(ctx, in, opts...) +} diff --git a/authorize/authorize.go b/authorize/authorize.go index b5d452966..5da810057 100644 --- a/authorize/authorize.go +++ b/authorize/authorize.go @@ -6,25 +6,23 @@ package authorize import ( "context" - "encoding/base64" "fmt" "html/template" - "io/ioutil" + "sync" "sync/atomic" "github.com/pomerium/pomerium/authorize/evaluator" - "github.com/pomerium/pomerium/authorize/evaluator/opa" "github.com/pomerium/pomerium/config" "github.com/pomerium/pomerium/internal/cryptutil" "github.com/pomerium/pomerium/internal/encoding" "github.com/pomerium/pomerium/internal/encoding/jws" "github.com/pomerium/pomerium/internal/frontend" + "github.com/pomerium/pomerium/internal/grpc" + "github.com/pomerium/pomerium/internal/grpc/databroker" "github.com/pomerium/pomerium/internal/log" "github.com/pomerium/pomerium/internal/telemetry/metrics" "github.com/pomerium/pomerium/internal/telemetry/trace" "github.com/pomerium/pomerium/internal/urlutil" - - "gopkg.in/square/go-jose.v2" ) type atomicOptions struct { @@ -53,11 +51,17 @@ func (a *atomicMarshalUnmarshaler) Store(encoder encoding.MarshalUnmarshaler) { // Authorize struct holds type Authorize struct { - pe evaluator.Evaluator + pe *evaluator.Evaluator currentOptions atomicOptions currentEncoder atomicMarshalUnmarshaler templates *template.Template + + dataBrokerClient databroker.DataBrokerServiceClient + + dataBrokerDataLock sync.RWMutex + dataBrokerData evaluator.DataBrokerData + dataBrokerSessionServerVersion string } // New validates and creates a new Authorize service from a set of config options. @@ -65,8 +69,25 @@ func New(opts config.Options) (*Authorize, error) { if err := validateOptions(opts); err != nil { return nil, fmt.Errorf("authorize: bad options: %w", err) } + + dataBrokerConn, err := grpc.NewGRPCClientConn( + &grpc.Options{ + Addr: opts.DataBrokerURL, + OverrideCertificateName: opts.OverrideCertificateName, + CA: opts.CA, + CAFile: opts.CAFile, + RequestTimeout: opts.GRPCClientTimeout, + ClientDNSRoundRobin: opts.GRPCClientDNSRoundRobin, + WithInsecure: opts.GRPCInsecure, + }) + if err != nil { + return nil, fmt.Errorf("authorize: error creating cache connection: %w", err) + } + a := Authorize{ - templates: template.Must(frontend.NewTemplates()), + templates: template.Must(frontend.NewTemplates()), + dataBrokerClient: databroker.NewDataBrokerServiceClient(dataBrokerConn), + dataBrokerData: make(evaluator.DataBrokerData), } var host string @@ -98,62 +119,14 @@ func validateOptions(o config.Options) error { } // newPolicyEvaluator returns an policy evaluator. -func newPolicyEvaluator(opts *config.Options) (evaluator.Evaluator, error) { +func newPolicyEvaluator(opts *config.Options) (*evaluator.Evaluator, error) { metrics.AddPolicyCountCallback("pomerium-authorize", func() int64 { return int64(len(opts.Policies)) }) ctx := context.Background() - ctx, span := trace.StartSpan(ctx, "authorize.newPolicyEvaluator") + _, span := trace.StartSpan(ctx, "authorize.newPolicyEvaluator") defer span.End() - var jwk jose.JSONWebKey - if opts.SigningKey == "" { - key, err := cryptutil.NewSigningKey() - if err != nil { - return nil, fmt.Errorf("authorize: couldn't generate signing key: %w", err) - } - jwk.Key = key - pubKeyBytes, err := cryptutil.EncodePublicKey(&key.PublicKey) - if err != nil { - return nil, fmt.Errorf("authorize: encode public key: %w", err) - } - log.Info().Interface("PublicKey", pubKeyBytes).Msg("authorize: ecdsa public key") - } else { - decodedCert, err := base64.StdEncoding.DecodeString(opts.SigningKey) - if err != nil { - return nil, fmt.Errorf("authorize: failed to decode certificate cert %v: %w", decodedCert, err) - } - keyBytes, err := cryptutil.DecodePrivateKey((decodedCert)) - if err != nil { - return nil, fmt.Errorf("authorize: couldn't generate signing key: %w", err) - } - jwk.Key = keyBytes - } - - var clientCA string - if opts.ClientCA != "" { - bs, err := base64.StdEncoding.DecodeString(opts.ClientCA) - if err != nil { - return nil, fmt.Errorf("authorize: invalid client ca: %w", err) - } - clientCA = string(bs) - } else if opts.ClientCAFile != "" { - bs, err := ioutil.ReadFile(opts.ClientCAFile) - if err != nil { - return nil, fmt.Errorf("authorize: invalid client ca file: %w", err) - } - clientCA = string(bs) - } - - data := map[string]interface{}{ - "shared_key": opts.SharedKey, - "route_policies": opts.Policies, - "admins": opts.Administrators, - "signing_key": jwk, - "authenticate_url": opts.AuthenticateURLString, - "client_ca": clientCA, - } - - return opa.New(ctx, &opa.Options{Data: data}) + return evaluator.New(opts) } // UpdateOptions implements the OptionsUpdater interface and updates internal diff --git a/authorize/authorize_test.go b/authorize/authorize_test.go index 1f6d6822e..244220db6 100644 --- a/authorize/authorize_test.go +++ b/authorize/authorize_test.go @@ -26,6 +26,7 @@ func TestNew(t *testing.T) { t.Run(tt.name, func(t *testing.T) { o := config.Options{ AuthenticateURL: mustParseURL("https://authN.example.com"), + DataBrokerURL: mustParseURL("https://cache.example.com"), SharedKey: tt.SharedKey, Policies: tt.Policies} if tt.name == "empty options" { diff --git a/authorize/check_response.go b/authorize/check_response.go index e5f9d5dd0..bc1b2aec4 100644 --- a/authorize/check_response.go +++ b/authorize/check_response.go @@ -12,23 +12,22 @@ import ( "google.golang.org/genproto/googleapis/rpc/status" "google.golang.org/grpc/codes" - "github.com/pomerium/pomerium/internal/grpc/authorize" + "github.com/pomerium/pomerium/authorize/evaluator" "github.com/pomerium/pomerium/internal/httputil" "github.com/pomerium/pomerium/internal/log" "github.com/pomerium/pomerium/internal/urlutil" ) func (a *Authorize) okResponse( - reply *authorize.IsAuthorizedReply, + reply *evaluator.Result, rawSession []byte, - isNewSession bool, ) *envoy_service_auth_v2.CheckResponse { - requestHeaders, err := a.getEnvoyRequestHeaders(rawSession, isNewSession) + requestHeaders, err := a.getEnvoyRequestHeaders(rawSession) if err != nil { log.Warn().Err(err).Msg("authorize: error generating new request headers") } requestHeaders = append(requestHeaders, - mkHeader(httputil.HeaderPomeriumJWTAssertion, reply.SignedJwt)) + mkHeader(httputil.HeaderPomeriumJWTAssertion, reply.SignedJWT)) return &envoy_service_auth_v2.CheckResponse{ Status: &status.Status{Code: int32(codes.OK), Message: "OK"}, diff --git a/authorize/evaluator/evaluator.go b/authorize/evaluator/evaluator.go index afeebbe42..dd9e4d097 100644 --- a/authorize/evaluator/evaluator.go +++ b/authorize/evaluator/evaluator.go @@ -4,48 +4,321 @@ package evaluator import ( "context" + "encoding/base64" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "strconv" - pb "github.com/pomerium/pomerium/internal/grpc/authorize" + "github.com/golang/protobuf/proto" + "github.com/golang/protobuf/ptypes" + "github.com/open-policy-agent/opa/rego" + "github.com/open-policy-agent/opa/storage/inmem" + "google.golang.org/protobuf/reflect/protoregistry" + "google.golang.org/protobuf/types/known/anypb" + "gopkg.in/square/go-jose.v2" + + "github.com/pomerium/pomerium/config" + "github.com/pomerium/pomerium/internal/cryptutil" + "github.com/pomerium/pomerium/internal/directory" + "github.com/pomerium/pomerium/internal/grpc/databroker" + "github.com/pomerium/pomerium/internal/grpc/session" + "github.com/pomerium/pomerium/internal/grpc/user" + "github.com/pomerium/pomerium/internal/log" ) // Evaluator specifies the interface for a policy engine. -type Evaluator interface { - IsAuthorized(ctx context.Context, req *Request) (*pb.IsAuthorizedReply, error) - PutData(ctx context.Context, data map[string]interface{}) error +type Evaluator struct { + rego *rego.Rego + query rego.PreparedEvalQuery + + clientCA string + authenticateHost string + jwk interface{} } -// A Request represents an evaluable request with an associated user, device, -// and request context. -type Request struct { - // User context - // - // User contains the associated user's JWT created by the authenticate - // service - User string `json:"user,omitempty"` +// New creates a new Evaluator. +func New(options *config.Options) (*Evaluator, error) { + e := &Evaluator{ + authenticateHost: options.AuthenticateURL.Host, + } + if options.ClientCA != "" { + e.clientCA = options.ClientCA + } else if options.ClientCAFile != "" { + bs, err := ioutil.ReadFile(options.ClientCAFile) + if err != nil { + return nil, err + } + e.clientCA = string(bs) + } - // Request context - // - // Method specifies the HTTP method (GET, POST, PUT, etc.). - Method string `json:"method,omitempty"` - // URL specifies either the URI being requested. - URL string `json:"url,omitempty"` - // Header contains the request header fields either received - // by the server or to be sent by the client. - Header map[string][]string `json:"headers,omitempty"` - // Host specifies the host on which the URL is sought. - Host string `json:"host,omitempty"` - // RequestURI is the unmodified request-target of the - // Request-Line (RFC 7230, Section 3.1.1) as sent by the client - // to a server. Usually the URL field should be used instead. - // It is an error to set this field in an HTTP client request. - RequestURI string `json:"request_uri,omitempty"` + if options.SigningKey == "" { + key, err := cryptutil.NewSigningKey() + if err != nil { + return nil, fmt.Errorf("authorize: couldn't generate signing key: %w", err) + } + e.jwk = key + pubKeyBytes, err := cryptutil.EncodePublicKey(&key.PublicKey) + if err != nil { + return nil, fmt.Errorf("authorize: encode public key: %w", err) + } + log.Info().Interface("PublicKey", pubKeyBytes).Msg("authorize: ecdsa public key") + } else { + decodedCert, err := base64.StdEncoding.DecodeString(options.SigningKey) + if err != nil { + return nil, fmt.Errorf("authorize: failed to decode certificate cert %v: %w", decodedCert, err) + } + keyBytes, err := cryptutil.DecodePrivateKey((decodedCert)) + if err != nil { + return nil, fmt.Errorf("authorize: couldn't generate signing key: %w", err) + } + e.jwk = keyBytes + } - // Connection context - // - // ClientCertificate is the PEM-encoded public certificate used for the user's TLS connection. - ClientCertificate string `json:"client_certificate"` + authzPolicy, err := readPolicy("/authz.rego") + if err != nil { + return nil, fmt.Errorf("error loading rego policy: %w", err) + } - // Device context - // - // todo(bdd): Use the peer TLS certificate to bind device state with a request + e.rego = rego.New( + rego.Store(inmem.NewFromObject(map[string]interface{}{ + "admins": options.Administrators, + "route_policies": options.Policies, + })), + rego.Module("pomerium.authz", string(authzPolicy)), + rego.Query("result = data.pomerium.authz"), + ) + + e.query, err = e.rego.PrepareForEval(context.Background()) + if err != nil { + return nil, fmt.Errorf("error preparing rego query: %w", err) + } + + return e, nil +} + +// Evaluate evaluates the policy against the request. +func (e *Evaluator) Evaluate(ctx context.Context, req *Request) (*Result, error) { + isValid, err := isValidClientCertificate(e.clientCA, req.HTTP.ClientCertificate) + if err != nil { + return nil, fmt.Errorf("error validating client certificate: %w", err) + } + + res, err := e.query.Eval(ctx, rego.EvalInput(e.newInput(req, isValid))) + if err != nil { + return nil, fmt.Errorf("error evaluating rego policy: %w", err) + } + + deny := getDenyVar(res[0].Bindings.WithoutWildcards()) + if len(deny) > 0 { + return &deny[0], nil + } + + signedJWT, err := e.getSignedJWT(req) + if err != nil { + return nil, fmt.Errorf("error signing JWT: %w", err) + } + + allow := allowed(res[0].Bindings.WithoutWildcards()) + if allow { + return &Result{ + Status: http.StatusOK, + Message: "OK", + SignedJWT: signedJWT, + }, nil + } + + if req.Session.ID == "" { + return &Result{ + Status: http.StatusUnauthorized, + Message: "login required", + SignedJWT: signedJWT, + }, nil + } + + return &Result{ + Status: http.StatusForbidden, + Message: "forbidden", + SignedJWT: signedJWT, + }, nil +} + +func (e *Evaluator) getSignedJWT(req *Request) (string, error) { + signer, err := jose.NewSigner(jose.SigningKey{ + Algorithm: jose.ES256, + Key: e.jwk, + }, nil) + if err != nil { + return "", err + } + + payload := map[string]interface{}{ + "iss": e.authenticateHost, + } + if u, err := url.Parse(req.HTTP.URL); err == nil { + payload["aud"] = u.Hostname() + } + if s, ok := req.DataBrokerData.Get("type.googleapis.com/session.Session", req.Session.ID).(*session.Session); ok { + if tm, err := ptypes.Timestamp(s.GetIdToken().GetExpiresAt()); err == nil { + payload["exp"] = tm.Unix() + } + if tm, err := ptypes.Timestamp(s.GetIdToken().GetIssuedAt()); err == nil { + payload["iat"] = tm.Unix() + } + if u, ok := req.DataBrokerData.Get("type.googleapis.com/user.User", s.GetUserId()).(*user.User); ok { + payload["sub"] = u.GetId() + payload["email"] = u.GetEmail() + } + if du, ok := req.DataBrokerData.Get("type.googleapis.com/directory.User", s.GetUserId()).(*directory.User); ok { + payload["groups"] = du.GetGroups() + } + } + + bs, err := json.Marshal(payload) + if err != nil { + return "", err + } + + jws, err := signer.Sign(bs) + if err != nil { + return "", err + } + + return jws.CompactSerialize() +} + +type input struct { + DataBrokerData DataBrokerData `json:"databroker_data"` + HTTP RequestHTTP `json:"http"` + Session RequestSession `json:"session"` + IsValidClientCertificate bool `json:"is_valid_client_certificate"` +} + +func (e *Evaluator) newInput(req *Request, isValidClientCertificate bool) *input { + i := new(input) + i.DataBrokerData = req.DataBrokerData + i.HTTP = req.HTTP + i.Session = req.Session + i.IsValidClientCertificate = isValidClientCertificate + return i +} + +type ( + // Request is the request data used for the evaluator. + Request struct { + DataBrokerData DataBrokerData `json:"databroker_data"` + HTTP RequestHTTP `json:"http"` + Session RequestSession `json:"session"` + } + + // 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"` + ImpersonateEmail string `json:"impersonate_email"` + ImpersonateGroups []string `json:"impersonate_groups"` + } +) + +// Result is the result of evaluation. +type Result struct { + Status int + Message string + SignedJWT string +} + +func allowed(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().Err(err).Msg("invalid type in deny") + continue + } + msg := fmt.Sprint(denial[1]) + + results = append(results, Result{ + Status: status, + Message: msg, + }) + } + return results +} + +// DataBrokerData stores the data broker data by type => id => record +type DataBrokerData map[string]map[string]interface{} + +// Get gets a record from the DataBrokerData. +func (dbd DataBrokerData) Get(typeURL, id string) interface{} { + m, ok := dbd[typeURL] + if !ok { + return nil + } + return m[id] +} + +// Update updates a record in the DataBrokerData. +func (dbd DataBrokerData) Update(record *databroker.Record) { + db, ok := dbd[record.GetType()] + if !ok { + db = make(map[string]interface{}) + dbd[record.GetType()] = db + } + + if record.GetDeletedAt() != nil { + delete(db, record.GetId()) + } else { + if obj, err := unmarshalAny(record.GetData()); err == nil { + db[record.GetId()] = obj + } else { + log.Warn().Err(err).Msg("failed to unmarshal unknown any type") + delete(db, record.GetId()) + } + } +} + +func unmarshalAny(any *anypb.Any) (proto.Message, error) { + messageType, err := protoregistry.GlobalTypes.FindMessageByURL(any.GetTypeUrl()) + if err != nil { + return nil, err + } + msg := proto.MessageV1(messageType.New()) + return msg, ptypes.UnmarshalAny(any, msg) } diff --git a/authorize/evaluator/evaluator_test.go b/authorize/evaluator/evaluator_test.go new file mode 100644 index 000000000..705dc06bf --- /dev/null +++ b/authorize/evaluator/evaluator_test.go @@ -0,0 +1,67 @@ +package evaluator + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/pomerium/pomerium/internal/grpc/directory" +) + +func TestJSONMarshal(t *testing.T) { + dbd := DataBrokerData{ + "type.googleapis.com/directory.User": map[string]interface{}{ + "user1": &directory.User{ + Id: "user1", + Groups: []string{"group1", "group2"}, + }, + }, + "type.googleapis.com/session.Session": map[string]interface{}{}, + "type.googleapis.com/user.User": map[string]interface{}{}, + } + + bs, _ := json.Marshal(input{ + DataBrokerData: dbd, + HTTP: RequestHTTP{ + Method: "GET", + URL: "https://example.com", + Headers: map[string]string{ + "Accept": "application/json", + }, + ClientCertificate: "CLIENT_CERTIFICATE", + }, + Session: RequestSession{ + ID: "SESSION_ID", + ImpersonateEmail: "y@example.com", + ImpersonateGroups: []string{"group1"}, + }, + IsValidClientCertificate: true, + }) + assert.JSONEq(t, `{ + "databroker_data": { + "type.googleapis.com/directory.User": { + "user1": { + "id": "user1", + "groups": ["group1", "group2"] + } + }, + "type.googleapis.com/session.Session": {}, + "type.googleapis.com/user.User": {} + }, + "http": { + "client_certificate": "CLIENT_CERTIFICATE", + "headers": { + "Accept": "application/json" + }, + "method": "GET", + "url": "https://example.com" + }, + "session": { + "id": "SESSION_ID", + "impersonate_email": "y@example.com", + "impersonate_groups": ["group1"] + }, + "is_valid_client_certificate": true + }`, string(bs)) +} diff --git a/authorize/evaluator/opa/functions.go b/authorize/evaluator/functions.go similarity index 73% rename from authorize/evaluator/opa/functions.go rename to authorize/evaluator/functions.go index 231d84277..3aca12635 100644 --- a/authorize/evaluator/opa/functions.go +++ b/authorize/evaluator/functions.go @@ -1,11 +1,15 @@ -package opa +package evaluator import ( "crypto/x509" "encoding/pem" "fmt" + "io/ioutil" lru "github.com/hashicorp/golang-lru" + "github.com/rakyll/statik/fs" + + _ "github.com/pomerium/pomerium/authorize/evaluator/opa/policy" // load static assets ) var isValidClientCertificateCache, _ = lru.New2Q(100) @@ -56,3 +60,18 @@ func parseCertificate(pemStr string) (*x509.Certificate, error) { } return x509.ParseCertificate(block.Bytes) } + +const statikNamespace = "rego" + +func readPolicy(fn string) ([]byte, error) { + statikFS, err := fs.NewWithNamespace(statikNamespace) + if err != nil { + return nil, err + } + r, err := statikFS.Open(fn) + if err != nil { + return nil, err + } + defer r.Close() + return ioutil.ReadAll(r) +} diff --git a/authorize/evaluator/opa/functions_test.go b/authorize/evaluator/functions_test.go similarity index 99% rename from authorize/evaluator/opa/functions_test.go rename to authorize/evaluator/functions_test.go index c9df75931..f93550315 100644 --- a/authorize/evaluator/opa/functions_test.go +++ b/authorize/evaluator/functions_test.go @@ -1,4 +1,4 @@ -package opa +package evaluator import ( "testing" diff --git a/authorize/evaluator/opa/opa.go b/authorize/evaluator/opa/opa.go index 086bb959e..1d6ad96e1 100644 --- a/authorize/evaluator/opa/opa.go +++ b/authorize/evaluator/opa/opa.go @@ -1,278 +1,6 @@ -//go:generate go run github.com/rakyll/statik -src=./policy -include=*.rego -ns rego -p policy -//go:generate go fmt ./policy/statik.go - // Package opa implements the policy evaluator interface to make authorization // decisions. package opa -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io/ioutil" - "strconv" - "sync" - - "github.com/open-policy-agent/opa/rego" - "github.com/open-policy-agent/opa/storage" - "github.com/open-policy-agent/opa/storage/inmem" - "github.com/rakyll/statik/fs" - - "github.com/pomerium/pomerium/authorize/evaluator" - _ "github.com/pomerium/pomerium/authorize/evaluator/opa/policy" // load static assets - pb "github.com/pomerium/pomerium/internal/grpc/authorize" - "github.com/pomerium/pomerium/internal/telemetry/trace" -) - -const statikNamespace = "rego" - -var _ evaluator.Evaluator = &PolicyEvaluator{} - -// PolicyEvaluator implements the evaluator interface using the open policy -// agent framework. The Open Policy Agent (OPA, pronounced “oh-pa”) is an open -// source, general-purpose policy engine that unifies policy enforcement across -// the stack. -// https://www.openpolicyagent.org/docs/latest/ -type PolicyEvaluator struct { - // The in-memory store supports multi-reader/single-writer concurrency with - // rollback so we leverage a RWMutex. - mu sync.RWMutex - store storage.Store - isAuthorized rego.PreparedEvalQuery - clientCA string -} - -// Options represent OPA's evaluator configurations. -type Options struct { - // AuthorizationPolicy accepts custom rego code which can be used to - // apply custom authorization policy. - // Defaults to authorization policy defined in config.yaml's policy. - AuthorizationPolicy string - // Data maps data that will be bound and - Data map[string]interface{} -} - -// New creates a new OPA policy evaluator. -func New(ctx context.Context, opts *Options) (*PolicyEvaluator, error) { - var pe PolicyEvaluator - pe.store = inmem.New() - - if opts.Data == nil { - return nil, errors.New("opa: cannot create new evaluator without data") - } - if opts.AuthorizationPolicy == "" { - b, err := readPolicy("/authz.rego") - if err != nil { - return nil, err - } - opts.AuthorizationPolicy = string(b) - } - if err := pe.PutData(ctx, opts.Data); err != nil { - return nil, err - } - if err := pe.UpdatePolicy(ctx, opts.AuthorizationPolicy); err != nil { - return nil, err - } - return &pe, nil -} - -// UpdatePolicy takes authorization and privilege access management rego code -// as an input and updates the prepared policy evaluator. -func (pe *PolicyEvaluator) UpdatePolicy(ctx context.Context, authz string) error { - ctx, span := trace.StartSpan(ctx, "authorize.evaluator.opa.UpdatePolicy") - defer span.End() - - var err error - pe.mu.Lock() - defer pe.mu.Unlock() - - r := rego.New( - rego.Store(pe.store), - rego.Module("pomerium.authz", authz), - rego.Query("result = data.pomerium.authz"), - ) - pe.isAuthorized, err = r.PrepareForEval(ctx) - if err != nil { - return fmt.Errorf("opa: prepare policy: %w", err) - } - return nil -} - -// IsAuthorized determines if a given request input is authorized. -func (pe *PolicyEvaluator) IsAuthorized(ctx context.Context, req *evaluator.Request) (*pb.IsAuthorizedReply, error) { - ctx, span := trace.StartSpan(ctx, "authorize.evaluator.opa.IsAuthorized") - defer span.End() - return pe.runBoolQuery(ctx, req, pe.isAuthorized) -} - -// PutData adds (or replaces if the mapping key is the same) contextual data -// for making policy decisions. -func (pe *PolicyEvaluator) PutData(ctx context.Context, data map[string]interface{}) error { - ctx, span := trace.StartSpan(ctx, "authorize.evaluator.opa.PutData") - defer span.End() - - pe.mu.Lock() - defer pe.mu.Unlock() - - if ca, ok := data["client_ca"].(string); ok { - pe.clientCA = ca - } - - txn, err := pe.store.NewTransaction(ctx, storage.WriteParams) - if err != nil { - return fmt.Errorf("opa: bad transaction: %w", err) - } - if err := pe.store.Write(ctx, txn, storage.ReplaceOp, storage.Path{}, data); err != nil { - pe.store.Abort(ctx, txn) - return fmt.Errorf("opa: write failed %v : %w", data, err) - } - if err := pe.store.Commit(ctx, txn); err != nil { - return fmt.Errorf("opa: commit failed: %w", err) - } - return nil -} - -func decisionFromInterface(i interface{}) (*pb.IsAuthorizedReply, error) { - var d pb.IsAuthorizedReply - var ok bool - m, ok := i.(map[string]interface{}) - if !ok { - return nil, errors.New("interface must be a map") - } - if d.Allow, ok = m["allow"].(bool); !ok { - return nil, errors.New("allow should be bool") - } - if d.SessionExpired, ok = m["expired"].(bool); !ok { - return nil, errors.New("expired should be bool") - } - - switch v := m["deny"].(type) { - case []interface{}: - for _, cause := range v { - if c, ok := cause.(string); ok { - d.DenyReasons = append(d.DenyReasons, c) - } - } - case string: - d.DenyReasons = []string{v} - } - - if v, ok := m["user"].(string); ok { - d.User = v - } - - if v, ok := m["email"].(string); ok { - d.Email = v - } - - switch v := m["groups"].(type) { - case []interface{}: - for _, cause := range v { - if c, ok := cause.(string); ok { - d.Groups = append(d.Groups, c) - } - } - case string: - d.Groups = []string{v} - } - - if v, ok := m["signed_jwt"].(string); ok { - d.SignedJwt = v - } - - // http_status = [200, "OK", { "HEADER": "VALUE" }] - if v, ok := m["http_status"].([]interface{}); ok { - d.HttpStatus = new(pb.HTTPStatus) - if len(v) > 0 { - d.HttpStatus.Code = int32(anyToInt(v[0])) - } - if len(v) > 1 { - if msg, ok := v[1].(string); ok { - d.HttpStatus.Message = msg - } - } - if len(v) > 2 { - if headers, ok := v[2].(map[string]interface{}); ok { - d.HttpStatus.Headers = make(map[string]string) - for hk, hv := range headers { - d.HttpStatus.Headers[hk] = fmt.Sprint(hv) - } - } - } - } - - return &d, nil -} - -func (pe *PolicyEvaluator) runBoolQuery(ctx context.Context, req *evaluator.Request, q rego.PreparedEvalQuery) (*pb.IsAuthorizedReply, error) { - pe.mu.RLock() - defer pe.mu.RUnlock() - - // `opa test` doesn't support custom function, so we'll pre-compute is_valid_client_certificate - isValid, err := isValidClientCertificate(pe.clientCA, req.ClientCertificate) - if err != nil { - return nil, fmt.Errorf("certificate error: %w", err) - } - input := struct { - *evaluator.Request - IsValidClientCertificate bool `json:"is_valid_client_certificate"` - }{ - Request: req, - IsValidClientCertificate: isValid, - } - - rs, err := q.Eval(ctx, rego.EvalInput(input)) - if err != nil { - return nil, fmt.Errorf("eval query: %w", err) - } else if len(rs) == 0 { - return nil, fmt.Errorf("empty eval result set %v", rs) - } - bindings := rs[0].Bindings.WithoutWildcards()["result"] - return decisionFromInterface(bindings) -} - -func readPolicy(fn string) ([]byte, error) { - statikFS, err := fs.NewWithNamespace(statikNamespace) - if err != nil { - return nil, err - } - r, err := statikFS.Open(fn) - if err != nil { - return nil, err - } - defer r.Close() - return ioutil.ReadAll(r) -} - -func anyToInt(obj interface{}) int { - switch v := obj.(type) { - case int: - return v - case int64: - return int(v) - case int32: - return int(v) - case int16: - return int(v) - case int8: - return int(v) - case uint64: - return int(v) - case uint32: - return int(v) - case uint16: - return int(v) - case uint8: - return int(v) - case json.Number: - i, _ := v.Int64() - return int(i) - case string: - i, _ := strconv.Atoi(v) - return i - default: - i, _ := strconv.Atoi(fmt.Sprint(v)) - return i - } -} +//go:generate go run github.com/rakyll/statik -src=./policy -include=*.rego -ns rego -p policy +//go:generate go fmt ./policy/statik.go diff --git a/authorize/evaluator/opa/opa_test.go b/authorize/evaluator/opa/opa_test.go deleted file mode 100644 index 169eec5e2..000000000 --- a/authorize/evaluator/opa/opa_test.go +++ /dev/null @@ -1,123 +0,0 @@ -package opa - -import ( - "context" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "gopkg.in/square/go-jose.v2" - "gopkg.in/square/go-jose.v2/jwt" - - "github.com/pomerium/pomerium/authorize/evaluator" - "github.com/pomerium/pomerium/config" -) - -func Test_Eval(t *testing.T) { - t.Parallel() - type Identity struct { - User string `json:"user,omitempty"` - Email string `json:"email,omitempty"` - Groups []string `json:"groups,omitempty"` - ImpersonateEmail string `json:"impersonate_email,omitempty"` - ImpersonateGroups []string `json:"impersonate_groups,omitempty"` - } - tests := []struct { - name string - policies []config.Policy - route string - Identity *Identity - admins []string - secret string - want bool - }{ - {"valid domain", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedDomains: []string{"example.com"}}}, "from.example", &Identity{Email: "user@example.com"}, nil, "secret", true}, - {"valid domain with admins", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedDomains: []string{"example.com"}}}, "from.example", &Identity{Email: "user@example.com"}, []string{"admin@example.com"}, "secret", true}, - {"invalid domain prepend", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedDomains: []string{"example.com"}}}, "from.example", &Identity{Email: "a@1example.com"}, nil, "secret", false}, - {"invalid domain postpend", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedDomains: []string{"example.com"}}}, "from.example", &Identity{Email: "user@example.com2"}, nil, "secret", false}, - {"valid group", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedGroups: []string{"admin"}}}, "from.example", &Identity{Email: "user@example.com", Groups: []string{"admin"}}, nil, "secret", true}, - {"invalid group", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedGroups: []string{"admin"}}}, "from.example", &Identity{Email: "user@example.com", Groups: []string{"everyone"}}, nil, "secret", false}, - {"invalid empty", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedGroups: []string{"admin"}}}, "from.example", &Identity{Email: "user@example.com", Groups: []string{""}}, nil, "secret", false}, - {"valid group multiple", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedGroups: []string{"admin"}}}, "from.example", &Identity{Email: "user@example.com", Groups: []string{"everyone", "admin"}}, nil, "secret", true}, - {"invalid group multiple", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedGroups: []string{"admin"}}}, "from.example", &Identity{Email: "user@example.com", Groups: []string{"everyones", "sadmin"}}, nil, "secret", false}, - {"valid user email", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedUsers: []string{"user@example.com"}}}, "from.example", &Identity{Email: "user@example.com"}, nil, "secret", true}, - {"invalid user email", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedUsers: []string{"user@example.com"}}}, "from.example", &Identity{Email: "user2@example.com"}, nil, "secret", false}, - {"empty everything", []config.Policy{{From: "https://from.example", To: "https://to.example"}}, "from.example", &Identity{Email: "user2@example.com"}, nil, "secret", false}, - {"empty policy", []config.Policy{}, "from.example", &Identity{Email: "user2@example.com"}, nil, "secret", false}, - // impersonation related - {"admin not impersonating allowed", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedDomains: []string{"example.com"}}}, "from.example", &Identity{Email: "admin@example.com"}, []string{"admin@example.com"}, "secret", true}, - {"admin not impersonating denied", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedDomains: []string{"example.com"}}}, "from.example", &Identity{Email: "admin@admin-domain.com"}, []string{"admin@admin-domain.com"}, "secret", false}, - {"impersonating match domain", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedDomains: []string{"example.com"}}}, "from.example", &Identity{Email: "admin@admin-domain.com", ImpersonateEmail: "user@example.com"}, []string{"admin@admin-domain.com"}, "secret", true}, - {"impersonating does not match domain", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedDomains: []string{"example.com"}}}, "from.example", &Identity{Email: "admin@admin-domain.com", ImpersonateEmail: "user@not-example.com"}, []string{"admin@admin-domain.com"}, "secret", false}, - {"impersonating match email", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedUsers: []string{"user@example.com"}}}, "from.example", &Identity{Email: "admin@admin-domain.com", ImpersonateEmail: "user@example.com"}, []string{"admin@admin-domain.com"}, "secret", true}, - {"impersonating does not match email", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedUsers: []string{"user@example.com"}}}, "from.example", &Identity{Email: "admin@admin-domain.com", ImpersonateEmail: "user@not-example.com"}, []string{"admin@admin-domain.com"}, "secret", false}, - {"impersonating match groups", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedGroups: []string{"support"}}}, "from.example", &Identity{Email: "admin@admin-domain.com", ImpersonateGroups: []string{"support"}}, []string{"admin@admin-domain.com"}, "secret", true}, - {"impersonating match many groups", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedGroups: []string{"support"}}}, "from.example", &Identity{Email: "admin@admin-domain.com", ImpersonateGroups: []string{"a", "b", "c", "support"}}, []string{"admin@admin-domain.com"}, "secret", true}, - {"impersonating does not match groups", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedGroups: []string{"support"}}}, "from.example", &Identity{Email: "admin@admin-domain.com", ImpersonateGroups: []string{"not support"}}, []string{"admin@admin-domain.com"}, "secret", false}, - {"impersonating does not match many groups", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedGroups: []string{"support"}}}, "from.example", &Identity{Email: "admin@admin-domain.com", ImpersonateGroups: []string{"not support", "b", "c"}}, []string{"admin@admin-domain.com"}, "secret", false}, - {"impersonating does not match empty groups", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedGroups: []string{"support"}}}, "from.example", &Identity{Email: "admin@admin-domain.com", ImpersonateGroups: []string{""}}, []string{"admin@admin-domain.com"}, "secret", false}, - // jwt validation - {"bad jwt shared secret", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedDomains: []string{"example.com"}}}, "from.example", &Identity{Email: "user@example.com"}, nil, "bad-secret", false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - for i := range tt.policies { - if err := (&tt.policies[i]).Validate(); err != nil { - t.Fatal(err) - } - } - key := []byte("secret") - sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.HS256, Key: key}, - (&jose.SignerOptions{}).WithType("JWT")) - if err != nil { - t.Fatal(err) - } - - cl := jwt.Claims{ - NotBefore: jwt.NewNumericDate(time.Now()), - Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour)), - Audience: jwt.Audience{tt.route}, - } - rawJWT, err := jwt.Signed(sig).Claims(cl).Claims(tt.Identity).CompactSerialize() - if err != nil { - t.Fatal(err) - } - - data := map[string]interface{}{ - "route_policies": tt.policies, - "admins": tt.admins, - "shared_key": tt.secret, - } - pe, err := New(context.Background(), &Options{Data: data}) - if err != nil { - t.Fatal(err) - } - req := &evaluator.Request{ - Host: tt.route, - URL: "https://" + tt.route, - User: rawJWT, - } - got, err := pe.IsAuthorized(context.TODO(), req) - if err != nil { - t.Fatal(err) - } - if got.GetAllow() != tt.want { - t.Errorf("pe.Eval() = %v, want %v", got.GetAllow(), tt.want) - } - }) - } -} - -func Test_anyToInt(t *testing.T) { - assert.Equal(t, 5, anyToInt("5")) - assert.Equal(t, 7, anyToInt(7)) - assert.Equal(t, 9, anyToInt(int8(9))) - assert.Equal(t, 9, anyToInt(int16(9))) - assert.Equal(t, 9, anyToInt(int32(9))) - assert.Equal(t, 9, anyToInt(int64(9))) - assert.Equal(t, 11, anyToInt(uint8(11))) - assert.Equal(t, 11, anyToInt(uint16(11))) - assert.Equal(t, 11, anyToInt(uint32(11))) - assert.Equal(t, 11, anyToInt(uint64(11))) - assert.Equal(t, 13, anyToInt(13.0)) -} diff --git a/authorize/evaluator/opa/policy/authz.rego b/authorize/evaluator/opa/policy/authz.rego index 09c78aef8..960fb095b 100644 --- a/authorize/evaluator/opa/policy/authz.rego +++ b/authorize/evaluator/opa/policy/authz.rego @@ -1,84 +1,91 @@ package pomerium.authz -import data.route_policies -import data.shared_key - default allow = false -route := first_allowed_route(input.url) -http_status = [495, "invalid client certificate"]{ - not input.is_valid_client_certificate -} +route := first_allowed_route(input.http.url) +session := input.databroker_data["type.googleapis.com/session.Session"][input.session.id] +user := input.databroker_data["type.googleapis.com/user.User"][session.user_id] +directory_user := input.databroker_data["type.googleapis.com/directory.User"][session.user_id] + # allow public allow { - route_policies[route].AllowPublicUnauthenticatedAccess == true + data.route_policies[route].AllowPublicUnauthenticatedAccess == true } # allow cors preflight allow { - route_policies[route].CORSAllowPreflight == true - input.method == "OPTIONS" - count(object.get(input.headers, "Access-Control-Request-Method", [])) > 0 - count(object.get(input.headers, "Origin", [])) > 0 + data.route_policies[route].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 by email allow { - token.payload.email = route_policies[route].allowed_users[_] - token.valid - count(deny)==0 + user.email == data.route_policies[route].allowed_users[_] } # allow group allow { some group - token.payload.groups[group] == route_policies[route].allowed_groups[_] - token.valid - count(deny)==0 + directory_user.groups[_] = group + data.route_policies[route].allowed_groups[_] = group } # allow by impersonate email allow { - token.payload.impersonate_email = route_policies[route].allowed_users[_] - token.valid - count(deny)==0 + data.route_policies[route].allowed_users[_] = input.session.impersonate_email } # allow by impersonate group allow { some group - token.payload.impersonate_groups[group] == route_policies[route].allowed_groups[_] - token.valid - count(deny)==0 + input.session.impersonate_groups[_] = group + data.route_policies[route].allowed_groups[_] = group } # allow by domain allow { some domain - email_in_domain(token.payload.email, route_policies[route].allowed_domains[domain]) - token.valid - count(deny)==0 + email_in_domain(user.email, data.route_policies[route].allowed_domains[domain]) } # allow by impersonate domain allow { some domain - email_in_domain(token.payload.impersonate_email, route_policies[route].allowed_domains[domain]) - token.valid - count(deny)==0 + email_in_domain(input.session.impersonate_email, data.route_policies[route].allowed_domains[domain]) } + # allow pomerium urls allow { - contains(input.url, "/.pomerium/") - not contains(input.url,"/.pomerium/admin") + contains(input.http.url, "/.pomerium/") + not contains(input.http.url, "/.pomerium/admin") +} + +# allow user is admin +allow { + element_in_list(data.admins, input.user.email) + contains(input.http.url, ".pomerium/admin") +} + +# deny non-admin users from accesing admin routes +deny[reason] { + reason = [403, "user is not admin"] + not element_in_list(data.admins, user.email) + contains(input.http.url,".pomerium/admin") +} + +deny[reason] { + reason = [495, "invalid client certificate"] + is_boolean(input.is_valid_client_certificate) + not input.is_valid_client_certificate } # returns the first matching route first_allowed_route(input_url) = route { - route := [route | some route ; allowed_route(input.url, route_policies[route])][0] + route := [route | some route ; allowed_route(input.http.url, data.route_policies[route])][0] } allowed_route(input_url, policy){ @@ -142,53 +149,6 @@ email_in_domain(email, domain) { x[1] == domain } -default expired = false - -expired { - now_seconds:=time.now_ns()/1e9 - expiry < now_seconds -} - -deny["token is expired (exp)"]{ - expired -} - -deny[sprintf("token has bad audience (aud): %s not in %+v",[input.host,audiences])]{ - not element_in_list(audiences,input.host) -} - -# allow user is admin -allow { - element_in_list(data.admins, token.payload.email) - token.valid - count(deny)==0 - contains(input.url,".pomerium/admin") -} - - -# deny non-admin users from accesing admin routes -deny["user is not admin"]{ - not element_in_list(data.admins, token.payload.email) - contains(input.url,".pomerium/admin") -} - -token = {"payload": payload, "valid": valid} { - [valid, header, payload] := io.jwt.decode_verify( - input.user, { - "secret": shared_key, - "aud": input.host, - } - ) -} - -user:=token.payload.user -email:=token.payload.email -groups:=token.payload.groups -audiences:=token.payload.aud -expiry:=token.payload.exp -signed_jwt:=io.jwt.encode_sign({"alg": "ES256"}, token.payload, data.signing_key) - - element_in_list(list, elem) { list[_] = elem } diff --git a/authorize/evaluator/opa/policy/authz_test.rego b/authorize/evaluator/opa/policy/authz_test.rego index 8230e133f..d3e438ade 100644 --- a/authorize/evaluator/opa/policy/authz_test.rego +++ b/authorize/evaluator/opa/policy/authz_test.rego @@ -1,143 +1,131 @@ package pomerium.authz -jwt_header := { - "typ": "JWT", - "alg": "HS256" -} -signing_key := { - "kty": "oct", - "k": "OkFmqMK9U0dmPhMCW0VYy6D_raJKwEJsMdxqdnukThzko3D_XrsihwYE0pxrUSpm0JTrW2QpIz4rT1vdEvZw67WP4xrqjiwyd7PgpPTD5xvQBM7TIKiSW0X2R0pfq_OItszPQRtb7VirrSbGJiLNS-NJMMrYVKWWtUbVSTXEjL7VcFqML5PiSe7XDmyCZjpgEpfE5Q82zIeXM2sLrz6HW2A9IwGk7mWS0c57R_2JGyFO2tCA4zEIYhWvLE62Os2tZ6YrrwdB8n35jlPpgUE6poEvIU20lPLaocozXYMqAku-KJnloJlAzKg2Xa_0iSiSgSAumx44B3n7DQjg3jPhRg" -} -shared_key := base64url.decode(signing_key.k) - test_email_allowed { - user := io.jwt.encode_sign(jwt_header, { - "aud": ["example.com"], - "email": "joe@example.com" - }, signing_key) - - allow with data.route_policies as [{ - "source": "example.com", - "allowed_users": ["joe@example.com"] - }] with data.signing_key as signing_key with data.shared_key as shared_key with input as { - "url": "http://example.com", - "host": "example.com", - "user": user - } + allow with + data.route_policies as [{ + "source": "example.com", + "allowed_users": ["x@example.com"] + }] with + input.databroker_data as { + "type.googleapis.com/session.Session": { + "session1": { + "user_id": "user1" + } + }, + "type.googleapis.com/user.User": { + "user1": { + "email": "x@example.com" + } + } + } with + input.http as { "url": "http://example.com" } with + input.session as { "id": "session1" } } test_example { - user := io.jwt.encode_sign(jwt_header, { - "aud": ["example.com"], - "email": "joe@example.com" - }, signing_key) - 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 data.signing_key as signing_key with data.shared_key as shared_key with input as { - "url": "http://example.com/b", - "host": "example.com", - "user": user - } + 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 { - user := io.jwt.encode_sign(jwt_header, { - "aud": ["example.com"], - "email": "joe@example.com" - }, signing_key) - - not allow with data.route_policies as [{ - "source": "example.com", - "allowed_users": ["bob@example.com"] - }] with data.signing_key as signing_key with data.shared_key as shared_key with input as { - "url": "http://example.com", - "host": "example.com", - "user": user - } + 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 as { - "url": "http://example.com", - "host": "example.com" - } -} -test_public_denied { - not allow with data.route_policies as [ - { - "source": "example.com", - "prefix": "/by-user", - "allowed_users": ["bob@example.com"] - }, - { + 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" } - ] with input as { - "url": "http://example.com/by-user", - "host": "example.com" - } } test_pomerium_allowed { - allow with data.route_policies as [{ - "source": "example.com", - "allowed_users": ["bob@example.com"] - }] with input as { - "url": "http://example.com/.pomerium/", - "host": "example.com" - } + allow with + data.route_policies as [{ + "source": "example.com", + "allowed_users": ["bob@example.com"] + }] with + input.http as { "url": "http://example.com/.pomerium/" } } test_pomerium_denied { - not allow with data.route_policies as [{ - "source": "example.com", - "allowed_users": ["bob@example.com"] - }] with input as { - "url": "http://example.com/.pomerium/admin", - "host": "example.com" - } + not allow with + data.route_policies as [{ + "source": "example.com", + "allowed_users": ["bob@example.com"] + }] with + input.http as { + "url": "http://example.com/.pomerium/admin", + "host": "example.com" + } } test_cors_preflight_allowed { - allow with data.route_policies as [{ - "source": "example.com", - "allowed_users": ["bob@example.com"], - "CORSAllowPreflight": true - }] with input as { - "url": "http://example.com/", - "host": "example.com", - "method": "OPTIONS", - "headers": { - "Origin": ["someorigin"], - "Access-Control-Request-Method": ["GET"] + 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 as { - "url": "http://example.com/", - "host": "example.com", - "method": "OPTIONS", - "headers": { - "Origin": ["someorigin"], - "Access-Control-Request-Method": ["GET"] + 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 { diff --git a/authorize/evaluator/opa/policy/statik.go b/authorize/evaluator/opa/policy/statik.go index 203899c3b..adb418997 100644 --- a/authorize/evaluator/opa/policy/statik.go +++ b/authorize/evaluator/opa/policy/statik.go @@ -6,11 +6,9 @@ import ( "github.com/rakyll/statik/fs" ) - const Rego = "rego" // static asset namespace func init() { - data := "PK\x03\x04\x14\x00\x08\x00\x08\x00[\x11\xccP\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\n\x00 \x00authz.regoUT\x05\x00\x01/\xe4\xe2^\xbcX_o\xdb6\x10\x7f\x16?\xc5\x95E\x01iU\x94\xb6X\x07\xd4\x9b\x97\x15\xc5\x1e\xf6\xb0\xa5h\xb7'AUi\xe9l1\x91H\x8d\xa4\x1a\xbb\x9e\xbf\xfb@R\xb2\x1d\xc5vS\xa4\xdbKd\xdd\xfd\xee\xf8\xbb?:\x92iYq\xcd\x16\x08\xadlP\xf1\xaeIXg\xaa\xcf\x84\xf0\xa6\x95\xca@\xc9\x0cK\x94\xec\x0c\xe6\xad\xacy\xc1Q\xdfR\xe9\x8a),\xf3k\\\x11R\xe2\x9cu\xb5\x01V\xd7\xf2\x06\xa60g\xb5FB\x9c5L\xa60\xe7J\x9b\xdci\xb1\xcc\x9d8\xe4\xa2\xedL\xd2\xa9:\"\xa42\xa6\xcd\xb5a\xa6\xd30\x85\xf4\xfbW/c\xa0\\|b5/\xa1\xa89\n\x03\x05*\xc3\xe7\xbc`\x06i\xb6&\x81\x90\x06\xbc\x0f\xaes\x87\xcc=2\xdfC\x92\x0d!\x8f{Zm7\xabyA\xfc\xcb\x9a\x04\xb7cK\xddk\x96\xbc\xb6\xea\xb7\x0e\xfa\x97\xb0\x19Aa\x9c\xab\xf2uQ\xa0\xd60\x9d\x82Q\xdd-\xcf\x85T\x1aZ\x85\xf3\x9a/*\xf3\xa5\x15\xde\\\xbe{\xefW\x19,\xb6>\x03\x1fP\x83\xa6\x92\xa5\x95\xd2\xcb\xb7\x7f\xfev\xf9\xc7{J\x82Bv\xc2\x84rv\x85\x85I\x16h\xfa\x04V\xc8JT:\x06\xea\xf9\x9d\xbd\x91\xc2(Y\x9f\xbd\xc3\xbf;\xd4\xe6\xecw\xe7\x8c\xc6\x90fQ\x04?\xc3\xb3{\xb8\xbaT|\xc1\xc5\xbe\xcd\x86\xec\xc2\x9d\xad\x00\x1b\xc6\xeb]\xa0F^\xa3HZ\xb6\xaa%+\x13\xa7\x84)\x1c\x0e\x7f\xe8\x82N\xa3\xd2i\x9e\x0d\xd6\xae\x84\x03\xb7\x12\xc5*\x9aN\x9f\xedgy\xa1d\xd7\xee\xd6\xd4\xb2\xc1^6Z\xdf u\xea\x1e\x99\xcd\xe2i\"=\xfc+\x98\xccV\xc0\x9b\x16\x95\x96\x82\x19<\x9d\x8c=`\xfe\x1f%fD\xe7\xdey\xda\xa7\xf6\x7f\xe4\xac\x94\x0d\xe3b\xc4\xac\x17\x06.79\x17\xb9\x17\x84\x07Z*\xfe\x02)o\xa9S\xff\xcc\xa2\xaf\xe1\xb6\x9f\xc0\x07\xf0\xbcS\xedo\xcdy;\xcc\xfa\xa1\x0d\x9d\xaa\xf5\x8ej!\x85\xb1\xfev\xd35\x06z\x9e\x0c\xe8s\x1a\xf9\xc1y\x00\xb7\x0fce\xc3\x05\x8d|\x8e\x14\x9aN \x0d\xa6B?\xc8\xa1a\xa6\xa8\xb8X\xf8\xd8\xc8\xd1\xe9\x9e\xdb\xe9>\xf4\xfbv \xda\x0d\xc1\xa7\x01\xfe\x01\x97[\xff\xf2#\x1c\xd9 \x8e\xe40\xca\xd2g\x99\xa5xd\xe5\x18\x9c\xc1*Z\xf7s\xd5\ns9\xbb\xb2\x04Z\xa64Z\xc1\x1eS\x12\xdc\xf2\x94k\xd9\xa9b\xcf\xa1\xb5\xdd:\x1d\x83\xed\xf8\xe7\xcb\xfb\x82\x99\xa9\xee U\xb8\xc0\xa3n\xc7\xc1\x9f\xa6l+\xb07\xf4\xbd4\x06\xea\x8dh\x0c\x94Fn\xd3\xa1d\xf3\xcd\xfd>r~\x03/;\\ o\x98xH4*ZRI\xed6\xca\xdb\x1e\x9c\xf8n\x1eNV\xe3\x18_ot2\x0f\x0f\xf7;\xe4\xc10e\xf4\x0d\x1f\xf7Ab[c\xf0\x98x\xcb\x03u>\xd1@GY0S\x9d\x8e\xedA>\xfb\xb8\x06\xe2\xccTv\x99\xbb\xb1\xdd\x8d\xe5T\x87\x1f[\xd8\xd9\x9c\x8c\xe6\xa1^\xfbx\x14\xe6n\xda\x0d\xcd\xe9 \xf1\x81\xb8\\\x91v\xbd\xac\x8d\xb2\x93o\x0dT\x17\x156H'\xe0\x7f\xc4@m\xcb\xd2 \xd8\xc7\x90\xc3 \xb8\x8cm,\xb34\x8f\xb7X\x8fQ\xec\xc6\xaa3;J\xed\xfa\xc9\x9c\x8b\xd2\x0e\xdc\\\x1b\xc5\xc5\"\xd7\xdd\xcc\xb1\xccEH\x82\xe0cx1 \xed\x91:\xd5\xd9E49?\x8f.\xc2\xf4\xc3y\xf64\n\xd3\x0f\x17\x8f\xb3\xef\xa2\x8f1 \x02mT\x0c\xcf#;D\x03_/\x10R5\xac\xe6\x9f\xfd\xe7\xe5\x1a\xa2_\xdb\x85w@\xdd\xc7I\xcf\xa9\xdb)\x8d\xda\x96\xe38\xd8\xa2z\xf0\xa3\x1eL\xc6\xdbj\xbfy\xfa7W\xb0\xa5\x1d\x16\xba\xad\xb9\x19\x94\xf4\x17\xbb\x9d\xf9-r\xe9\xfa\xe0\x05 \x96\xe9sw~\xe9\xf7\xeb\xcd\xeer\x82\xcb\x96+,w\xd7\x93A\xe0.\x137\xb9\xc6B\x8aRO\xa6\x867\x98X\x89\xd0at\xfe\x1c_\x91\xc0AW\xf0\x13\xec\x01\xbdo\xb1J\xa9\xdb\xb2\x81\xeb\xed\x12!.\xdb\xc8\xddRz\xc9\x16\xab[\xc5\x85\x99\x87\xbdM\xc54\xccX \xac+9\x8a\x02!d]\x19M\xe0\x89\x06\x7f\xbf\x81'O?\xd18\xed\x0f\xe8\xb6\x1b\x06\xa8\xce\xa2\xe1\x1a\x8456\xf6\xe2\xc3E^sm\xc2-$\xde\xd9E\xfb\x07\x1e{\xc4\xb4|\xdd>\xbf;=\x8c\xfd\xb8{\x9e\xc3\xe8\x18\x0e\x1c\xc8\xbepZ9t\x1a\xa1\x07\x0f\x19\xe41X+\x10R\x9c9\xb1c\xa8a\xaed\x03\xcc\xdek\xeci\xc3k\xdc\xe7\xad\xfb\xcc\x0f\x81\xd8,x\x7f\xc7rr\x8fX\xeeM\xd7\xd7n\nk\xda\xbbp\xdf\xaf\xfb\x15\x03u\xc9\xa0\x13pO\xffE\xbb\x9f1\xf8\x0bV<`3\xdb\xd1\\&W7&)\xb1\x90%\xe6\x9fP\xf1\xf9\xca~\xc4=\x07m\xf1k\x12\x04\x01\xd5X(\xb4\x83cw\xf3\x8e\x9d\x82uv\xb9\xbd\x16!A\xb0!\x81\xa3j\x1dL\xa6\xb7\xe3\xb52\xff\xc1\x8d5\xfe&\xe3\x0f\xf7c\x9d\x97\x92mo\x8d\xf5\xac+\xfd\x17\xb5\xba\xe3u\xd9\x12\xcd\x17\x02\xcb\xfc\xea\xc6L\xa6}\xc8(\\\xc8V\x13\xae)\xab\x17t\x02\xf4\xd7\xf7/^\xfe@7\xa3\x12\xc5\xfd\xbf\x1c\xf8B\xd8qw\x8d\xab\x88\x102.\xb1\xfd\x13\xbb\xc2\xdb\x81\x01`\xdf\xd3\xdcNM+#\x1b\xf2o\x00\x00\x00\xff\xffPK\x07\x08i@hx%\x05\x00\x00\xf2\x10\x00\x00PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00[\x11\xccPi@hx%\x05\x00\x00\xf2\x10\x00\x00\n\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x00\x00\x00\x00authz.regoUT\x05\x00\x01/\xe4\xe2^PK\x05\x06\x00\x00\x00\x00\x01\x00\x01\x00A\x00\x00\x00f\x05\x00\x00\x00\x00" - fs.RegisterWithNamespace("rego", data) - } - \ No newline at end of file + data := "PK\x03\x04\x14\x00\x08\x00\x08\x00\xf6\x8b\xcfP\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\n\x00 \x00authz.regoUT\x05\x00\x01\x80\xb0\xe7^\xb4WMs\xdb6\x13>\x93\xbfb\x83\\\xc4\xf7\xa5\xa9\xa4\x1f\x87\xaa\xa3\xba\x99\x9czh\x9dI\x9a\x13\x87A r%\"!\x01\x16\x00k;\xae\xff{g\x01R\x92e\x8bV\xea\xf6b\x91\x8bg\x9f\xfd\xe4b\xdd\x89\xf2\xb3\xd8 t\xbaE#\xfb6\x13\xbd\xab\xbf\xc4q\x85k\xd17\x0eD\xd3\xe8KX\xc2Z4\x16\xe386\xbaw\x08\x8b%\xac\xa5\xb1\x8e\xfbc\xac\xb8\x17\xcf\xa4\xeaz\x97\xd5\xceuYo\x9a$\xb6h\xad\xd4\x8a\xe0\xe1\xa8\x12N\xac\x8c\xfe\x8c\x86\xd3c\xce\xdcu\x87\xd9F\xebM\x83\xa2\x936+u;\x1f\xb4\xb2w\xe1\x97\x15yP\x1e\xe5\xb2*\xe2\xde\xa2\xf9JZR\xc9\xde[4\xac\xc8G*\x92q\xe2\xab\xa4\xc1\xd2is\xcd\xff\x01\xf3V\xf9(}\x1c?\x1f2\xd9\xf5\xabF\x96qx\xb9\x89#b\xcd|\xf6x\xa7\x1bYJ\xb4\xb9\x7f-\xb2W\x84y\xe3\xf1\xef\x15\x95\x05\x95\x93\xa5pX\xbd*K\xb4\x16\x96Kp\xa6\xc7\xf8vG_jc\xa13\xb8n\xe4\xa6v'\x99y}\xf1\xf6]05\xaam\x89\xa3\xbd\x82\xb6\xe8j]\xd1\x11\xbbx\xf3\xfb/\x17\xbf\xbdcqT\xea^\xb9\x99^}\xc2\xd2e\x1bt\xfb\x1dP\xa3\xa8\xd0\xd8\x14Xp\xf7\xec\xb5V\xce\xe8\xe6\xec-\xfe\xd1\xa3ug\xbfzF\x96B^$ \xfc\x04/N\xe5\xbb0r#\xd5\xbe\xe2^\x06V\xd7\x80\xad\x90\xcd.v_x/#\xef'2163)\xd8\x9c\x17\xfb\xb4\x1b\xa3\xfbn\xc7iu\x8b\x83,\xba\xdb;\x99\x97\x92:,\xb7\x88\xc7m\xde\xd7\xba\x1b\x93l;4V+\xe1\xf00\xbe\x13\xd8\xc7\x88`\xec\xeb\xed\xb7\xb4\xa3\xe5\x81\xf6\xb8\xd9\x89\x14\x1c'\xfdo\xb2Q\xe9VHu\xe0\xcb \x8c|\x1c\\*\x1e\x04\xb3]\xfd\xd3S\xaa\x1f\xb4l\x1e~\x8bd\"#_\xe5\xc6#\x89\x7f\xb2o\xe3\x04\x87\xde4v\xe7S\xa9\x95#\x9d\x83\xe1\x9c\x02\x9bg\xa3\xca\x9c%q\xa4\xb4\x83\x93\xc0\xa2j\xa5bwl\xfb\xa1)-\xf8\xa3\x9dml\xb0E\xe5( \x8d\xb4n\xe6#\xf4\x18\x9b\x0e\x8d\xb8+N2\xe5\xeb\x11\xeb\x15\xaakPZ\x9dy\xa9w\xc3\xc2\xda\xe8\x16\x04\xcd\x1c\xa96\xc1%\xf0y\xb41\xe1s\x83\xc2jU\x90\x83\xe1\x11\x96\x90\x7f\xf7\xe2\xdb\x14\xd8\x18\x07\xe5\"\x18*Bb )\x86\x87C\x98p\xe8\x87\xefS`R\xfd)\x1aYA\xd9HT\x0eJ4N\xae\xfd%@\x9eI\xcbWZ7(\xc6\xee\x92\x96{<\x0fx\xbe\x87\x1f*\xfc(.$\xd6\xa0\xeb\x8d\xb2\xe0j\x0c7=\xb4\xc2\x955%\xd4\xa72>z\xfds\xba\xf9a\x19`>\xa4qc\x08\xcd\x0c\x7f\x81\xffL\xc2\xcb\x8f0\xb5AL}\x13I\x91\xbf\xf0\x13\xfa\x88\x0f)x\x85\xeb\xe4f\x18O$\xe4z\xf5\x89\\\xe9\x84\xb1H\x82=\x9f\xe3\xe8\x0e\x13\xb7\xba7\xe5\x1e!\xe9nI\x0f\xc1t\xe5\xca\xabS\xc1\xc2\xd5'B\x0dn\xf0(\xeda\xf0\xd3.S-\xf6n\xd6 M\x81\x05%\x96\x02c\x89\xbf\xdeY|\xfb\xaf\xf3>\xf3\xbcQ\x90=\\\x89\xa0\x98\x05HrP\xb4\xac\xd6\xd6\xef%w\x19\xbc\xf8~\x1e&\xabq\xcc\xdf\xa04\x99\x87\xa7\xf3\x8eyp\xc28{)\x0f\xfb \xa3\xd6\x18\x19\xb3\xa0\xf9@\x9d'\x1a\xe8\xa8\x17\xc2\xd5\xd3\xb1=\x89s\x88kt\\\xb8\x9a\xcc\xdc\x8f\xed~,S\x1d~\xcc\xb0\xd7\x99\x8c\xe6\xa9\xacC<\x06\xb9\x9f{cszH\xfa@\\\xbeH\xbb^\xb6\xce\xd0\x0c\xbc\x01f\xcb\x1a[d\x0b\x08\x0f)0jY\xb6\x00\xfa\x19s\xb8\x00\x9f\xb1[\xf2,\xe7\xe9\x16\x1b0F\\\xd21\xedB\xde~\xb6\x96\xaa\xa2\xd1\xcb\xad3Rm\xb8\xedW\xdeK\xaefq\x14}\x9c\x9d/f4As[\x9c'\x8b\xf9<9\x9f\xe5\x1f\xe6\xc5\xff\x93Y\xfe\xe1\xfcy\xf1\xbf\xe4c\x1aG\x91u&\x85\x97 \x0d\xd1(\xd4\x0b\x946\xadh\xe4\x97\xf0y\xf9\x86\x18l\xfb\xf0\x1e8\x1e\xe2ds\xe6\xd7\x1fg\xb6\xe58\x0e&\xd4\x00~6\x80\xe3\xc3]i\xdc\x88\xfc\x9b/\xd8\x15\x0d\x0b\xdb5\xd2\x8d\x87\xecg\x96\x8c\xff1\\\xf9>\xf8&\x8e\xae\xf2\x97\x85\xdf\xf2\xc3\x12F\xd4\x0777\xfdI\xfd}N\xbc\x00\xf4\x1e\x16M\x92\x91\xc6\xdf\x01\x00\x00\xff\xffPK\x07\x08\x07L\x12vA\x04\x00\x00\x13\x0f\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00\x9cn\xcfP\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0f\x00 \x00authz_test.regoUT\x05\x00\x019}\xe7^\xccW]O\xeb8\x10}N~E\xe4\xa7e\xd5&\xe2\xb5\x12Z\x10Z\xad\xf6a\xb7\x88\x8f\xa7\xaa\x8a\xdcdn\xe3K\x12\x07\xdb\x11\x05\xd4\xff~5v>\x9a\xe2\x80\x89@\x97\x17\x12l\xcf\x999\xe7\xcc\x98P\xd1\xe4\x9en!\xa8x\x01\x82\xd5EHk\x95=\xfb\xbe\x02\xa9b((\xcbc\x9a\xe7\xfc\x11\xd2\xe0\xc5\xf7\xf4k\xf0\xc8T\xe6{^J\x15\x0d\x05\xaf\x15\xc4\x15\xcfY\xc2@\x06T\x06\xab\x17\xdf\xf3<\"y-\x12 \x8b\x80\xc0\x8e\x16U\x0ea\xc2\x0b2\xd3{\x0db\\K\x10\x92,\x82\x15\xd9\x9d\x1f\x9eZ\xfb\x9e\xb7_\xb7yXY\xd5*\xc4l\x1b\xc1\xefA\xc4\xf8\x8a\x99L\"\xf5TA\xb8\xe5|\x9b\x03\xad\x98D\x80H\x82\x94\x8c\x97\xe1\x8dy\x92\x859\xeb\x91f\xe3\xb4[\xf1\x08\x16\x11\xb3\x14+\xc5\xd7S\xa2\xd7\xf7\xf8s?\x1b\xcd\x80G\xc3; \xa2\xc76\xd1=\xb0V\x0fa\x87\xe4\x0e\xe0\x91\xe6\x90e\xa6T\xa5\xa9\x05\xa4\x16:\x18W\x16Qt\x88\x10\x1c\x055\xa4\x9a8C\xa5#\x1a\xec\xfd}k\xa7\xc1\xc0\x02K\xae\x02'3\xb1\xceV\xbb\xceQKM3s\xa6\xa2*\xc3\x13\x11mWZ\xafS^PV\x1a\xb7\x8f\xbdn\x84\x9e\x92gs\x9c\xa7\xef\xa9\x92\xf3\x12\xce\xbb\xc6\x1e&[\x7f\\\xf7h\xf3JyL6\x90\xfd\x94\xcc\x82\xde\xf8\x9f\x1c\xce\x87\xc6\xf5^\xe8\xd1J\xa1df\xb2\x9c\x0d\x992]\x1b\xbeyw\xbe\\\x14\xf8D\xfeU\xbd\xc9Y\xf2\x05w\xcb\x05\xc2\\i\xf4\xbb\x12/3(\x15K\xa8\x82\xf4\"I@\xa2 J\xd40]\x01\x7f?`0\xc1Bk\xaf\xdb\x9a\\\xc0\x0f\xb63m\xfe4G\xad\xc7\x9b\xddf\xf1\xd8XYR\xb9\xab\xa6o\xad1\xe5\xb4\x03o\x0cP\xc3\x02\xc5\xef;\xa1\x19\xd0/\xe8\x057\x99\xa6\xf4A\x14\xb6eG\x87-\xd1R\x99\xd0\x14_\xce\xe6\x1dozB4-X\xd9\xe4\xcc\xb8T\xc7\xd5\x0c\xdcK\xb8\x9016j\xce\xb6\x99\xfa=\x1e\x9a\x83\x97\xcb\xeb\x1b\xd3\xc6m5\x0e\xa3\xae#\x0bP\x19\xd7\xd7\xd7\xf2\xea\xf6\xdf\xe5\xff7M\xeaq\xb1Zu\x80\xa6\xa6\xaaf\xc6\x96\x82mY\xa9\xab\x94\xbc\x00n~]\xb7C\xa6Gi~\xc9K%x>\xbf\x86\x87\x1a\xa4\x9a\xff\xd7\xa6_\x91\x7f\xfe\xbem&\xb7\x11\xd9\xa6\xf1\xb7m\xaeo\xacc3\x9fTH\x88k\x91c\x1e|,\xce\x82n\xed\x0f[\x81\x98=\xc2\x8f\x8d\xbf\x1e$9\xd1A\xa1L2( 8;3\x94\x88Y\xc5I\xd1k\xc3Q\xc1-\x8c\xd7[=\x1c\xe9jj\x857\xae\x19\x8b\xba\xf1i\xd7m\xb5\x91Y\xf02b\xe9\xfe\xe4\xe3\xf1\x96\x03Sa\xe4\x14\x9c\xe8\xb3\x80\xde\xc7\x89\xdc\x80\x1c*2H\xdd\x18\x8e\xa2q\xb1}\xdb.{7\x98\x0f\x00\xf7n8\xf8`p\xa4\xa8\xefz\xdd\x96\xba+\x8f@\xcc_\x027\x8a\x96\x1a\xba\xf0\x11v8\x16\xee\xdc\xda/~Gf\x96 '\x12VI\xba\xffj\xa6\x08\xf2*\xd8.\x87\x80-|\xc0k}\x1cq\xc3?\x1d\x15\xb1\x10\xeb@\x9a\xcd\x06\xcb\x9d\\\x07\xb0\xda==\xaf\x0d\xb9_\x01\x00\x00\xff\xffPK\x07\x08.6I;\x1b\x03\x00\x00S\x10\x00\x00PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\xf6\x8b\xcfP\x07L\x12vA\x04\x00\x00\x13\x0f\x00\x00\n\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xb4\x81\x00\x00\x00\x00authz.regoUT\x05\x00\x01\x80\xb0\xe7^PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\x9cn\xcfP.6I;\x1b\x03\x00\x00S\x10\x00\x00\x0f\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xb4\x81\x82\x04\x00\x00authz_test.regoUT\x05\x00\x019}\xe7^PK\x05\x06\x00\x00\x00\x00\x02\x00\x02\x00\x87\x00\x00\x00\xe3\x07\x00\x00\x00\x00" + fs.RegisterWithNamespace("rego", data) +} diff --git a/authorize/grpc.go b/authorize/grpc.go index f993b8647..84e7afb1a 100644 --- a/authorize/grpc.go +++ b/authorize/grpc.go @@ -2,17 +2,17 @@ package authorize import ( "context" - "errors" - "fmt" - "io" "io/ioutil" "net/http" "net/url" "strings" + "github.com/golang/protobuf/ptypes" + "github.com/pomerium/pomerium/authorize/evaluator" - "github.com/pomerium/pomerium/internal/grpc/authorize" - "github.com/pomerium/pomerium/internal/httputil" + "github.com/pomerium/pomerium/internal/grpc/databroker" + "github.com/pomerium/pomerium/internal/grpc/session" + "github.com/pomerium/pomerium/internal/grpc/user" "github.com/pomerium/pomerium/internal/log" "github.com/pomerium/pomerium/internal/sessions" "github.com/pomerium/pomerium/internal/telemetry/requestid" @@ -23,6 +23,16 @@ import ( envoy_service_auth_v2 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v2" ) +var sessionTypeURL, userTypeURL string + +func init() { + any, _ := ptypes.MarshalAny(new(session.Session)) + sessionTypeURL = any.GetTypeUrl() + + any, _ = ptypes.MarshalAny(new(user.User)) + userTypeURL = any.GetTypeUrl() +} + // Check implements the envoy auth server gRPC endpoint. func (a *Authorize) Check(ctx context.Context, in *envoy_service_auth_v2.CheckRequest) (*envoy_service_auth_v2.CheckResponse, error) { ctx, span := trace.StartSpan(ctx, "authorize.grpc.Check") @@ -31,84 +41,113 @@ func (a *Authorize) Check(ctx context.Context, in *envoy_service_auth_v2.CheckRe // maybe rewrite http request for forward auth isForwardAuth := a.handleForwardAuth(in) hreq := getHTTPRequestFromCheckRequest(in) + rawJWT, _ := loadRawSession(hreq, a.currentOptions.Load(), a.currentEncoder.Load()) + sessionState, _ := loadSession(a.currentEncoder.Load(), rawJWT) - isNewSession := false - rawJWT, sessionErr := loadSession(hreq, a.currentOptions.Load(), a.currentEncoder.Load()) - if a.isExpired(rawJWT) { - log.Info().Msg("refreshing session") - if newRawJWT, err := a.refreshSession(ctx, rawJWT); err == nil { - rawJWT = newRawJWT - sessionErr = nil - isNewSession = true - } else { - log.Warn().Err(err).Msg("authorize: error refreshing session") - // set the error to expired so that we can force a new login - sessionErr = sessions.ErrExpired + // only accept sessions whose databroker server versions match + if sessionState != nil { + a.dataBrokerDataLock.RLock() + if a.dataBrokerSessionServerVersion != sessionState.Version { + log.Warn(). + Str("server_version", a.dataBrokerSessionServerVersion). + Str("session_version", sessionState.Version). + Msg("clearing session due to invalid version") + sessionState = nil } + a.dataBrokerDataLock.RUnlock() } - req := getEvaluatorRequestFromCheckRequest(in, rawJWT) - reply, err := a.pe.IsAuthorized(ctx, req) + if sessionState != nil { + a.forceSync(ctx, sessionState.ID) + } + + a.dataBrokerDataLock.RLock() + defer a.dataBrokerDataLock.RUnlock() + + req := a.getEvaluatorRequestFromCheckRequest(in, sessionState) + reply, err := a.pe.Evaluate(ctx, req) if err != nil { + log.Error().Err(err).Msg("error during OPA evaluation") return nil, err } - logAuthorizeCheck(ctx, in, reply, rawJWT) + logAuthorizeCheck(ctx, in, reply) switch { - case reply.GetHttpStatus().GetCode() > 0 && reply.GetHttpStatus().GetCode() != http.StatusOK: - // custom error from the IsAuthorized call - return a.deniedResponse(in, - reply.GetHttpStatus().GetCode(), - reply.GetHttpStatus().GetMessage(), - reply.GetHttpStatus().GetHeaders(), - ), nil - - case reply.Allow: - // ok! - return a.okResponse(reply, rawJWT, isNewSession), nil - - case reply.SessionExpired, - errors.Is(sessionErr, sessions.ErrExpired), - errors.Is(sessionErr, sessions.ErrIssuedInTheFuture), - errors.Is(sessionErr, sessions.ErrMalformed), - errors.Is(sessionErr, sessions.ErrNoSessionFound), - errors.Is(sessionErr, sessions.ErrNotValidYet): - // redirect to login - - // no redirect for forward auth, that's handled by a separate config setting + case reply.Status == http.StatusOK: + return a.okResponse(reply, rawJWT), nil + case reply.Status == http.StatusUnauthorized: if isForwardAuth { return a.deniedResponse(in, http.StatusUnauthorized, "Unauthenticated", nil), nil } - return a.redirectResponse(in), nil - - default: - // all other errors - var msg string - if sessionErr != nil { - msg = sessionErr.Error() - } - return a.deniedResponse(in, http.StatusForbidden, msg, nil), nil } + return a.deniedResponse(in, int32(reply.Status), reply.Message, nil), nil } -func (a *Authorize) getEnvoyRequestHeaders(rawJWT []byte, isNewSession bool) ([]*envoy_api_v2_core.HeaderValueOption, error) { - var hvos []*envoy_api_v2_core.HeaderValueOption - - if isNewSession { - cookieStore, err := getCookieStore(a.currentOptions.Load(), a.currentEncoder.Load()) - if err != nil { - return nil, err - } - - hdrs, err := getJWTSetCookieHeaders(cookieStore, rawJWT) - if err != nil { - return nil, err - } - for k, v := range hdrs { - hvos = append(hvos, mkHeader("x-pomerium-"+k, v)) - } +func (a *Authorize) forceSync(ctx context.Context, sessionID string) { + s := a.forceSyncSession(ctx, sessionID) + if s == nil { + return } + a.forceSyncUser(ctx, s.GetUserId()) +} + +func (a *Authorize) forceSyncSession(ctx context.Context, sessionID string) *session.Session { + a.dataBrokerDataLock.RLock() + s, ok := a.dataBrokerData.Get(sessionTypeURL, sessionID).(*session.Session) + a.dataBrokerDataLock.RUnlock() + if ok { + return s + } + + res, err := a.dataBrokerClient.Get(ctx, &databroker.GetRequest{ + Type: sessionTypeURL, + Id: sessionID, + }) + if err != nil { + log.Warn().Err(err).Msg("failed to get session from databroker") + return nil + } + + a.dataBrokerDataLock.Lock() + if current := a.dataBrokerData.Get(sessionTypeURL, sessionID); current == nil { + a.dataBrokerData.Update(res.GetRecord()) + } + s, _ = a.dataBrokerData.Get(sessionTypeURL, sessionID).(*session.Session) + a.dataBrokerDataLock.Unlock() + + return s +} + +func (a *Authorize) forceSyncUser(ctx context.Context, userID string) *user.User { + a.dataBrokerDataLock.RLock() + s, ok := a.dataBrokerData.Get(userTypeURL, userID).(*user.User) + a.dataBrokerDataLock.RUnlock() + if ok { + return s + } + + res, err := a.dataBrokerClient.Get(ctx, &databroker.GetRequest{ + Type: userTypeURL, + Id: userID, + }) + if err != nil { + log.Warn().Err(err).Msg("failed to get user from databroker") + return nil + } + + a.dataBrokerDataLock.Lock() + if current := a.dataBrokerData.Get(userTypeURL, userID); current == nil { + a.dataBrokerData.Update(res.GetRecord()) + } + s, _ = a.dataBrokerData.Get(userTypeURL, userID).(*user.User) + a.dataBrokerDataLock.Unlock() + + return s +} + +func (a *Authorize) getEnvoyRequestHeaders(rawJWT []byte) ([]*envoy_api_v2_core.HeaderValueOption, error) { + var hvos []*envoy_api_v2_core.HeaderValueOption hdrs, err := getJWTClaimHeaders(a.currentOptions.Load(), a.currentEncoder.Load(), rawJWT) if err != nil { @@ -121,44 +160,6 @@ func (a *Authorize) getEnvoyRequestHeaders(rawJWT []byte, isNewSession bool) ([] return hvos, nil } -func (a *Authorize) refreshSession(ctx context.Context, rawJWT []byte) (newSession []byte, err error) { - options := a.currentOptions.Load() - - // 1 - build a signed url to call refresh on authenticate service - refreshURI := options.GetAuthenticateURL().ResolveReference(&url.URL{Path: "/.pomerium/refresh"}) - signedRefreshURL := urlutil.NewSignedURL(options.SharedKey, refreshURI).String() - - // 2 - http call to authenticate service - req, err := http.NewRequestWithContext(ctx, http.MethodGet, signedRefreshURL, nil) - if err != nil { - return nil, fmt.Errorf("authorize: refresh request: %w", err) - } - req.Header.Set("Authorization", fmt.Sprintf("Pomerium %s", rawJWT)) - req.Header.Set("X-Requested-With", "XmlHttpRequest") - req.Header.Set("Accept", "application/json") - - res, err := httputil.DefaultClient.Do(req) - if err != nil { - return nil, fmt.Errorf("authorize: client err %s: %w", signedRefreshURL, err) - } - defer res.Body.Close() - newJwt, err := ioutil.ReadAll(io.LimitReader(res.Body, 4<<10)) - if err != nil { - return nil, err - } - // auth couldn't refresh the session, delete the session and reload via 302 - if res.StatusCode != http.StatusOK { - return nil, fmt.Errorf("authorize: backend refresh failed: %s", newJwt) - } - return newJwt, nil -} - -func (a *Authorize) isExpired(rawSession []byte) bool { - state := sessions.State{} - err := a.currentEncoder.Load().Unmarshal(rawSession, &state) - return err == nil && state.IsExpired() -} - func (a *Authorize) handleForwardAuth(req *envoy_service_auth_v2.CheckRequest) bool { opts := a.currentOptions.Load() @@ -188,39 +189,50 @@ func (a *Authorize) handleForwardAuth(req *envoy_service_auth_v2.CheckRequest) b return false } -func getEvaluatorRequestFromCheckRequest(in *envoy_service_auth_v2.CheckRequest, rawJWT []byte) *evaluator.Request { +func (a *Authorize) getEvaluatorRequestFromCheckRequest(in *envoy_service_auth_v2.CheckRequest, sessionState *sessions.State) *evaluator.Request { requestURL := getCheckRequestURL(in) req := &evaluator.Request{ - User: string(rawJWT), - Header: getCheckRequestHeaders(in), - Host: in.GetAttributes().GetRequest().GetHttp().GetHost(), - Method: in.GetAttributes().GetRequest().GetHttp().GetMethod(), - RequestURI: requestURL.String(), - URL: requestURL.String(), - ClientCertificate: getPeerCertificate(in), + DataBrokerData: a.dataBrokerData, + HTTP: evaluator.RequestHTTP{ + Method: in.GetAttributes().GetRequest().GetHttp().GetMethod(), + URL: requestURL.String(), + Headers: getCheckRequestHeaders(in), + ClientCertificate: getPeerCertificate(in), + }, + } + if sessionState != nil { + req.Session = evaluator.RequestSession{ + ID: sessionState.ID, + ImpersonateEmail: sessionState.ImpersonateEmail, + ImpersonateGroups: sessionState.ImpersonateGroups, + } } return req } func getHTTPRequestFromCheckRequest(req *envoy_service_auth_v2.CheckRequest) *http.Request { hattrs := req.GetAttributes().GetRequest().GetHttp() - return &http.Request{ + hreq := &http.Request{ Method: hattrs.GetMethod(), URL: getCheckRequestURL(req), - Header: getCheckRequestHeaders(req), + Header: make(http.Header), Body: ioutil.NopCloser(strings.NewReader(hattrs.GetBody())), Host: hattrs.GetHost(), RequestURI: hattrs.GetPath(), } + for k, v := range getCheckRequestHeaders(req) { + hreq.Header.Set(k, v) + } + return hreq } -func getCheckRequestHeaders(req *envoy_service_auth_v2.CheckRequest) map[string][]string { - h := make(map[string][]string) +func getCheckRequestHeaders(req *envoy_service_auth_v2.CheckRequest) map[string]string { + hdrs := make(map[string]string) ch := req.GetAttributes().GetRequest().GetHttp().GetHeaders() for k, v := range ch { - h[http.CanonicalHeaderKey(k)] = []string{v} + hdrs[http.CanonicalHeaderKey(k)] = v } - return h + return hdrs } func getCheckRequestURL(req *envoy_service_auth_v2.CheckRequest) *url.URL { @@ -256,31 +268,24 @@ func getPeerCertificate(in *envoy_service_auth_v2.CheckRequest) string { func logAuthorizeCheck( ctx context.Context, in *envoy_service_auth_v2.CheckRequest, - reply *authorize.IsAuthorizedReply, - rawJWT []byte, + reply *evaluator.Result, ) { hdrs := getCheckRequestHeaders(in) hattrs := in.GetAttributes().GetRequest().GetHttp() evt := log.Info().Str("service", "authorize") // request evt = evt.Str("request-id", requestid.FromContext(ctx)) - evt = evt.Strs("check-request-id", hdrs["X-Request-Id"]) + evt = evt.Str("check-request-id", hdrs["X-Request-Id"]) evt = evt.Str("method", hattrs.GetMethod()) evt = evt.Interface("headers", hdrs) evt = evt.Str("path", hattrs.GetPath()) evt = evt.Str("host", hattrs.GetHost()) evt = evt.Str("query", hattrs.GetQuery()) // reply - evt = evt.Bool("allow", reply.GetAllow()) - evt = evt.Bool("session-expired", reply.GetSessionExpired()) - evt = evt.Strs("deny-reasons", reply.GetDenyReasons()) - evt = evt.Str("email", reply.GetEmail()) - evt = evt.Strs("groups", reply.GetGroups()) - if rawJWT != nil { - evt = evt.Str("session", string(rawJWT)) - } - if reply.GetHttpStatus() != nil { - evt = evt.Interface("http_status", reply.GetHttpStatus()) + if reply != nil { + evt = evt.Bool("allow", reply.Status == http.StatusOK) + evt = evt.Int("status", reply.Status) + evt = evt.Str("message", reply.Message) } evt.Msg("authorize check") } diff --git a/authorize/grpc_test.go b/authorize/grpc_test.go index 467c5c5cf..bcfc32246 100644 --- a/authorize/grpc_test.go +++ b/authorize/grpc_test.go @@ -1,24 +1,15 @@ package authorize import ( - "context" - "encoding/base64" - "encoding/json" - "net/http" - "net/http/httptest" "net/url" - "strings" "testing" - "time" envoy_service_auth_v2 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v2" - "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + "github.com/pomerium/pomerium/authorize/evaluator" "github.com/pomerium/pomerium/config" - "github.com/pomerium/pomerium/internal/cryptutil" "github.com/pomerium/pomerium/internal/encoding/jws" - "github.com/stretchr/testify/assert" - "gopkg.in/square/go-jose.v2/jwt" ) const certPEM = ` @@ -46,7 +37,11 @@ yE+vPxsiUkvQHdO2fojCkY8jg70jxM+gu59tPDNbw3Uh/2Ij310FgTHsnGQMyA== -----END CERTIFICATE-----` func Test_getEvaluatorRequest(t *testing.T) { - actual := getEvaluatorRequestFromCheckRequest(&envoy_service_auth_v2.CheckRequest{ + a := new(Authorize) + encoder, _ := jws.NewHS256Signer([]byte{0, 0, 0, 0}, "") + a.currentEncoder.Store(encoder) + + actual := a.getEvaluatorRequestFromCheckRequest(&envoy_service_auth_v2.CheckRequest{ Attributes: &envoy_service_auth_v2.AttributeContext{ Source: &envoy_service_auth_v2.AttributeContext_Peer{ Certificate: url.QueryEscape(certPEM), @@ -66,18 +61,18 @@ func Test_getEvaluatorRequest(t *testing.T) { }, }, }, - }, []byte("HELLO WORLD")) + }, nil) expect := &evaluator.Request{ - User: "HELLO WORLD", - Method: "GET", - URL: "https://example.com/some/path?qs=1", - Header: map[string][]string{ - "Accept": {"text/html"}, - "X-Forwarded-Proto": {"https"}, + Session: evaluator.RequestSession{}, + HTTP: evaluator.RequestHTTP{ + Method: "GET", + URL: "https://example.com/some/path?qs=1", + Headers: map[string]string{ + "Accept": "text/html", + "X-Forwarded-Proto": "https", + }, + ClientCertificate: certPEM, }, - Host: "example.com", - RequestURI: "https://example.com/some/path?qs=1", - ClientCertificate: certPEM, } assert.Equal(t, expect, actual) } @@ -123,28 +118,6 @@ func Test_handleForwardAuth(t *testing.T) { }) } -func Test_refreshSession(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _ = json.NewEncoder(w).Encode(struct { - Authorization string - }{ - Authorization: r.Header.Get("Authorization"), - }) - })) - defer srv.Close() - - sharedKey := make([]byte, 32) - a := new(Authorize) - a.currentOptions.Store(config.Options{ - AuthenticateURL: mustParseURL(srv.URL), - SharedKey: base64.StdEncoding.EncodeToString(sharedKey), - }) - - newSession, err := a.refreshSession(context.Background(), []byte("ABCD")) - assert.NoError(t, err) - assert.Equal(t, `{"Authorization":"Pomerium ABCD"}`, strings.TrimSpace(string(newSession))) -} - func mustParseURL(str string) *url.URL { u, err := url.Parse(str) if err != nil { @@ -152,149 +125,3 @@ func mustParseURL(str string) *url.URL { } return u } - -func TestAuthorize_Check(t *testing.T) { - // golden policy - p := config.Policy{ - From: "http://test.example.com", - To: "http://localhost", - AllowedUsers: []string{"bob@example.com"}, - } - err := p.Validate() - if err != nil { - t.Fatal(err) - } - ps := []config.Policy{p} - - type user struct { - // Standard claims (as specified in RFC 7519). - jwt.Claims - // Pomerium claims (not standard claims) - Email string `json:"email"` - Groups []string `json:"groups,omitempty"` - User string `json:"user,omitempty"` - ImpersonateEmail string `json:"impersonate_email,omitempty"` - ImpersonateGroups []string `json:"impersonate_groups,omitempty"` - } - - tests := []struct { - name string - ctx context.Context - sk string - inUser string - inExpiry time.Time - inIssuer string - inAudience string - in *envoy_service_auth_v2.CheckRequest - want string - wantErr bool - }{ - {"good", - context.TODO(), - cryptutil.NewBase64Key(), - "bob@example.com", - time.Now().Add(1 * time.Hour), - "authN.example.com", - "test.example.com", - nil, - "OK", - false}, - {"bad user, alice", - context.TODO(), - cryptutil.NewBase64Key(), - "alice@example.com", - time.Now().Add(1 * time.Hour), - "authN.example.com", - "test.example.com", - nil, - "Access Denied", - false}, - {"expired", - context.TODO(), - cryptutil.NewBase64Key(), - "bob@example.com", - time.Now().Add(-1 * time.Hour), - "authN.example.com", - "test.example.com", - nil, - "Access Denied", - false}, - {"bad audience", - context.TODO(), - cryptutil.NewBase64Key(), - "bob@example.com", - time.Now().Add(1 * time.Hour), - "authN.example.com", - "bad.example.com", - nil, - "Access Denied", - false}, - {"bad issuer", - context.TODO(), - cryptutil.NewBase64Key(), - "bob@example.com", - time.Now().Add(1 * time.Hour), - "bad.example.com", - "test.example.com", - nil, - "OK", - false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var sa user - sa.Expiry = jwt.NewNumericDate(tt.inExpiry) - sa.IssuedAt = jwt.NewNumericDate(time.Now()) - sa.NotBefore = jwt.NewNumericDate(time.Now()) - sa.Email = tt.inUser - sa.Subject = sa.Email - sa.Issuer = tt.inIssuer - sa.Audience = jwt.Audience{tt.inAudience} - - sharedKey := tt.sk - - encoder, err := jws.NewHS256Signer([]byte(sharedKey), tt.inIssuer) - if err != nil { - t.Fatal(err) - } - raw, err := encoder.Marshal(sa) - if err != nil { - t.Fatal(err) - } - opts := config.Options{ - Policies: ps, - CookieName: "_pomerium", - AuthenticateURL: mustParseURL("https://authN.example.com"), - SharedKey: sharedKey} - a, err := New(opts) - if err != nil { - t.Fatal(err) - } - in := &envoy_service_auth_v2.CheckRequest{ - Attributes: &envoy_service_auth_v2.AttributeContext{ - Request: &envoy_service_auth_v2.AttributeContext_Request{ - Http: &envoy_service_auth_v2.AttributeContext_HttpRequest{ - Id: "id-1234", - Method: "GET", - Headers: map[string]string{ - "accept": "text/json", - "cookie": "_pomerium=" + string(raw), - }, - Host: "test.example.com", - Scheme: "http", - Body: "BODY", - }, - }, - }, - } - got, err := a.Check(tt.ctx, in) - if (err != nil) != tt.wantErr { - t.Errorf("Authorize.Check() error = %v, wantErr %v", err, tt.wantErr) - return - } - if diff := cmp.Diff(got.Status.GetMessage(), tt.want); diff != "" { - t.Errorf("Authorize.Check() = %v", diff) - } - }) - } -} diff --git a/authorize/run.go b/authorize/run.go new file mode 100644 index 000000000..993b23d03 --- /dev/null +++ b/authorize/run.go @@ -0,0 +1,194 @@ +package authorize + +import ( + "context" + "io" + "time" + + backoff "github.com/cenkalti/backoff/v4" + "golang.org/x/sync/errgroup" + "google.golang.org/protobuf/types/known/emptypb" + + "github.com/pomerium/pomerium/internal/grpc/databroker" + "github.com/pomerium/pomerium/internal/log" +) + +// Run runs the authorize server. +func (a *Authorize) Run(ctx context.Context) error { + eg, ctx := errgroup.WithContext(ctx) + + updateTypes := make(chan []string) + eg.Go(func() error { + return a.runTypesSyncer(ctx, updateTypes) + }) + + updateRecord := make(chan *databroker.Record) + eg.Go(func() error { + return a.runDataSyncer(ctx, updateTypes, updateRecord) + }) + + eg.Go(func() error { + return a.runDataUpdater(ctx, updateRecord) + }) + + return eg.Wait() +} + +func (a *Authorize) runTypesSyncer(ctx context.Context, updateTypes chan<- []string) error { + log.Info().Msg("starting type sync") + return tryForever(ctx, func(backoff interface{ Reset() }) error { + stream, err := a.dataBrokerClient.SyncTypes(ctx, new(emptypb.Empty)) + if err != nil { + return err + } + + for { + res, err := stream.Recv() + if err == io.EOF { + return nil + } else if err != nil { + return err + } + + backoff.Reset() + + select { + case <-stream.Context().Done(): + return stream.Context().Err() + case updateTypes <- res.GetTypes(): + } + } + }) +} + +func (a *Authorize) runDataSyncer(ctx context.Context, updateTypes <-chan []string, updateRecord chan<- *databroker.Record) error { + eg, ctx := errgroup.WithContext(ctx) + eg.Go(func() error { + seen := map[string]struct{}{} + for { + select { + case <-ctx.Done(): + return ctx.Err() + case types := <-updateTypes: + for _, dataType := range types { + dataType := dataType + if _, ok := seen[dataType]; !ok { + eg.Go(func() error { + return a.runDataTypeSyncer(ctx, dataType, updateRecord) + }) + seen[dataType] = struct{}{} + } + } + } + } + }) + return eg.Wait() +} + +func (a *Authorize) runDataTypeSyncer(ctx context.Context, typeURL string, updateRecord chan<- *databroker.Record) error { + var serverVersion, recordVersion string + + log.Info().Str("type_url", typeURL).Msg("starting data initial load") + backoff := backoff.NewExponentialBackOff() + for { + res, err := a.dataBrokerClient.GetAll(ctx, &databroker.GetAllRequest{ + Type: typeURL, + }) + if err != nil { + log.Warn().Err(err).Str("type_url", typeURL).Msg("error getting data") + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(backoff.NextBackOff()): + } + continue + } + + serverVersion = res.GetServerVersion() + if typeURL == sessionTypeURL { + a.dataBrokerDataLock.Lock() + a.dataBrokerSessionServerVersion = serverVersion + a.dataBrokerDataLock.Unlock() + } + recordVersion = res.GetRecordVersion() + + for _, record := range res.GetRecords() { + select { + case <-ctx.Done(): + return ctx.Err() + case updateRecord <- record: + } + } + + break + } + + log.Info().Str("type_url", typeURL).Msg("starting data syncer") + return tryForever(ctx, func(backoff interface{ Reset() }) error { + stream, err := a.dataBrokerClient.Sync(ctx, &databroker.SyncRequest{ + ServerVersion: serverVersion, + RecordVersion: recordVersion, + Type: typeURL, + }) + if err != nil { + return err + } + + for { + res, err := stream.Recv() + if err == io.EOF { + return nil + } else if err != nil { + return err + } + + backoff.Reset() + serverVersion = res.GetServerVersion() + for _, record := range res.GetRecords() { + if record.GetVersion() > recordVersion { + recordVersion = record.GetVersion() + } + } + + for _, record := range res.GetRecords() { + select { + case <-stream.Context().Done(): + return stream.Context().Err() + case updateRecord <- record: + } + } + } + }) +} + +func (a *Authorize) runDataUpdater(ctx context.Context, updateRecord <-chan *databroker.Record) error { + log.Info().Msg("starting data updater") + for { + var record *databroker.Record + + select { + case <-ctx.Done(): + return ctx.Err() + case record = <-updateRecord: + } + + a.dataBrokerDataLock.Lock() + a.dataBrokerData.Update(record) + a.dataBrokerDataLock.Unlock() + } +} + +func tryForever(ctx context.Context, callback func(onSuccess interface{ Reset() }) error) error { + backoff := backoff.NewExponentialBackOff() + for { + err := callback(backoff) + if err != nil { + log.Warn().Err(err).Msg("sync error") + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(backoff.NextBackOff()): + } + } +} diff --git a/authorize/session.go b/authorize/session.go index 8d41abd6f..a6f04a6aa 100644 --- a/authorize/session.go +++ b/authorize/session.go @@ -18,7 +18,7 @@ import ( "github.com/pomerium/pomerium/internal/urlutil" ) -func loadSession(req *http.Request, options config.Options, encoder encoding.MarshalUnmarshaler) ([]byte, error) { +func loadRawSession(req *http.Request, options config.Options, encoder encoding.MarshalUnmarshaler) ([]byte, error) { var loaders []sessions.SessionLoader cookieStore, err := getCookieStore(options, encoder) if err != nil { @@ -42,6 +42,15 @@ func loadSession(req *http.Request, options config.Options, encoder encoding.Mar return nil, sessions.ErrNoSessionFound } +func loadSession(encoder encoding.MarshalUnmarshaler, rawJWT []byte) (*sessions.State, error) { + var s sessions.State + err := encoder.Unmarshal(rawJWT, &s) + if err != nil { + return nil, err + } + return &s, nil +} + func getCookieStore(options config.Options, encoder encoding.MarshalUnmarshaler) (sessions.SessionStore, error) { cookieOptions := &cookie.Options{ Name: options.CookieName, diff --git a/authorize/session_test.go b/authorize/session_test.go index 85269bf9f..dff7eb61f 100644 --- a/authorize/session_test.go +++ b/authorize/session_test.go @@ -19,9 +19,7 @@ func TestLoadSession(t *testing.T) { if !assert.NoError(t, err) { return } - state := &sessions.State{ - Email: "bob@example.com", - } + state := &sessions.State{ID: "xyz", Version: "v1"} rawjwt, err := encoder.Marshal(state) if !assert.NoError(t, err) { return @@ -35,7 +33,7 @@ func TestLoadSession(t *testing.T) { }, }, }) - raw, err := loadSession(req, opts, encoder) + raw, err := loadRawSession(req, opts, encoder) if err != nil { return nil, err } @@ -70,9 +68,7 @@ func TestLoadSession(t *testing.T) { } sess, err := load(t, hattrs) assert.NoError(t, err) - if assert.NotNil(t, sess) { - assert.Equal(t, "bob@example.com", sess.Email) - } + assert.NotNil(t, sess) }) t.Run("header", func(t *testing.T) { hattrs := &envoy_service_auth_v2.AttributeContext_HttpRequest{ @@ -87,9 +83,7 @@ func TestLoadSession(t *testing.T) { } sess, err := load(t, hattrs) assert.NoError(t, err) - if assert.NotNil(t, sess) { - assert.Equal(t, "bob@example.com", sess.Email) - } + assert.NotNil(t, sess) }) t.Run("query param", func(t *testing.T) { hattrs := &envoy_service_auth_v2.AttributeContext_HttpRequest{ @@ -103,36 +97,6 @@ func TestLoadSession(t *testing.T) { } sess, err := load(t, hattrs) assert.NoError(t, err) - if assert.NotNil(t, sess) { - assert.Equal(t, "bob@example.com", sess.Email) - } + assert.NotNil(t, sess) }) } - -func TestGetJWTClaimHeaders(t *testing.T) { - options := config.NewDefaultOptions() - options.JWTClaimsHeaders = []string{"email", "groups", "user"} - encoder, err := jws.NewHS256Signer(nil, "example.com") - if !assert.NoError(t, err) { - return - } - state := &sessions.State{ - Email: "bob@example.com", - Groups: []string{"user", "wheel", "sudo"}, - User: "bob", - } - rawjwt, err := encoder.Marshal(state) - if !assert.NoError(t, err) { - return - } - - hdrs, err := getJWTClaimHeaders(*options, encoder, rawjwt) - if !assert.NoError(t, err) { - return - } - assert.Equal(t, map[string]string{ - "x-pomerium-claim-email": "bob@example.com", - "x-pomerium-claim-groups": "user,wheel,sudo", - "x-pomerium-claim-user": "bob", - }, hdrs) -} diff --git a/cache/cache.go b/cache/cache.go index 66b8a22dd..598742383 100644 --- a/cache/cache.go +++ b/cache/cache.go @@ -5,24 +5,34 @@ package cache import ( "context" - "errors" "fmt" - stdlog "log" + "net" + + "google.golang.org/grpc" + "gopkg.in/tomb.v2" "github.com/pomerium/pomerium/config" "github.com/pomerium/pomerium/internal/cryptutil" - "github.com/pomerium/pomerium/internal/kv" - "github.com/pomerium/pomerium/internal/kv/autocache" - "github.com/pomerium/pomerium/internal/kv/bolt" - "github.com/pomerium/pomerium/internal/kv/redis" - "github.com/pomerium/pomerium/internal/log" + "github.com/pomerium/pomerium/internal/directory" + "github.com/pomerium/pomerium/internal/grpc/databroker" + "github.com/pomerium/pomerium/internal/grpc/session" + "github.com/pomerium/pomerium/internal/grpc/user" + "github.com/pomerium/pomerium/internal/identity" + "github.com/pomerium/pomerium/internal/identity/manager" "github.com/pomerium/pomerium/internal/urlutil" ) // Cache represents the cache service. The cache service is a simple interface // for storing keyed blobs (bytes) of unstructured data. type Cache struct { - cache kv.Store + dataBrokerServer *DataBrokerServer + sessionServer *SessionServer + userServer *UserServer + manager *manager.Manager + + localListener net.Listener + localGRPCServer *grpc.Server + localGRPCConnection *grpc.ClientConn } // New creates a new cache service. @@ -31,58 +41,80 @@ func New(opts config.Options) (*Cache, error) { return nil, fmt.Errorf("cache: bad option: %w", err) } - cache, err := newCacheStore(opts.CacheStore, &opts) + authenticator, err := identity.NewAuthenticator(opts.GetOauthOptions()) + if err != nil { + return nil, fmt.Errorf("cache: failed to create authenticator: %w", err) + } + + directoryProvider := directory.GetProvider(&opts) + + localListener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return nil, err } + localGRPCServer := grpc.NewServer() + localGRPCConnection, err := grpc.DialContext(context.Background(), localListener.Addr().String(), + grpc.WithInsecure()) + if err != nil { + return nil, err + } + + dataBrokerServer := NewDataBrokerServer(localGRPCServer) + dataBrokerClient := databroker.NewDataBrokerServiceClient(localGRPCConnection) + sessionServer := NewSessionServer(localGRPCServer, dataBrokerClient) + sessionClient := session.NewSessionServiceClient(localGRPCConnection) + userServer := NewUserServer(localGRPCServer, dataBrokerClient) + userClient := user.NewUserServiceClient(localGRPCConnection) + + manager := manager.New(authenticator, directoryProvider, sessionClient, userClient, dataBrokerClient) + return &Cache{ - cache: cache, + dataBrokerServer: dataBrokerServer, + sessionServer: sessionServer, + userServer: userServer, + manager: manager, + + localListener: localListener, + localGRPCServer: localGRPCServer, + localGRPCConnection: localGRPCConnection, }, nil } +// Register registers all the gRPC services with the given server. +func (c *Cache) Register(grpcServer *grpc.Server) { + databroker.RegisterDataBrokerServiceServer(grpcServer, c.dataBrokerServer) + session.RegisterSessionServiceServer(grpcServer, c.sessionServer) + user.RegisterUserServiceServer(grpcServer, c.userServer) +} + +// Run runs the cache components. +func (c *Cache) Run(ctx context.Context) error { + t, ctx := tomb.WithContext(ctx) + t.Go(func() error { + return c.runMemberList(ctx) + }) + t.Go(func() error { + return c.localGRPCServer.Serve(c.localListener) + }) + t.Go(func() error { + <-ctx.Done() + c.localGRPCServer.Stop() + return nil + }) + t.Go(func() error { + return c.manager.Run(ctx) + }) + return t.Wait() +} + // validate checks that proper configuration settings are set to create // a cache instance func validate(o config.Options) error { if _, err := cryptutil.NewAEADCipherFromBase64(o.SharedKey); err != nil { return fmt.Errorf("invalid 'SHARED_SECRET': %w", err) } - if err := urlutil.ValidateURL(o.CacheURL); err != nil { - return fmt.Errorf("invalid 'CACHE_SERVICE_URL': %w", err) + if err := urlutil.ValidateURL(o.DataBrokerURL); err != nil { + return fmt.Errorf("invalid 'DATA_BROKER_SERVICE_URL': %w", err) } return nil } - -// newCacheStore creates a new cache store by name and given a set of -// configuration options. -func newCacheStore(name string, o *config.Options) (s kv.Store, err error) { - switch name { - case bolt.Name: - s, err = bolt.New(&bolt.Options{Path: o.CacheStorePath}) - case redis.Name: - s, err = redis.New(&redis.Options{ - Addr: o.CacheStoreAddr, - Password: o.CacheStorePassword, - }) - case autocache.Name: - acLog := log.Logger.With().Str("service", autocache.Name).Logger() - s, err = autocache.New(&autocache.Options{ - SharedKey: o.SharedKey, - Log: stdlog.New(acLog, "", 0), - ClusterDomain: o.GetCacheURL().Hostname(), - }) - default: - return nil, fmt.Errorf("cache: unknown store: %s", name) - } - if err != nil { - return nil, err - } - return s, nil -} - -// Close shuts down the underlying cache store, services, or both -- if any. -func (c *Cache) Close() error { - if c.cache == nil { - return errors.New("cache: cannot close nil cache") - } - return c.cache.Close(context.TODO()) -} diff --git a/cache/cache_test.go b/cache/cache_test.go index a2e5da537..e03a6fec6 100644 --- a/cache/cache_test.go +++ b/cache/cache_test.go @@ -23,24 +23,19 @@ func TestNew(t *testing.T) { opts config.Options wantErr bool }{ - {"good - autocache", config.Options{CacheStore: "autocache", SharedKey: cryptutil.NewBase64Key(), CacheURL: &url.URL{Scheme: "http", Host: "example"}}, false}, - {"redis failed", config.Options{CacheStore: "redis", SharedKey: cryptutil.NewBase64Key(), CacheURL: &url.URL{Scheme: "http", Host: "example"}}, true}, - {"bad cache store name", config.Options{CacheStore: "pringles-can", SharedKey: cryptutil.NewBase64Key(), CacheURL: &url.URL{Scheme: "http", Host: "example"}}, true}, - {"bad shared secret", config.Options{CacheStorePath: dir + "/bolt.db", CacheStore: "bolt", SharedKey: string([]byte(cryptutil.NewBase64Key())[:31]), CacheURL: &url.URL{Scheme: "http", Host: "example"}}, true}, - {"bad cache url", config.Options{SharedKey: cryptutil.NewBase64Key(), CacheURL: &url.URL{}}, true}, - {"no store set", config.Options{SharedKey: cryptutil.NewBase64Key(), CacheURL: &url.URL{Scheme: "http", Host: "example"}}, true}, - {"good - bolt", config.Options{CacheStorePath: dir + "/bolt.db", CacheStore: "bolt", SharedKey: cryptutil.NewBase64Key(), CacheURL: &url.URL{Scheme: "http", Host: "example"}}, false}, + {"good - autocache", config.Options{CacheStore: "autocache", SharedKey: cryptutil.NewBase64Key(), DataBrokerURL: &url.URL{Scheme: "http", Host: "example"}}, false}, + {"bad shared secret", config.Options{CacheStorePath: dir + "/bolt.db", CacheStore: "bolt", SharedKey: string([]byte(cryptutil.NewBase64Key())[:31]), DataBrokerURL: &url.URL{Scheme: "http", Host: "example"}}, true}, + {"bad cache url", config.Options{SharedKey: cryptutil.NewBase64Key(), DataBrokerURL: &url.URL{}}, true}, + {"good - bolt", config.Options{CacheStorePath: dir + "/bolt.db", CacheStore: "bolt", SharedKey: cryptutil.NewBase64Key(), DataBrokerURL: &url.URL{Scheme: "http", Host: "example"}}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - s, err := New(tt.opts) + tt.opts.Provider = "google" + _, err := New(tt.opts) if (err != nil) != tt.wantErr { t.Errorf("New() error = %v, wantErr %v", err, tt.wantErr) return } - if err == nil { - s.Close() - } }) } } diff --git a/cache/databroker.go b/cache/databroker.go new file mode 100644 index 000000000..e18198aa9 --- /dev/null +++ b/cache/databroker.go @@ -0,0 +1,23 @@ +package cache + +import ( + "google.golang.org/grpc" + + "github.com/pomerium/pomerium/internal/databroker/memory" + "github.com/pomerium/pomerium/internal/grpc/databroker" +) + +// A DataBrokerServer implements the data broker service interface. +type DataBrokerServer struct { + databroker.DataBrokerServiceServer +} + +// NewDataBrokerServer creates a new databroker service server. +func NewDataBrokerServer(grpcServer *grpc.Server) *DataBrokerServer { + srv := &DataBrokerServer{ + // just wrap the in-memory data broker server + DataBrokerServiceServer: memory.New(), + } + databroker.RegisterDataBrokerServiceServer(grpcServer, srv) + return srv +} diff --git a/cache/grpc.go b/cache/grpc.go deleted file mode 100644 index e504e6c77..000000000 --- a/cache/grpc.go +++ /dev/null @@ -1,35 +0,0 @@ -//go:generate ../scripts/protoc -I ../internal/grpc/cache/ --go_out=plugins=grpc:../internal/grpc/cache/ ../internal/grpc/cache/cache.proto - -package cache - -import ( - "context" - - "github.com/pomerium/pomerium/internal/grpc/cache" - "github.com/pomerium/pomerium/internal/telemetry/trace" - - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -// Get retrieves a key the cache store and returns the value, if found. -func (c *Cache) Get(ctx context.Context, in *cache.GetRequest) (*cache.GetReply, error) { - ctx, span := trace.StartSpan(ctx, "cache.grpc.Get") - defer span.End() - exists, value, err := c.cache.Get(ctx, in.GetKey()) - if err != nil { - return nil, status.Errorf(codes.Unknown, "cache.grpc.Get error: %v", err) - } - return &cache.GetReply{Exists: exists, Value: value}, nil -} - -// Set persists a key value pair in the cache store. -func (c *Cache) Set(ctx context.Context, in *cache.SetRequest) (*cache.SetReply, error) { - ctx, span := trace.StartSpan(ctx, "cache.grpc.Set") - defer span.End() - err := c.cache.Set(ctx, in.GetKey(), in.GetValue()) - if err != nil { - return nil, status.Errorf(codes.Unknown, "cache.grpc.Set error: %v", err) - } - return &cache.SetReply{}, nil -} diff --git a/cache/grpc_test.go b/cache/grpc_test.go deleted file mode 100644 index 766187022..000000000 --- a/cache/grpc_test.go +++ /dev/null @@ -1,107 +0,0 @@ -package cache - -import ( - "context" - "io/ioutil" - "log" - "net/url" - "os" - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" - - "github.com/pomerium/pomerium/config" - "github.com/pomerium/pomerium/internal/cryptutil" - "github.com/pomerium/pomerium/internal/grpc/cache" -) - -func TestCache_Get_and_Set(t *testing.T) { - hugeKey := cryptutil.NewRandomStringN(10 << 20) - dir, err := ioutil.TempDir("", "example") - if err != nil { - log.Fatal(err) - } - c, err := New(config.Options{ - CacheStorePath: dir + "/bolt.db", CacheStore: "bolt", - SharedKey: cryptutil.NewBase64Key(), - CacheURL: &url.URL{Scheme: "http", Host: "example"}}) - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(dir) - defer c.Close() - tests := []struct { - name string - ctx context.Context - SetRequest *cache.SetRequest - SetReply *cache.SetReply - - GetRequest *cache.GetRequest - GetReply *cache.GetReply - wantSetError bool - wantGetError bool - }{ - {"good", - context.TODO(), - &cache.SetRequest{Key: "key", Value: []byte("hello")}, - &cache.SetReply{}, - &cache.GetRequest{Key: "key"}, - &cache.GetReply{ - Exists: true, - Value: []byte("hello"), - }, - false, - false, - }, - {"miss", - context.TODO(), - &cache.SetRequest{Key: "key", Value: []byte("hello")}, - &cache.SetReply{}, - &cache.GetRequest{Key: "no-such-key"}, - &cache.GetReply{ - Exists: false, - Value: nil, - }, - false, - false, - }, - {"key too large", - context.TODO(), - &cache.SetRequest{Key: hugeKey, Value: []byte("hello")}, - nil, - &cache.GetRequest{Key: hugeKey}, - &cache.GetReply{ - Exists: false, - Value: nil, - }, - true, - false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - - setGot, err := c.Set(tt.ctx, tt.SetRequest) - if (err != nil) != tt.wantSetError { - t.Errorf("Cache.Set() error = %v, wantSetError %v", err, tt.wantSetError) - return - } - cmpOpts := []cmp.Option{ - cmpopts.IgnoreUnexported(cache.SetReply{}, cache.GetReply{}), - } - - if diff := cmp.Diff(setGot, tt.SetReply, cmpOpts...); diff != "" { - t.Errorf("Cache.Set() = %v", diff) - } - getGot, err := c.Get(tt.ctx, tt.GetRequest) - if (err != nil) != tt.wantGetError { - t.Errorf("Cache.Get() error = %v, wantGetError %v", err, tt.wantGetError) - return - } - if diff := cmp.Diff(getGot, tt.GetReply, cmpOpts...); diff != "" { - t.Errorf("Cache.Get() = %v", diff) - } - }) - } -} diff --git a/cache/memberlist.go b/cache/memberlist.go new file mode 100644 index 000000000..ea7dbc00e --- /dev/null +++ b/cache/memberlist.go @@ -0,0 +1,73 @@ +package cache + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + stdlog "log" + + "github.com/hashicorp/memberlist" + "github.com/rs/zerolog" + + "github.com/pomerium/pomerium/internal/log" +) + +type memberlistHandler struct { + cfg *memberlist.Config + memberlist *memberlist.Memberlist + log zerolog.Logger +} + +func (c *Cache) runMemberList(ctx context.Context) error { + mh := new(memberlistHandler) + mh.log = log.With().Str("service", "memberlist").Logger() + + pr, pw := io.Pipe() + defer pw.Close() + defer pr.Close() + + mh.cfg = memberlist.DefaultLANConfig() + mh.cfg.Events = mh + mh.cfg.Logger = stdlog.New(pw, "", 0) + go mh.runLogHandler(pr) + + var err error + mh.memberlist, err = memberlist.Create(mh.cfg) + if err != nil { + return fmt.Errorf("memberlist: error creating memberlist: %w", err) + } + + // the only way memberlist would be empty here, following create is if + // the current node suddenly died. Still, we check to be safe. + if len(mh.memberlist.Members()) == 0 { + return errors.New("memberlist: can't find self") + } + + <-ctx.Done() + return mh.memberlist.Shutdown() +} + +func (mh *memberlistHandler) NotifyJoin(node *memberlist.Node) { + mh.log.Debug().Interface("node", node).Msg("node joined") + + if mh.memberlist != nil && len(mh.memberlist.Members()) > 1 { + mh.log.Error().Msg("detected multiple cache servers, which is not supported") + } +} + +func (mh *memberlistHandler) NotifyLeave(node *memberlist.Node) { + mh.log.Debug().Interface("node", node).Msg("node left") +} + +func (mh *memberlistHandler) NotifyUpdate(node *memberlist.Node) { + mh.log.Debug().Interface("node", node).Msg("node updated") +} + +func (mh *memberlistHandler) runLogHandler(r io.Reader) { + s := bufio.NewScanner(r) + for s.Scan() { + mh.log.Debug().Msg(s.Text()) + } +} diff --git a/cache/session.go b/cache/session.go new file mode 100644 index 000000000..f1c1624be --- /dev/null +++ b/cache/session.go @@ -0,0 +1,72 @@ +package cache + +import ( + "context" + + "github.com/golang/protobuf/ptypes" + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/emptypb" + + "github.com/pomerium/pomerium/internal/grpc/databroker" + "github.com/pomerium/pomerium/internal/grpc/session" + "github.com/pomerium/pomerium/internal/log" +) + +// SessionServer implements the session service interface for adding and syncing sessions. +type SessionServer struct { + dataBrokerClient databroker.DataBrokerServiceClient +} + +// NewSessionServer creates a new SessionServer. +func NewSessionServer(grpcServer *grpc.Server, dataBrokerClient databroker.DataBrokerServiceClient) *SessionServer { + srv := &SessionServer{ + dataBrokerClient: dataBrokerClient, + } + session.RegisterSessionServiceServer(grpcServer, srv) + return srv +} + +// Delete deletes a session from the session server. +func (srv *SessionServer) Delete(ctx context.Context, req *session.DeleteRequest) (*emptypb.Empty, error) { + log.Info(). + Str("service", "session"). + Str("session_id", req.GetId()). + Msg("delete") + + data, err := ptypes.MarshalAny(new(session.Session)) + if err != nil { + return nil, err + } + + return srv.dataBrokerClient.Delete(ctx, &databroker.DeleteRequest{ + Type: data.GetTypeUrl(), + Id: req.GetId(), + }) +} + +// Add adds a session to the session server. +func (srv *SessionServer) Add(ctx context.Context, req *session.AddRequest) (*session.AddResponse, error) { + log.Info(). + Str("service", "session"). + Str("session_id", req.GetSession().GetId()). + Msg("add") + + data, err := ptypes.MarshalAny(req.GetSession()) + if err != nil { + return nil, err + } + + res, err := srv.dataBrokerClient.Set(ctx, &databroker.SetRequest{ + Type: data.GetTypeUrl(), + Id: req.GetSession().GetId(), + Data: data, + }) + if err != nil { + return nil, err + } + + return &session.AddResponse{ + Session: req.Session, + ServerVersion: res.GetServerVersion(), + }, nil +} diff --git a/cache/user.go b/cache/user.go new file mode 100644 index 000000000..6ba10130d --- /dev/null +++ b/cache/user.go @@ -0,0 +1,51 @@ +package cache + +import ( + "context" + + "github.com/golang/protobuf/ptypes" + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/emptypb" + + "github.com/pomerium/pomerium/internal/grpc/databroker" + "github.com/pomerium/pomerium/internal/grpc/user" + "github.com/pomerium/pomerium/internal/log" +) + +// UserServer implements the user service interface for syncing users. +type UserServer struct { + dataBrokerClient databroker.DataBrokerServiceClient +} + +// NewUserServer creates a new UserServer. +func NewUserServer(grpcServer *grpc.Server, dataBrokerClient databroker.DataBrokerServiceClient) *UserServer { + srv := &UserServer{ + dataBrokerClient: dataBrokerClient, + } + user.RegisterUserServiceServer(grpcServer, srv) + return srv +} + +// Add adds a user to the user server. +func (srv *UserServer) Add(ctx context.Context, req *user.AddRequest) (*emptypb.Empty, error) { + log.Info(). + Str("service", "user"). + Str("user_id", req.GetUser().GetId()). + Msg("add") + + data, err := ptypes.MarshalAny(req.GetUser()) + if err != nil { + return nil, err + } + + _, err = srv.dataBrokerClient.Set(ctx, &databroker.SetRequest{ + Type: data.GetTypeUrl(), + Id: req.GetUser().GetId(), + Data: data, + }) + if err != nil { + return nil, err + } + + return new(emptypb.Empty), nil +} diff --git a/config/options.go b/config/options.go index 9efa32e2a..76cac0671 100644 --- a/config/options.go +++ b/config/options.go @@ -21,6 +21,7 @@ import ( "gopkg.in/yaml.v2" "github.com/pomerium/pomerium/internal/cryptutil" + "github.com/pomerium/pomerium/internal/identity/oauth" "github.com/pomerium/pomerium/internal/log" "github.com/pomerium/pomerium/internal/telemetry" "github.com/pomerium/pomerium/internal/telemetry/metrics" @@ -216,6 +217,10 @@ type Options struct { CacheURLString string `mapstructure:"cache_service_url" yaml:"cache_service_url,omitempty"` CacheURL *url.URL `yaml:",omitempty"` + // DataBrokerURL is the routable destination of the databroker service's gRPC endpiont. + DataBrokerURLString string `mapstructure:"databroker_service_url" yaml:"databroker_service_url,omitempty"` + DataBrokerURL *url.URL `yaml:",omitempty"` + // CacheStoreAddr specifies the host and port on which the cache store // should connect to. e.g. (localhost:6379) CacheStoreAddr string `mapstructure:"cache_store_address" yaml:"cache_store_address,omitempty"` @@ -480,6 +485,10 @@ func (o *Options) Validate() error { } } + if o.DataBrokerURLString == "" { + o.DataBrokerURLString = o.CacheURLString + } + if IsAuthorize(o.Services) || IsCache(o.Services) { // if authorize is set, we don't really need a http server // but we'll still set one up incase the user wants to use @@ -522,6 +531,14 @@ func (o *Options) Validate() error { o.CacheURL = u } + if o.DataBrokerURLString != "" { + u, err := urlutil.ParseAndValidateURL(o.DataBrokerURLString) + if err != nil { + return fmt.Errorf("config: bad cache service url %s : %w", o.DataBrokerURLString, err) + } + o.DataBrokerURL = u + } + if o.ForwardAuthURLString != "" { u, err := urlutil.ParseAndValidateURL(o.ForwardAuthURLString) if err != nil { @@ -643,10 +660,10 @@ func (o *Options) GetAuthorizeURL() *url.URL { return u } -// GetCacheURL returns the CacheURL in the options or localhost:5443. -func (o *Options) GetCacheURL() *url.URL { - if o != nil && o.CacheURL != nil { - return o.CacheURL +// GetDataBrokerURL returns the DataBrokerURL in the options or localhost:5443. +func (o *Options) GetDataBrokerURL() *url.URL { + if o != nil && o.DataBrokerURL != nil { + return o.DataBrokerURL } u, _ := url.Parse("http://localhost" + DefaultAlternativeAddr) return u @@ -661,6 +678,21 @@ func (o *Options) GetForwardAuthURL() *url.URL { return u } +// GetOauthOptions gets the oauth.Options for the given config options. +func (o *Options) GetOauthOptions() oauth.Options { + redirectURL := o.GetAuthenticateURL() + redirectURL.Path = o.AuthenticateCallbackPath + return oauth.Options{ + RedirectURL: redirectURL, + ProviderName: o.Provider, + ProviderURL: o.ProviderURL, + ClientID: o.ClientID, + ClientSecret: o.ClientSecret, + Scopes: o.Scopes, + ServiceAccount: o.ServiceAccount, + } +} + // OptionsUpdater updates local state based on an Options struct type OptionsUpdater interface { UpdateOptions(Options) error diff --git a/config/options_test.go b/config/options_test.go index e6b0d623f..e34c8fe2c 100644 --- a/config/options_test.go +++ b/config/options_test.go @@ -207,7 +207,7 @@ func Test_Checksum(t *testing.T) { func TestOptionsFromViper(t *testing.T) { t.Parallel() opts := []cmp.Option{ - cmpopts.IgnoreFields(Options{}, "CacheStore", "CookieSecret", "GRPCInsecure", "GRPCAddr", "CacheURLString", "CacheURL", "AuthorizeURL", "AuthorizeURLString", "DefaultUpstreamTimeout", "CookieExpire", "Services", "Addr", "RefreshCooldown", "LogLevel", "KeyFile", "CertFile", "SharedKey", "ReadTimeout", "IdleTimeout", "GRPCClientTimeout", "GRPCClientDNSRoundRobin", "TracingSampleRate"), + cmpopts.IgnoreFields(Options{}, "CacheStore", "CookieSecret", "GRPCInsecure", "GRPCAddr", "CacheURLString", "CacheURL", "DataBrokerURLString", "DataBrokerURL", "AuthorizeURL", "AuthorizeURLString", "DefaultUpstreamTimeout", "CookieExpire", "Services", "Addr", "RefreshCooldown", "LogLevel", "KeyFile", "CertFile", "SharedKey", "ReadTimeout", "IdleTimeout", "GRPCClientTimeout", "GRPCClientDNSRoundRobin", "TracingSampleRate"), cmpopts.IgnoreFields(Policy{}, "Source", "Destination"), cmpOptIgnoreUnexported, } diff --git a/docs/configuration/readme.md b/docs/configuration/readme.md index 56ede369a..412b66ac5 100644 --- a/docs/configuration/readme.md +++ b/docs/configuration/readme.md @@ -464,34 +464,6 @@ Expose a prometheus format HTTP endpoint on the specified port. Disabled by defa Name | Type | Description --------------------------------------------- | --------- | ----------------------------------------------------------------------- -boltdb_free_alloc_size_bytes | Gauge | Bytes allocated in free pages -boltdb_free_page_n | Gauge | Number of free pages on the freelist -boltdb_freelist_inuse_size_bytes | Gauge | Bytes used by the freelist -boltdb_open_txn | Gauge | number of currently open read transactions -boltdb_pending_page_n | Gauge | Number of pending pages on the freelist -boltdb_txn | Gauge | total number of started read transactions -boltdb_txn_cursor_total | Counter | Total number of cursors created -boltdb_txn_node_deref_total | Counter | Total number of node dereferences -boltdb_txn_node_total | Counter | Total number of node allocations -boltdb_txn_page_alloc_size_bytes_total | Counter | Total bytes allocated -boltdb_txn_page_total | Counter | Total number of page allocations -boltdb_txn_rebalance_duration_ms_total | Counter | Total time spent rebalancing -boltdb_txn_rebalance_total | Counter | Total number of node rebalances -boltdb_txn_spill_duration_ms_total | Counter | Total time spent spilling -boltdb_txn_spill_total | Counter | Total number of nodes spilled -boltdb_txn_split_total | Counter | Total number of nodes split -boltdb_txn_write_duration_ms_total | Counter | Total time spent writing to disk -boltdb_txn_write_total | Counter | Total number of writes performed -groupcache_cache_hits_total | Counter | Total cache hits in local or cluster cache -groupcache_cache_hits_total | Counter | Total cache hits in local or cluster cache -groupcache_gets_total | Counter | Total get request, including from peers -groupcache_loads_deduped_total | Counter | gets without cache hits after duplicate suppression -groupcache_loads_total | Counter | Total gets without cache hits -groupcache_local_load_errs_total | Counter | Total local load errors -groupcache_local_loads_total | Counter | Total good local loads -groupcache_peer_errors_total | Counter | Total errors from peers -groupcache_peer_loads_total | Counter | Total remote loads or cache hits without error -groupcache_server_requests_total | Counter | Total gets from peers grpc_client_request_duration_ms | Histogram | GRPC client request duration by service grpc_client_request_size_bytes | Histogram | GRPC client request size by service grpc_client_requests_total | Counter | Total GRPC client requests made by service @@ -512,12 +484,6 @@ pomerium_build_info | Gauge | Pomerium build metad pomerium_config_checksum_int64 | Gauge | Currently loaded configuration checksum by service pomerium_config_last_reload_success | Gauge | Whether the last configuration reload succeeded by service pomerium_config_last_reload_success_timestamp | Gauge | The timestamp of the last successful configuration reload by service -redis_conns | Gauge | Number of total connections in the pool -redis_hits_total | Counter | Total number of times free connection was found in the pool -redis_idle_conns | Gauge | Number of idle connections in the pool -redis_misses_total | Counter | Total number of times free connection was NOT found in the pool -redis_stale_conns_total | Counter | Total number of stale connections removed from the pool -redis_timeouts_total | Counter | Total number of times a wait timeout occurred #### Envoy Proxy Metrics @@ -808,59 +774,23 @@ Refresh cooldown is the minimum amount of time between allowed manually refreshe The cache service is used for storing user session data. -### Cache Store +### Data Broker Service URL -- Environmental Variable: `CACHE_STORE` -- Config File Key: `cache_store` -- Type: `string` -- Default: `autocache` -- Options: `autocache` `bolt` or `redis`. Other contributions are welcome. +- Environmental Variable: `DATABROKER_SERVICE_URL` +- Config File Key: `databroker_service_url` +- Type: `URL` +- Example: `https://cache.corp.example.com` +- Default: in all-in-one mode, `http://localhost:5443` -CacheStore is the name of session cache backend to use. +The data broker service URL points to a data broker which is responsible for storing sessions, users and user groups. The `cache` service implements a basic in-memory databroker, so the legacy option `cache_service_url` will be used if this option is not configured. -### Autocache +To create your own data broker, implement the following gRPC interface: -[Autocache](https://github.com/pomerium/autocache) is the default session store. Autocache is based off of distributed version of [memcached](https://memcached.org/), called [groupcache](https://github.com/golang/groupcache) made by Google and used by many organizations like Twitter and Vimeo in production. Autocache is suitable for both small deployments, where it acts as a embedded cache, or larger scale, distributed installs. +- [internal/grpc/databroker/databroker.proto](https://github.com/pomerium/pomerium/blob/master/internal/grpc/databroker/databroker.proto) -When deployed in a distributed fashion, autocache uses [gossip](https://github.com/hashicorp/memberlist) based membership to manage its peers. +For an example implementation, the in-memory database used by the cache service can be found here: -Autocache does not require any additional settings but does require that the cache url setting returns name records that correspond to a [list of peers](https://kubernetes.io/docs/concepts/services-networking/service/#headless-services). - -### [Bolt](https://godoc.org/go.etcd.io/bbolt/) - -Bolt is a simple, lightweight, low level key value store and is the underlying storage mechanism in projects like [etcd](https://etcd.io/). Bolt persists data to a file, and has no built in eviction mechanism. - -Bolt is suitable for all-in-one deployments that do not require concurrent / distributed writes. - -#### Bolt Path - -- Environmental Variable: `CACHE_STORE_PATH` -- Config File Key: `cache_store_path` -- Type: `string` -- Example: `/etc/bolt.db` - -CacheStorePath is the path to save bolt's database file. - -### [Redis](https://redis.io/) - -Redis, when used as a [LRU cache](https://redis.io/topics/lru-cache), functions in a very similar way to autocache. Redis store support allows you to leverage existing infrastructure, and to persist session data if that is a requirement. - -#### Redis Address - -- Environmental Variable: `CACHE_STORE_ADDRESS` -- Config File Key: `cache_store_address` -- Type: `string` -- Example: `localhost:6379` - -CacheStoreAddr specifies the host and port on which the cache store should connect to redis. - -#### Redis Password - -- Environmental Variable: `CACHE_STORE_PASSWORD` -- Config File Key: `cache_store_password` -- Type: `string` - -CacheStoreAddr is the password used to connect to redis. +- [internal/databroker/memory](https://github.com/pomerium/pomerium/tree/master/internal/databroker/memory) ## Policy diff --git a/docs/docs/reference/programmatic-access.md b/docs/docs/reference/programmatic-access.md index a4e0c464b..0f6172b0b 100644 --- a/docs/docs/reference/programmatic-access.md +++ b/docs/docs/reference/programmatic-access.md @@ -29,27 +29,6 @@ It is the script or application's responsibility to create a HTTP callback handl See the python script below for example of how to start a callback server, and store the session payload. -### Refresh API - -The Refresh API allows for a valid refresh token enabled session, using an `Authorization: Pomerium` bearer token, to refresh the current user session and return a new user session (`jwt`) and refresh token (`refresh_token`). If successfully, a new updated refresh token and identity session are returned as a json response. - -```bash -$ curl \ - -H "Accept: application/json" \ - -H "Authorization: Pomerium $(cat cred-from-above-step.json | jq -r .refresh_token)" \ - https://authenticate.example.com/api/v1/refresh - -{ - "jwt":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", - "refresh_token":"fXiWCF_z1NWKU3yZ...." -} - -``` - -:::tip -Note that the Authorization refresh token is set to Authorization `Pomerium` _not_ `Bearer`. -::: - ## Handling expiration and revocation Your application should handle token expiration. If the session expires before work is done, the identity provider issued `refresh_token` can be used to create a new valid session. @@ -68,7 +47,6 @@ The application interacting with Pomerium must manage the following workflow. Co 1. Pomerium's proxy service and makes a callback request to the original `redirect_uri` with the user session and refresh token as arguments. 1. The script or application is responsible for handling that http callback request, and securely handling the callback session (`pomerium_jwt`) and refresh token (`pomerium_refresh_token`) queryparams. 1. The script or application can now make any requests as normal, by setting the `Authorization: Pomerium ${pomerium_jwt}` header. -1. If the script or application encounters a `401` error or token expiration error, the script or application can make a request the authenticate service's refresh api endpoint (e.g. `https://authenticate.corp.domain.example/api/v1/refresh`) with the `Authorization: Pomerium ${pomerium_refresh_token}` header. Note that the refresh token is used, not the user session jwt. If successful, a new user session jwt and refresh token will be returned and requests can continue as before. ## Example Code @@ -76,8 +54,7 @@ Please consider see the following minimal but complete python example. ```bash python3 scripts/programmatic_access.py \ - --dst https://httpbin.example.com/headers \ - --refresh-endpoint https://authenticate.example.com/api/v1/refresh + --dst https://httpbin.example.com/headers ``` <<< @/scripts/programmatic_access.py diff --git a/go.mod b/go.mod index 6253dd2f9..169b81cab 100644 --- a/go.mod +++ b/go.mod @@ -7,20 +7,25 @@ require ( contrib.go.opencensus.io/exporter/prometheus v0.2.0 contrib.go.opencensus.io/exporter/zipkin v0.1.1 github.com/caddyserver/certmagic v0.11.2 + github.com/cenkalti/backoff/v4 v4.0.0 github.com/cespare/xxhash/v2 v2.1.1 github.com/coreos/go-oidc v2.2.1+incompatible github.com/envoyproxy/go-control-plane v0.9.5 github.com/fsnotify/fsnotify v1.4.9 + github.com/go-chi/chi v4.1.2+incompatible github.com/go-redis/redis/v7 v7.4.0 github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e github.com/golang/mock v1.4.3 github.com/golang/protobuf v1.4.2 + github.com/google/btree v1.0.0 github.com/google/go-cmp v0.4.1 github.com/google/go-jsonnet v0.16.0 + github.com/google/uuid v1.1.1 github.com/gorilla/handlers v1.4.2 github.com/gorilla/mux v1.7.4 github.com/gorilla/websocket v1.4.2 github.com/hashicorp/golang-lru v0.5.4 + github.com/hashicorp/memberlist v0.1.3 github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 // indirect github.com/lithammer/shortuuid/v3 v3.0.4 github.com/mitchellh/hashstructure v1.0.0 @@ -32,7 +37,6 @@ require ( github.com/open-policy-agent/opa v0.20.5 github.com/openzipkin/zipkin-go v0.2.2 github.com/pelletier/go-toml v1.6.0 // indirect - github.com/pomerium/autocache v0.0.0-20200505053831-8c1cd659f055 github.com/pomerium/csrf v1.6.2-0.20190918035251-f3318380bad3 github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 // indirect github.com/prometheus/client_golang v1.6.0 @@ -46,6 +50,7 @@ require ( github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/viper v1.7.0 github.com/stretchr/testify v1.6.1 + github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 github.com/uber/jaeger-client-go v2.20.1+incompatible // indirect go.etcd.io/bbolt v1.3.4 go.opencensus.io v0.22.3 @@ -59,5 +64,6 @@ require ( google.golang.org/protobuf v1.24.0 gopkg.in/cookieo9/resources-go.v2 v2.0.0-20150225115733-d27c04069d0d gopkg.in/square/go-jose.v2 v2.5.1 + gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637 gopkg.in/yaml.v2 v2.3.0 ) diff --git a/go.sum b/go.sum index 5c4b9677f..fba82c893 100644 --- a/go.sum +++ b/go.sum @@ -47,7 +47,6 @@ github.com/Azure/go-autorest/tracing v0.1.0/go.mod h1:ROEEAFwXycQw7Sn3DXNtEedEvd github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/OneOfOne/xxhash v1.2.7 h1:fzrmmkskv067ZQbd9wERNGuxckWw67dyzoMG62p7LMo= github.com/OneOfOne/xxhash v1.2.7/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= @@ -64,9 +63,8 @@ github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRF github.com/aliyun/alibaba-cloud-sdk-go v1.61.112/go.mod h1:pUKYbK5JQ+1Dfxk80P0qxGqe5dkxDoabbZS7zOcouyA= github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= +github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da h1:8GUt8eRujhVEGZFFEjBj46YV4rDjvGrNxb0KMWYkL2I= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= -github.com/armon/go-metrics v0.3.0 h1:B7AQgHi8QSEi4uHu7Sbsga+IJDU+CENgjxoo81vDUqU= -github.com/armon/go-metrics v0.3.0/go.mod h1:zXjbSimjXTd7vOpY8B0/2LpvNvDoXBuplAD+gJD3GYs= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/aws/aws-sdk-go v1.30.20/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= @@ -90,8 +88,6 @@ github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= -github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudflare/cloudflare-go v0.10.2/go.mod h1:qhVI5MKwBGhdNU89ZRz2plgYutcJ5PCekLxXn56w6SY= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= @@ -138,6 +134,8 @@ github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-acme/lego/v3 v3.7.0 h1:qC5/8/CbltyAE8fGLE6bGlqucj7pXc/vBxiLwLOsmAQ= github.com/go-acme/lego/v3 v3.7.0/go.mod h1:4eDjjYkAsDXyNcwN8IhhZAwxz9Ltiks1Zmpv0q20J7A= +github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec= +github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= github.com/go-cmd/cmd v1.0.5/go.mod h1:y8q8qlK5wQibcw63djSl/ntiHUHXHGdCkPk0j4QeW4s= github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= @@ -239,21 +237,16 @@ github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBt github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-immutable-radix v1.0.0 h1:AKDB1HM5PWEA7i4nhcpwOrO2byshxBjXVn/J/3+z5/0= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= -github.com/hashicorp/go-immutable-radix v1.1.0 h1:vN9wG1D6KG6YHRTWr8512cxGOVgTMEfgEdSj/hr8MPc= -github.com/hashicorp/go-immutable-radix v1.1.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-msgpack v0.5.3 h1:zKjpN5BK/P5lMYrLmBHdBULWbJ0XpYR+7NGzqkZzoD4= github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= -github.com/hashicorp/go-msgpack v0.5.5 h1:i9R9JSrqIz0QVLz3sz+i3YJdT7TTSLcfLLzJi9aZTuI= -github.com/hashicorp/go-msgpack v0.5.5/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= -github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= +github.com/hashicorp/go-sockaddr v1.0.0 h1:GeH6tui99pF4NJgfnhp+L6+FfobzVW3Ah46sLo0ICXs= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= -github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc= -github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.1 h1:fv1ep09latC32wFoVwnqcnKJGnMSdBanPczbHAYm1BE= @@ -267,9 +260,8 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= +github.com/hashicorp/memberlist v0.1.3 h1:EmmoJme1matNzb+hMpDuR/0sbJSUisxyqBGG676r31M= github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= -github.com/hashicorp/memberlist v0.2.2 h1:5+RffWKwqJ71YPu9mWsF7ZOscZmwfasdA8kbdC7AO2g= -github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= @@ -326,7 +318,6 @@ github.com/mattn/go-tty v0.0.0-20180219170247-931426f7535a/go.mod h1:XPvLUNfbS4f github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= -github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= github.com/miekg/dns v1.1.27 h1:aEH/kqUzUxGJ/UHcEKdJY+ugH6WEzsEBBSPa8zuy1aM= github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= @@ -334,7 +325,6 @@ github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrk github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= github.com/mitchellh/go-vnc v0.0.0-20150629162542-723ed9867aed/go.mod h1:3rdaFaCv4AyBgu5ALFM0+tSuHrBh6v692nyQe3ikrq0= -github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= github.com/mitchellh/hashstructure v1.0.0 h1:ZkRJX1CyOoTkar7p/mLS5TZU4nJ1Rn/F8u9dGS02Q3Y= github.com/mitchellh/hashstructure v1.0.0/go.mod h1:QjSHrPWS+BGUVBYkbTZWEnOh3G1DutKwClXU/ABz6AQ= @@ -377,9 +367,8 @@ github.com/openzipkin/zipkin-go v0.2.2 h1:nY8Hti+WKaP0cRsSeQ026wU03QsM762XBeCXBb github.com/openzipkin/zipkin-go v0.2.2/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4= github.com/oracle/oci-go-sdk v7.0.0+incompatible/go.mod h1:VQb79nF8Z2cwLkLS35ukwStZIg5F66tcBccjip/j888= github.com/ovh/go-ovh v0.0.0-20181109152953-ba5adb4cf014/go.mod h1:joRatxRJaZBsY3JAOEMcoOp05CnZzsx4scTxi95DHyQ= +github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c h1:Lgl0gzECD8GnQ5QCWA8o6BtfL6mDH5rQgM4/fX3avOs= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= -github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= -github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pelletier/go-toml v1.6.0 h1:aetoXYr0Tv7xRU/V4B4IZJ2QcbtMUFoNb3ORp7TzIK4= github.com/pelletier/go-toml v1.6.0/go.mod h1:5N711Q9dKgbdkxHL+MEfF31hpT7l0S0s/t2kKREewys= @@ -394,8 +383,6 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pomerium/autocache v0.0.0-20200505053831-8c1cd659f055 h1:y73x4eEnZkCHdC46HG2h+ZAuNQUW1tu7hgvC7v+jI2o= -github.com/pomerium/autocache v0.0.0-20200505053831-8c1cd659f055/go.mod h1:xCwNSx2PcCbOG6XT0PpAGRkM5ZHnbXZLKg2ntE22j+M= github.com/pomerium/csrf v1.6.2-0.20190918035251-f3318380bad3 h1:FmzFXnCAepHZwl6QPhTFqBHcbcGevdiEQjutK+M5bj4= github.com/pomerium/csrf v1.6.2-0.20190918035251-f3318380bad3/go.mod h1:UE2U4JOsjXNeq+MX/lqhZpUFsNAxbXERuYsWK2iULh0= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= @@ -403,7 +390,6 @@ github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 h1:J9b7z+QKAm github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA= github.com/prometheus/client_golang v0.0.0-20181025174421-f30f42803563/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM= github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs= github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= @@ -419,7 +405,6 @@ github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2 github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/common v0.0.0-20181020173914-7e9e6cabbd39/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= -github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= @@ -428,7 +413,6 @@ github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt2 github.com/prometheus/common v0.9.1 h1:KOMtN28tlbam3/7ZKEYKHhKoJZYYj3gMH4uc62x7X7U= github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= @@ -456,7 +440,6 @@ github.com/rs/zerolog v1.19.0/go.mod h1:IzD0RJ65iWH0w97OQQebJEvTZYvsCUm9WVLWBQrJ github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= -github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sacloud/libsacloud v1.26.1/go.mod h1:79ZwATmHLIFZIMd7sxA3LwzVy/B77uj3LDoToVTxDoQ= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= @@ -504,8 +487,9 @@ github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/timewasted/linode v0.0.0-20160829202747-37e84520dcf7/go.mod h1:imsgLplxEC/etjIhdr3dNzV3JeT27LbVu5pYWm0JCBY= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 h1:nrZ3ySNYwJbSpD6ce9duiP+QkD3JuLCcWkdaehUS/3Y= +github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80/go.mod h1:iFyPdL66DjUD96XmzVL3ZntbzcflLnznH0fr99w5VqE= github.com/transip/gotransip/v6 v6.0.2/go.mod h1:pQZ36hWWRahCUXkFWlx9Hs711gLd8J4qdgLdRzmtY+g= -github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/uber-go/atomic v1.3.2/go.mod h1:/Ct5t2lcmbJ4OSe/waGBoaVvVqtO0bmtfVNex1PFV8g= github.com/uber/jaeger-client-go v2.15.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= github.com/uber/jaeger-client-go v2.20.1+incompatible h1:HgqpYBng0n7tLJIlyT4kPCIv5XgCsF+kai1NnnrJzEU= @@ -542,9 +526,7 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20190418165655-df01cb2cc480/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200117160349-530e935923ad/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9 h1:vEg9joUBmeBcK9iSJftGNf3coIG4HqZElCPehJsfAYM= golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= @@ -644,7 +626,6 @@ golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -655,7 +636,6 @@ golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200117145432-59e60aa80a0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -692,7 +672,6 @@ golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgw golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190828213141-aed303cbaa74/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190920225731-5eefd052ad72/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= @@ -804,6 +783,8 @@ gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76 gopkg.in/square/go-jose.v2 v2.5.1 h1:7odma5RETjNHWJnR32wx8t+Io4djHE1PqxCFx3iiZ2w= gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637 h1:yiW+nvdHb9LVqSHQBXfZCieqV4fzYhNBql77zY0ykqs= +gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637/go.mod h1:BHsqpu/nsuzkT5BpiH1EMZPLyqSMM8JbIavyFACoFNk= gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/integration/authorization_test.go b/integration/authorization_test.go index b55d5766c..a967e9829 100644 --- a/integration/authorization_test.go +++ b/integration/authorization_test.go @@ -77,64 +77,6 @@ func TestAuthorization(t *testing.T) { } }) }) - t.Run("groups", func(t *testing.T) { - t.Run("allowed", func(t *testing.T) { - client := testcluster.NewHTTPClient() - res, err := flows.Authenticate(ctx, client, mustParseURL("https://httpdetails.localhost.pomerium.io/by-group"), - withAPI, flows.WithEmail("bob@dogs.test"), flows.WithGroups("admin", "user")) - if assert.NoError(t, err) { - assert.Equal(t, http.StatusOK, res.StatusCode, "expected OK for admin") - } - }) - t.Run("not allowed", func(t *testing.T) { - client := testcluster.NewHTTPClient() - res, err := flows.Authenticate(ctx, client, mustParseURL("https://httpdetails.localhost.pomerium.io/by-group"), - withAPI, flows.WithEmail("joe@cats.test"), flows.WithGroups("user")) - if assert.NoError(t, err) { - assertDeniedAccess(t, res, "expected Forbidden for user, but got %d", res.StatusCode) - } - }) - }) - - t.Run("refresh", func(t *testing.T) { - client := testcluster.NewHTTPClient() - res, err := flows.Authenticate(ctx, client, mustParseURL("https://httpdetails.localhost.pomerium.io/by-domain"), - withAPI, flows.WithEmail("bob@dogs.test"), flows.WithGroups("user"), flows.WithTokenExpiration(time.Second)) - if !assert.NoError(t, err) { - return - } - assert.Equal(t, http.StatusOK, res.StatusCode, "expected OK for dogs.test") - res.Body.Close() - - // poll till we get a new cookie because of a refreshed session - ticker := time.NewTicker(time.Millisecond * 500) - defer ticker.Stop() - deadline := time.NewTimer(time.Second * 10) - defer deadline.Stop() - for i := 0; ; i++ { - select { - case <-ticker.C: - case <-deadline.C: - t.Fatal("timed out waiting for refreshed session") - return - case <-ctx.Done(): - t.Fatal("timed out waiting for refreshed session") - return - } - - res, err = client.Get(mustParseURL("https://httpdetails.localhost.pomerium.io/by-domain").String()) - if !assert.NoError(t, err) { - return - } - res.Body.Close() - if !assert.Equal(t, http.StatusOK, res.StatusCode, "failed after %d times", i+1) { - return - } - if res.Header.Get("Set-Cookie") != "" { - break - } - } - }) }) } } diff --git a/internal/cmd/pomerium/pomerium.go b/internal/cmd/pomerium/pomerium.go index e46be429a..078130e9d 100644 --- a/internal/cmd/pomerium/pomerium.go +++ b/internal/cmd/pomerium/pomerium.go @@ -22,7 +22,6 @@ import ( "github.com/pomerium/pomerium/config" "github.com/pomerium/pomerium/internal/controlplane" "github.com/pomerium/pomerium/internal/envoy" - pbCache "github.com/pomerium/pomerium/internal/grpc/cache" "github.com/pomerium/pomerium/internal/httputil" "github.com/pomerium/pomerium/internal/log" "github.com/pomerium/pomerium/internal/telemetry/metrics" @@ -76,11 +75,19 @@ func Run(ctx context.Context, configFile string) error { if err := setupAuthenticate(opt, controlPlane); err != nil { return err } - if err := setupAuthorize(opt, controlPlane, &optionsUpdaters); err != nil { - return err + var authorizeServer *authorize.Authorize + if config.IsAuthorize(opt.Services) { + authorizeServer, err = setupAuthorize(opt, controlPlane, &optionsUpdaters) + if err != nil { + return err + } } - if err := setupCache(opt, controlPlane); err != nil { - return err + var cacheServer *cache.Cache + if config.IsCache(opt.Services) { + cacheServer, err = setupCache(opt, controlPlane) + if err != nil { + return err + } } if err := setupProxy(opt, controlPlane); err != nil { return err @@ -112,6 +119,16 @@ func Run(ctx context.Context, configFile string) error { eg.Go(func() error { return envoyServer.Run(ctx) }) + if authorizeServer != nil { + eg.Go(func() error { + return authorizeServer.Run(ctx) + }) + } + if cacheServer != nil { + eg.Go(func() error { + return cacheServer.Run(ctx) + }) + } return eg.Wait() } @@ -132,14 +149,10 @@ func setupAuthenticate(opt *config.Options, controlPlane *controlplane.Server) e return nil } -func setupAuthorize(opt *config.Options, controlPlane *controlplane.Server, optionsUpdaters *[]config.OptionsUpdater) error { - if !config.IsAuthorize(opt.Services) { - return nil - } - +func setupAuthorize(opt *config.Options, controlPlane *controlplane.Server, optionsUpdaters *[]config.OptionsUpdater) (*authorize.Authorize, error) { svc, err := authorize.New(*opt) if err != nil { - return fmt.Errorf("error creating authorize service: %w", err) + return nil, fmt.Errorf("error creating authorize service: %w", err) } envoy_service_auth_v2.RegisterAuthorizationServer(controlPlane.GRPCServer, svc) @@ -148,23 +161,19 @@ func setupAuthorize(opt *config.Options, controlPlane *controlplane.Server, opti *optionsUpdaters = append(*optionsUpdaters, svc) err = svc.UpdateOptions(*opt) if err != nil { - return fmt.Errorf("error updating authorize options: %w", err) + return nil, fmt.Errorf("error updating authorize options: %w", err) } - return nil + return svc, nil } -func setupCache(opt *config.Options, controlPlane *controlplane.Server) error { - if !config.IsCache(opt.Services) { - return nil - } - +func setupCache(opt *config.Options, controlPlane *controlplane.Server) (*cache.Cache, error) { svc, err := cache.New(*opt) if err != nil { - return fmt.Errorf("error creating config service: %w", err) + return nil, fmt.Errorf("error creating config service: %w", err) } - pbCache.RegisterCacheServer(controlPlane.GRPCServer, svc) + svc.Register(controlPlane.GRPCServer) log.Info().Msg("enabled cache service") - return nil + return svc, nil } func setupMetrics(ctx context.Context, opt *config.Options) error { diff --git a/internal/controlplane/luascripts/statik.go b/internal/controlplane/luascripts/statik.go index 704a4455a..72cd7d859 100644 --- a/internal/controlplane/luascripts/statik.go +++ b/internal/controlplane/luascripts/statik.go @@ -9,6 +9,6 @@ import ( const Luascripts = "luascripts" // static asset namespace func init() { - data := "PK\x03\x04\x14\x00\x08\x00\x08\x00@\x89\xb3P\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x12\x00 \x00clean-upstream.luaUT\x05\x00\x01\xe8\x12\xc4^\x94S\xc1n\x9c0\x10\xbd\xf3\x15O\xf4PV%\x91z\xdd\xc8\xff\xd0{\xd5\"\x17f\x17\xab`\xbb\xf68\x9b\xe4\xd0o\xaf\x00\xc3\xe2\x00\xaa\xe2\x03\x1e\xcb\xf3\xde<\xde\x8c/A\xd7\xac\x8c\x86\xa3\xde\xfcZk\xc5\xe3#r\xf1\xfd\xe7\xd3\x8f/O\xc8K\xe4\xf9\xe9\xa3\xb8\x15\xca\x11\x07\xa7#&#\xddd\xd9\xe2[+}e\x1d]\xd4K\xe1\xd9\x95\x98\xe2\x04\xe7\xd9\xe1\xaf\x80V\x1d\xa4n\x86\xe3y(\xfb\xb5\xc4\xa7\x98\x0d!\"\xf0\x1d;\xe9g\xf3Z\x19]9\xfa\x13\xc8s\x11\xf7jrl*\xd3\x99ZvhI6\xe4<\x04\xd2\x9cs\xbc(\xd6\xc9=\xb1l$\xcbm\xf6|S\x9c\xb2U~\x9c\x8e\xb5Sb!9_\x89\x8b|\x7f\x80\xa2\x83\xea\xb2G\xc1-\xe9\xf1\xfa^hiPT=q'\\\x91/fFc\x13\xaaai\xba-\\\x07\xb3\xbdU\x94\x8e\xf8\xbcf)ql\x179\xe5\xbd\xc8\x1d0\xf4o\xde\xb7\x06\xca\xc0\xadq\xeaM\x8e\xdd\xfd\x9f\x85I\xf6\xc6\xc9\x94k\xc7\xcb\xf7\xc5\x12K\xf7\xb8\x0f\xa0q\xbe!\x90\x7f\x8b\xd2\x90\xaf[\xb1z\x03 \xb0\xdc\xe59m\x9buwx\xf8\xb3cqks\x0f\x1f\x8a\xb7F\xfb\xa1\xbbS\xb0<\x95\x11\xf1/\x00\x00\xff\xffPK\x07\x08\xfb\x06j<\xa2\x01\x00\x00\xf0\x04\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00bz\xb3P\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x18\x00 \x00ext-authz-set-cookie.luaUT\x05\x00\x01\xe8\xf8\xc3^\x8c\x91An\x830\x10E\xf7\x9cb\xc4\xcaHI\x0e\x80\x94\x03t\xd1\x13T\x955\xc5C\xb0j\x8fS{\x88\x9aM\xcf^AM\x04\x0dM\x19 \x01\xe2\xff?\xf8\xbf\xb6\xe7Fl` \xbe\x84\xab\x0e\xac#}\xf4\x94D\xe5\xbb\xee\x90\x8d\xa3\xaa\x00\x00p\xa1A\x07\x1d\xa1\xa1\x98\xe0\x08KM\x9d?\xa8\xb9\xd8\\\x19\xbdm\xb4'\xc1{G\x92H\xe8\x9f\xb8\x0d\xaa\xaa\xb3\xf4\x99\x04\x0d\n\xe6\x18\xdbN\x0b\xeb\x13\x89*?\xf7\xe7\xe0)\xda\xde\xef\x13\xc9\xbe \xe1\xddRY\xc1\xd7\x11\xd8:\x90\x8ex\xf4\x0d3_^\xa7\xc1=\x1e\xf3\xd0Z'\x14\xd3\xa1\x139\x1f\\\x8f\xe5\x0e\xca)U'\x12\x9dSw\xb7\xa4\xbb\xd9\xf2OU\xf1[\x1d\xc9\x87\x0b\xfdi\x18\xf5\xc4\xa6\x18\xaeb\x8dM:\x07N\xa4\xa6\x87\x7f\xe8,D\xdb\xf0,-\x1b\xf8\xfc\xe4\xc8\x9b\x83\xe3\xb2\xef\xd3\x83\xbeoh\x07_&\x87l\x86\xd7\x97U\x12\xaf\xab|\xa7Z\xd1\x18U\xce\x8a\xdc=\x08Z\x96\xfc\x1d\x00\x00\xff\xffPK\x07\x08\x93\xe7\xad\x94\x07\x01\x00\x00\x00\x03\x00\x00PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00@\x89\xb3P\xfb\x06j<\xa2\x01\x00\x00\xf0\x04\x00\x00\x12\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xb4\x81\x00\x00\x00\x00clean-upstream.luaUT\x05\x00\x01\xe8\x12\xc4^PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00bz\xb3P\x93\xe7\xad\x94\x07\x01\x00\x00\x00\x03\x00\x00\x18\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xb4\x81\xeb\x01\x00\x00ext-authz-set-cookie.luaUT\x05\x00\x01\xe8\xf8\xc3^PK\x05\x06\x00\x00\x00\x00\x02\x00\x02\x00\x98\x00\x00\x00A\x03\x00\x00\x00\x00" + data := "PK\x03\x04\x14\x00\x08\x00\x08\x00\x88\xbd\xbbP\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x12\x00 \x00clean-upstream.luaUT\x05\x00\x01P\xfb\xce^\x94S\xc1n\x9c0\x10\xbd\xf3\x15O\xf4PV%\x91z\xdd\xc8\xff\xd0{\xd5\"\x17f\x17\xab`\xbb\xf68\x9b\xe4\xd0o\xaf\x00\xc3\xe2\x00\xaa\xe2\x03\x1e\xcb\xf3\xde<\xde\x8c/A\xd7\xac\x8c\x86\xa3\xde\xfcZk\xc5\xe3#r\xf1\xfd\xe7\xd3\x8f/O\xc8K\xe4\xf9\xe9\xa3\xb8\x15\xca\x11\x07\xa7#&#\xddd\xd9\xe2[+}e\x1d]\xd4K\xe1\xd9\x95\x98\xe2\x04\xe7\xd9\xe1\xaf\x80V\x1d\xa4n\x86\xe3y(\xfb\xb5\xc4\xa7\x98\x0d!\"\xf0\x1d;\xe9g\xf3Z\x19]9\xfa\x13\xc8s\x11\xf7jrl*\xd3\x99ZvhI6\xe4<\x04\xd2\x9cs\xbc(\xd6\xc9=\xb1l$\xcbm\xf6|S\x9c\xb2U~\x9c\x8e\xb5Sb!9_\x89\x8b|\x7f\x80\xa2\x83\xea\xb2G\xc1-\xe9\xf1\xfa^hiPT=q'\\\x91/fFc\x13\xaaai\xba-\\\x07\xb3\xbdU\x94\x8e\xf8\xbcf)ql\x179\xe5\xbd\xc8\x1d0\xf4o\xde\xb7\x06\xca\xc0\xadq\xeaM\x8e\xdd\xfd\x9f\x85I\xf6\xc6\xc9\x94k\xc7\xcb\xf7\xc5\x12K\xf7\xb8\x0f\xa0q\xbe!\x90\x7f\x8b\xd2\x90\xaf[\xb1z\x03 \xb0\xdc\xe59m\x9buwx\xf8\xb3cqks\x0f\x1f\x8a\xb7F\xfb\xa1\xbbS\xb0<\x95\x11\xf1/\x00\x00\xff\xffPK\x07\x08\xfb\x06j<\xa2\x01\x00\x00\xf0\x04\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00\x88\xbd\xbbP\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x18\x00 \x00ext-authz-set-cookie.luaUT\x05\x00\x01P\xfb\xce^\x8c\x91An\x830\x10E\xf7\x9cb\xc4\xcaHI\x0e\x80\x94\x03t\xd1\x13T\x955\xc5C\xb0j\x8fS{\x88\x9aM\xcf^AM\x04\x0dM\x19 \x01\xe2\xff?\xf8\xbf\xb6\xe7Fl` \xbe\x84\xab\x0e\xac#}\xf4\x94D\xe5\xbb\xee\x90\x8d\xa3\xaa\x00\x00p\xa1A\x07\x1d\xa1\xa1\x98\xe0\x08KM\x9d?\xa8\xb9\xd8\\\x19\xbdm\xb4'\xc1{G\x92H\xe8\x9f\xb8\x0d\xaa\xaa\xb3\xf4\x99\x04\x0d\n\xe6\x18\xdbN\x0b\xeb\x13\x89*?\xf7\xe7\xe0)\xda\xde\xef\x13\xc9\xbe \xe1\xddRY\xc1\xd7\x11\xd8:\x90\x8ex\xf4\x0d3_^\xa7\xc1=\x1e\xf3\xd0Z'\x14\xd3\xa1\x139\x1f\\\x8f\xe5\x0e\xca)U'\x12\x9dSw\xb7\xa4\xbb\xd9\xf2OU\xf1[\x1d\xc9\x87\x0b\xfdi\x18\xf5\xc4\xa6\x18\xaeb\x8dM:\x07N\xa4\xa6\x87\x7f\xe8,D\xdb\xf0,-\x1b\xf8\xfc\xe4\xc8\x9b\x83\xe3\xb2\xef\xd3\x83\xbeoh\x07_&\x87l\x86\xd7\x97U\x12\xaf\xab|\xa7Z\xd1\x18U\xce\x8a\xdc=\x08Z\x96\xfc\x1d\x00\x00\xff\xffPK\x07\x08\x93\xe7\xad\x94\x07\x01\x00\x00\x00\x03\x00\x00PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\x88\xbd\xbbP\xfb\x06j<\xa2\x01\x00\x00\xf0\x04\x00\x00\x12\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xb4\x81\x00\x00\x00\x00clean-upstream.luaUT\x05\x00\x01P\xfb\xce^PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\x88\xbd\xbbP\x93\xe7\xad\x94\x07\x01\x00\x00\x00\x03\x00\x00\x18\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xb4\x81\xeb\x01\x00\x00ext-authz-set-cookie.luaUT\x05\x00\x01P\xfb\xce^PK\x05\x06\x00\x00\x00\x00\x02\x00\x02\x00\x98\x00\x00\x00A\x03\x00\x00\x00\x00" fs.RegisterWithNamespace("luascripts", data) } diff --git a/internal/controlplane/xds_listeners.go b/internal/controlplane/xds_listeners.go index 0b7f8455d..be400ec76 100644 --- a/internal/controlplane/xds_listeners.go +++ b/internal/controlplane/xds_listeners.go @@ -127,7 +127,7 @@ func buildMainHTTPConnectionManagerFilter(options *config.Options, domains []str if options.Addr == options.GRPCAddr { // if this is a gRPC service domain and we're supposed to handle that, add those routes if (config.IsAuthorize(options.Services) && domain == options.GetAuthorizeURL().Host) || - (config.IsCache(options.Services) && domain == options.GetCacheURL().Host) { + (config.IsCache(options.Services) && domain == options.GetDataBrokerURL().Host) { vh.Routes = append(vh.Routes, buildGRPCRoutes()...) } } @@ -304,6 +304,10 @@ func buildGRPCHTTPConnectionManagerFilter() *envoy_config_listener_v3.Filter { ClusterSpecifier: &envoy_config_route_v3.RouteAction_Cluster{ Cluster: "pomerium-control-plane-grpc", }, + // disable the timeout to support grpc streaming + Timeout: &durationpb.Duration{ + Seconds: 0, + }, }, }, }}, @@ -388,7 +392,7 @@ func getAllRouteableDomains(options *config.Options, addr string) []string { lookup[options.GetAuthorizeURL().Host] = struct{}{} } if config.IsCache(options.Services) && addr == options.GRPCAddr { - lookup[options.GetCacheURL().Host] = struct{}{} + lookup[options.GetDataBrokerURL().Host] = struct{}{} } if config.IsProxy(options.Services) && addr == options.Addr { for _, policy := range options.Policies { diff --git a/internal/controlplane/xds_listeners_test.go b/internal/controlplane/xds_listeners_test.go index a52c1073d..809c50af3 100644 --- a/internal/controlplane/xds_listeners_test.go +++ b/internal/controlplane/xds_listeners_test.go @@ -336,7 +336,7 @@ func Test_getAllRouteableDomains(t *testing.T) { Services: "all", AuthenticateURL: mustParseURL("https://authenticate.example.com"), AuthorizeURL: mustParseURL("https://authorize.example.com:9001"), - CacheURL: mustParseURL("https://cache.example.com:9001"), + DataBrokerURL: mustParseURL("https://cache.example.com:9001"), Policies: []config.Policy{ {Source: &config.StringURL{URL: mustParseURL("https://a.example.com")}}, {Source: &config.StringURL{URL: mustParseURL("https://b.example.com")}}, diff --git a/internal/databroker/memory/config.go b/internal/databroker/memory/config.go new file mode 100644 index 000000000..ad88a7eba --- /dev/null +++ b/internal/databroker/memory/config.go @@ -0,0 +1,45 @@ +package memory + +import "time" + +var ( + // DefaultDeletePermanentlyAfter is the default amount of time to wait before deleting + // a record permanently. + DefaultDeletePermanentlyAfter = time.Hour + // DefaultBTreeDegree is the default number of items to store in each node of the BTree. + DefaultBTreeDegree = 8 +) + +type serverConfig struct { + deletePermanentlyAfter time.Duration + btreeDegree int +} + +func newServerConfig(options ...ServerOption) *serverConfig { + cfg := new(serverConfig) + WithDeletePermanentlyAfter(DefaultDeletePermanentlyAfter)(cfg) + WithBTreeDegree(DefaultBTreeDegree)(cfg) + for _, option := range options { + option(cfg) + } + return cfg +} + +// A ServerOption customizes the server. +type ServerOption func(*serverConfig) + +// WithBTreeDegree sets the number of items to store in each node of the BTree. +func WithBTreeDegree(degree int) ServerOption { + return func(cfg *serverConfig) { + cfg.btreeDegree = degree + } +} + +// WithDeletePermanentlyAfter sets the deletePermanentlyAfter duration. +// If a record is deleted via Delete, it will be permanently deleted after +// the given duration. +func WithDeletePermanentlyAfter(dur time.Duration) ServerOption { + return func(cfg *serverConfig) { + cfg.deletePermanentlyAfter = dur + } +} diff --git a/internal/databroker/memory/db.go b/internal/databroker/memory/db.go new file mode 100644 index 000000000..2d133939d --- /dev/null +++ b/internal/databroker/memory/db.go @@ -0,0 +1,141 @@ +package memory + +import ( + "fmt" + "sync" + "sync/atomic" + "time" + + "github.com/golang/protobuf/ptypes" + "github.com/google/btree" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/anypb" + + "github.com/pomerium/pomerium/internal/grpc/databroker" +) + +type byIDRecord struct { + *databroker.Record +} + +func (k byIDRecord) Less(than btree.Item) bool { + return k.Id < than.(byIDRecord).Id +} + +type byVersionRecord struct { + *databroker.Record +} + +func (k byVersionRecord) Less(than btree.Item) bool { + return k.Version < than.(byVersionRecord).Version +} + +// DB is an in-memory database of records using b-trees. +type DB struct { + recordType string + + lastVersion uint64 + + mu sync.Mutex + byID *btree.BTree + byVersion *btree.BTree + deletedIDs []string +} + +// NewDB creates a new in-memory database for the given record type. +func NewDB(recordType string, btreeDegree int) *DB { + return &DB{ + recordType: recordType, + byID: btree.New(btreeDegree), + byVersion: btree.New(btreeDegree), + } +} + +// ClearDeleted clears all the currently deleted records older than the given cutoff. +func (db *DB) ClearDeleted(cutoff time.Time) { + db.mu.Lock() + defer db.mu.Unlock() + + var remaining []string + for _, id := range db.deletedIDs { + record, _ := db.byID.Get(byIDRecord{Record: &databroker.Record{Id: id}}).(byIDRecord) + ts, _ := ptypes.Timestamp(record.DeletedAt) + if ts.Before(cutoff) { + db.byID.Delete(record) + db.byVersion.Delete(byVersionRecord(record)) + } else { + remaining = append(remaining, id) + } + } + db.deletedIDs = remaining +} + +// Delete marks a record as deleted. +func (db *DB) Delete(id string) { + db.replaceOrInsert(id, func(record *databroker.Record) { + record.DeletedAt = ptypes.TimestampNow() + db.deletedIDs = append(db.deletedIDs, id) + }) +} + +// Get gets a record from the db. +func (db *DB) Get(id string) *databroker.Record { + record, ok := db.byID.Get(byIDRecord{Record: &databroker.Record{Id: id}}).(byIDRecord) + if !ok { + return nil + } + return record.Record +} + +// GetAll gets all the records in the db. +func (db *DB) GetAll() []*databroker.Record { + var records []*databroker.Record + db.byID.Ascend(func(item btree.Item) bool { + records = append(records, item.(byIDRecord).Record) + return true + }) + return records +} + +// List lists all the changes since the given version. +func (db *DB) List(sinceVersion string) []*databroker.Record { + var records []*databroker.Record + db.byVersion.AscendGreaterOrEqual(byVersionRecord{Record: &databroker.Record{Version: sinceVersion}}, func(i btree.Item) bool { + record := i.(byVersionRecord) + if record.Version > sinceVersion { + records = append(records, record.Record) + } + return true + }) + return records +} + +// Set replaces or inserts a record in the db. +func (db *DB) Set(id string, data *anypb.Any) { + db.replaceOrInsert(id, func(record *databroker.Record) { + record.Data = data + }) +} + +func (db *DB) replaceOrInsert(id string, f func(record *databroker.Record)) { + db.mu.Lock() + defer db.mu.Unlock() + + record, ok := db.byID.Get(byIDRecord{Record: &databroker.Record{Id: id}}).(byIDRecord) + if ok { + db.byVersion.Delete(byVersionRecord(record)) + record.Record = proto.Clone(record.Record).(*databroker.Record) + } else { + record.Record = new(databroker.Record) + } + f(record.Record) + if record.CreatedAt == nil { + record.CreatedAt = ptypes.TimestampNow() + } + record.ModifiedAt = ptypes.TimestampNow() + record.Type = db.recordType + record.Id = id + record.Version = fmt.Sprintf("%012X", atomic.AddUint64(&db.lastVersion, 1)) + db.byID.ReplaceOrInsert(record) + db.byVersion.ReplaceOrInsert(byVersionRecord(record)) +} diff --git a/internal/databroker/memory/db_test.go b/internal/databroker/memory/db_test.go new file mode 100644 index 000000000..8d6bc966c --- /dev/null +++ b/internal/databroker/memory/db_test.go @@ -0,0 +1,60 @@ +package memory + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/types/known/anypb" +) + +func TestDB(t *testing.T) { + db := NewDB("example", 2) + t.Run("get missing record", func(t *testing.T) { + assert.Nil(t, db.Get("abcd")) + }) + t.Run("get record", func(t *testing.T) { + data := new(anypb.Any) + db.Set("abcd", data) + record := db.Get("abcd") + if assert.NotNil(t, record) { + assert.NotNil(t, record.CreatedAt) + assert.Equal(t, data, record.Data) + assert.Nil(t, record.DeletedAt) + assert.Equal(t, "abcd", record.Id) + assert.NotNil(t, record.ModifiedAt) + assert.Equal(t, "example", record.Type) + assert.Equal(t, "000000000001", record.Version) + } + }) + t.Run("delete record", func(t *testing.T) { + db.Delete("abcd") + record := db.Get("abcd") + if assert.NotNil(t, record) { + assert.NotNil(t, record.DeletedAt) + } + }) + t.Run("clear deleted", func(t *testing.T) { + db.ClearDeleted(time.Now().Add(time.Second)) + assert.Nil(t, db.Get("abcd")) + }) + t.Run("keep remaining", func(t *testing.T) { + data := new(anypb.Any) + db.Set("abcd", data) + db.Delete("abcd") + db.ClearDeleted(time.Now().Add(-10 * time.Second)) + assert.NotNil(t, db.Get("abcd")) + db.ClearDeleted(time.Now().Add(time.Second)) + }) + t.Run("list", func(t *testing.T) { + for i := 0; i < 10; i++ { + data := new(anypb.Any) + db.Set(fmt.Sprintf("%02d", i), data) + } + + assert.Len(t, db.List(""), 10) + assert.Len(t, db.List("00000000000A"), 4) + assert.Len(t, db.List("00000000000F"), 0) + }) +} diff --git a/internal/databroker/memory/server.go b/internal/databroker/memory/server.go new file mode 100644 index 000000000..ef6856e35 --- /dev/null +++ b/internal/databroker/memory/server.go @@ -0,0 +1,236 @@ +// Package memory contains an in-memory data broker implementation. +package memory + +import ( + "context" + "reflect" + "sort" + "sync" + "time" + + "github.com/golang/protobuf/ptypes/empty" + "github.com/google/uuid" + "github.com/rs/zerolog" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/emptypb" + + "github.com/pomerium/pomerium/internal/grpc/databroker" + "github.com/pomerium/pomerium/internal/log" +) + +// Server implements the databroker service using an in memory database. +type Server struct { + version string + cfg *serverConfig + log zerolog.Logger + + mu sync.RWMutex + byType map[string]*DB + onchange *Signal +} + +// New creates a new server. +func New(options ...ServerOption) *Server { + cfg := newServerConfig(options...) + srv := &Server{ + version: uuid.New().String(), + cfg: cfg, + log: log.With().Str("service", "databroker").Logger(), + + byType: make(map[string]*DB), + onchange: NewSignal(), + } + go func() { + ticker := time.NewTicker(cfg.deletePermanentlyAfter / 2) + defer ticker.Stop() + + for range ticker.C { + var recordTypes []string + srv.mu.RLock() + for recordType := range srv.byType { + recordTypes = append(recordTypes, recordType) + } + srv.mu.RUnlock() + + for _, recordType := range recordTypes { + srv.getDB(recordType).ClearDeleted(time.Now().Add(-cfg.deletePermanentlyAfter)) + } + } + }() + return srv +} + +// Delete deletes a record from the in-memory list. +func (srv *Server) Delete(ctx context.Context, req *databroker.DeleteRequest) (*empty.Empty, error) { + srv.log.Info(). + Str("type", req.GetType()). + Str("id", req.GetId()). + Msg("delete") + + defer srv.onchange.Broadcast() + + srv.getDB(req.GetType()).Delete(req.GetId()) + + return new(empty.Empty), nil +} + +// Get gets a record from the in-memory list. +func (srv *Server) Get(ctx context.Context, req *databroker.GetRequest) (*databroker.GetResponse, error) { + srv.log.Info(). + Str("type", req.GetType()). + Str("id", req.GetId()). + Msg("get") + + record := srv.getDB(req.GetType()).Get(req.GetId()) + if record == nil { + return nil, status.Error(codes.NotFound, "record not found") + } + return &databroker.GetResponse{Record: record}, nil +} + +// GetAll gets all the records from the in-memory list. +func (srv *Server) GetAll(ctx context.Context, req *databroker.GetAllRequest) (*databroker.GetAllResponse, error) { + srv.log.Info(). + Str("type", req.GetType()). + Msg("get all") + + records := srv.getDB(req.GetType()).GetAll() + var recordVersion string + for _, record := range records { + if record.GetVersion() > recordVersion { + recordVersion = record.GetVersion() + } + } + return &databroker.GetAllResponse{ + ServerVersion: srv.version, + RecordVersion: recordVersion, + Records: records, + }, nil +} + +// Set updates a record in the in-memory list, or adds a new one. +func (srv *Server) Set(ctx context.Context, req *databroker.SetRequest) (*databroker.SetResponse, error) { + srv.log.Info(). + Str("type", req.GetType()). + Str("id", req.GetId()). + Msg("set") + + defer srv.onchange.Broadcast() + + db := srv.getDB(req.GetType()) + db.Set(req.GetId(), req.GetData()) + record := db.Get(req.GetId()) + + return &databroker.SetResponse{ + Record: record, + ServerVersion: srv.version, + }, nil +} + +// Sync streams updates for the given record type. +func (srv *Server) Sync(req *databroker.SyncRequest, stream databroker.DataBrokerService_SyncServer) error { + srv.log.Info(). + Str("type", req.GetType()). + Str("server_version", req.GetServerVersion()). + Str("record_version", req.GetRecordVersion()). + Msg("sync") + + recordVersion := req.GetRecordVersion() + // reset record version if the server versions don't match + if req.GetServerVersion() != srv.version { + recordVersion = "" + } + + db := srv.getDB(req.GetType()) + + ch := srv.onchange.Bind() + defer srv.onchange.Unbind(ch) + for { + updated := db.List(recordVersion) + + if len(updated) > 0 { + sort.Slice(updated, func(i, j int) bool { + return updated[i].Version < updated[j].Version + }) + recordVersion = updated[len(updated)-1].Version + err := stream.Send(&databroker.SyncResponse{ + ServerVersion: srv.version, + Records: updated, + }) + if err != nil { + return err + } + } + + select { + case <-stream.Context().Done(): + return stream.Context().Err() + case <-ch: + } + } +} + +// GetTypes returns all the known record types. +func (srv *Server) GetTypes(_ context.Context, _ *emptypb.Empty) (*databroker.GetTypesResponse, error) { + var recordTypes []string + srv.mu.RLock() + for recordType := range srv.byType { + recordTypes = append(recordTypes, recordType) + } + srv.mu.RUnlock() + + sort.Strings(recordTypes) + return &databroker.GetTypesResponse{ + Types: recordTypes, + }, nil +} + +// SyncTypes synchronizes all the known record types. +func (srv *Server) SyncTypes(req *emptypb.Empty, stream databroker.DataBrokerService_SyncTypesServer) error { + srv.log.Info(). + Msg("sync types") + + ch := srv.onchange.Bind() + defer srv.onchange.Unbind(ch) + + var prev []string + for { + res, err := srv.GetTypes(stream.Context(), req) + if err != nil { + return err + } + + if prev == nil || !reflect.DeepEqual(prev, res.Types) { + err := stream.Send(res) + if err != nil { + return err + } + prev = res.Types + } + + select { + case <-stream.Context().Done(): + return stream.Context().Err() + case <-ch: + } + } +} + +func (srv *Server) getDB(recordType string) *DB { + // double-checked locking: + // first try the read lock, then re-try with the write lock, and finally create a new db if nil + srv.mu.RLock() + db := srv.byType[recordType] + srv.mu.RUnlock() + if db == nil { + srv.mu.Lock() + db = srv.byType[recordType] + if db == nil { + db = NewDB(recordType, srv.cfg.btreeDegree) + srv.byType[recordType] = db + } + srv.mu.Unlock() + } + return db +} diff --git a/internal/databroker/memory/signal.go b/internal/databroker/memory/signal.go new file mode 100644 index 000000000..5901419a2 --- /dev/null +++ b/internal/databroker/memory/signal.go @@ -0,0 +1,45 @@ +package memory + +import "sync" + +// A Signal is used to let multiple listeners know when something happened. +type Signal struct { + mu sync.Mutex + chs map[chan struct{}]struct{} +} + +// NewSignal creates a new Signal. +func NewSignal() *Signal { + return &Signal{ + chs: make(map[chan struct{}]struct{}), + } +} + +// Broadcast signals all the listeners. Broadcast never blocks. +func (s *Signal) Broadcast() { + s.mu.Lock() + for ch := range s.chs { + select { + case ch <- struct{}{}: + default: + } + } + s.mu.Unlock() +} + +// Bind creates a new listening channel bound to the signal. The channel used has a size of 1 +// and any given broadcast will signal at least one event, but may signal more than one. +func (s *Signal) Bind() chan struct{} { + ch := make(chan struct{}, 1) + s.mu.Lock() + s.chs[ch] = struct{}{} + s.mu.Unlock() + return ch +} + +// Unbind stops the listening channel bound to the signal. +func (s *Signal) Unbind(ch chan struct{}) { + s.mu.Lock() + delete(s.chs, ch) + s.mu.Unlock() +} diff --git a/internal/directory/azure/azure.go b/internal/directory/azure/azure.go new file mode 100644 index 000000000..922794931 --- /dev/null +++ b/internal/directory/azure/azure.go @@ -0,0 +1,295 @@ +// Package azure contains an azure active directory directory provider. +package azure + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" + "sync" + + "golang.org/x/oauth2" + + "github.com/pomerium/pomerium/internal/grpc/directory" +) + +var ( + defaultGraphHost = "graph.microsoft.com" + + defaultLoginHost = "login.microsoftonline.com" + defaultLoginScope = "https://graph.microsoft.com/.default" + defaultLoginGrantType = "client_credentials" +) + +type config struct { + graphURL *url.URL + httpClient *http.Client + loginURL *url.URL + serviceAccount *ServiceAccount +} + +// An Option updates the provider configuration. +type Option func(*config) + +// WithGraphURL sets the graph URL for the configuration. +func WithGraphURL(graphURL *url.URL) Option { + return func(cfg *config) { + cfg.graphURL = graphURL + } +} + +// WithLoginURL sets the login URL for the configuration. +func WithLoginURL(loginURL *url.URL) Option { + return func(cfg *config) { + cfg.loginURL = loginURL + } +} + +// WithHTTPClient sets the http client to use for requests to the Azure APIs. +func WithHTTPClient(httpClient *http.Client) Option { + return func(cfg *config) { + cfg.httpClient = httpClient + } +} + +// WithServiceAccount sets the service account to use to access Azure. +func WithServiceAccount(serviceAccount *ServiceAccount) Option { + return func(cfg *config) { + cfg.serviceAccount = serviceAccount + } +} + +func getConfig(options ...Option) *config { + cfg := new(config) + WithGraphURL(&url.URL{ + Scheme: "https", + Host: defaultGraphHost, + })(cfg) + WithHTTPClient(http.DefaultClient)(cfg) + WithLoginURL(&url.URL{ + Scheme: "https", + Host: defaultLoginHost, + })(cfg) + for _, option := range options { + option(cfg) + } + return cfg +} + +// A Provider is a directory implementation using azure active directory. +type Provider struct { + cfg *config + + mu sync.RWMutex + token *oauth2.Token +} + +// New creates a new Provider. +func New(options ...Option) *Provider { + return &Provider{ + cfg: getConfig(options...), + } +} + +// UserGroups returns the directory users in azure active directory. +func (p *Provider) UserGroups(ctx context.Context) ([]*directory.User, error) { + if p.cfg.serviceAccount == nil { + return nil, fmt.Errorf("azure: service account not defined") + } + + groupIDs, err := p.listGroups(ctx) + if err != nil { + return nil, err + } + + userIDToGroupIDs := map[string][]string{} + for _, groupID := range groupIDs { + userIDs, err := p.listGroupMembers(ctx, groupID) + if err != nil { + return nil, err + } + + for _, userID := range userIDs { + userIDToGroupIDs[userID] = append(userIDToGroupIDs[userID], groupID) + } + } + + var users []*directory.User + for userID, groupIDs := range userIDToGroupIDs { + sort.Strings(groupIDs) + users = append(users, &directory.User{Id: userID, Groups: groupIDs}) + } + sort.Slice(users, func(i, j int) bool { + return users[i].GetId() < users[j].GetId() + }) + return users, nil +} + +func (p *Provider) listGroups(ctx context.Context) (groupIDs []string, err error) { + nextURL := p.cfg.graphURL.ResolveReference(&url.URL{ + Path: "/v1.0/groups", + }).String() + + for nextURL != "" { + var result struct { + Value []struct { + ID string `json:"id"` + } `json:"value"` + NextLink string `json:"@odata.nextLink"` + } + err := p.api(ctx, "GET", nextURL, nil, &result) + if err != nil { + return nil, err + } + for _, v := range result.Value { + groupIDs = append(groupIDs, v.ID) + } + nextURL = result.NextLink + } + + return groupIDs, nil +} + +func (p *Provider) listGroupMembers(ctx context.Context, groupID string) (userIDs []string, err error) { + nextURL := p.cfg.graphURL.ResolveReference(&url.URL{ + Path: fmt.Sprintf("/v1.0/groups/%s/members", groupID), + }).String() + + for nextURL != "" { + var result struct { + Value []struct { + ID string `json:"id"` + } `json:"value"` + NextLink string `json:"@odata.nextLink"` + } + err := p.api(ctx, "GET", nextURL, nil, &result) + if err != nil { + return nil, err + } + for _, v := range result.Value { + userIDs = append(userIDs, v.ID) + } + nextURL = result.NextLink + } + + return userIDs, nil +} + +func (p *Provider) api(ctx context.Context, method, url string, body io.Reader, out interface{}) error { + token, err := p.getToken(ctx) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return fmt.Errorf("azure: error creating HTTP request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken)) + + res, err := p.cfg.httpClient.Do(req) + if err != nil { + return fmt.Errorf("azure: error making HTTP request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode/100 != 2 { + return fmt.Errorf("azure: error querying api: %s", res.Status) + } + + err = json.NewDecoder(res.Body).Decode(out) + if err != nil { + return fmt.Errorf("azure: error decoding api response: %w", err) + } + + return nil +} + +func (p *Provider) getToken(ctx context.Context) (*oauth2.Token, error) { + p.mu.RLock() + token := p.token + p.mu.RUnlock() + + if token != nil && token.Valid() { + return token, nil + } + + p.mu.Lock() + defer p.mu.Unlock() + + token = p.token + if token != nil && token.Valid() { + return token, nil + } + + tokenURL := p.cfg.loginURL.ResolveReference(&url.URL{ + Path: fmt.Sprintf("/%s/oauth2/v2.0/token", p.cfg.serviceAccount.DirectoryID), + }) + + req, err := http.NewRequestWithContext(ctx, "POST", tokenURL.String(), strings.NewReader(url.Values{ + "client_id": {p.cfg.serviceAccount.ClientID}, + "client_secret": {p.cfg.serviceAccount.ClientSecret}, + "scope": {defaultLoginScope}, + "grant_type": {defaultLoginGrantType}, + }.Encode())) + if err != nil { + return nil, fmt.Errorf("azure: error creating HTTP request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + res, err := p.cfg.httpClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode/100 != 2 { + return nil, fmt.Errorf("azure: error querying oauth2 token: %s", res.Status) + } + err = json.NewDecoder(res.Body).Decode(&token) + if err != nil { + return nil, fmt.Errorf("azure: error decoding oauth2 token: %w", err) + } + p.token = token + + return p.token, nil +} + +// A ServiceAccount is used by the Azure provider to query the Microsoft Graph API. +type ServiceAccount struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + DirectoryID string `json:"directory_id"` +} + +// ParseServiceAccount parses the service account in the config options. +func ParseServiceAccount(rawServiceAccount string) (*ServiceAccount, error) { + bs, err := base64.StdEncoding.DecodeString(rawServiceAccount) + if err != nil { + return nil, err + } + + var serviceAccount ServiceAccount + err = json.Unmarshal(bs, &serviceAccount) + if err != nil { + return nil, err + } + + if serviceAccount.ClientID == "" { + return nil, fmt.Errorf("client_id is required") + } + if serviceAccount.ClientSecret == "" { + return nil, fmt.Errorf("client_secret is required") + } + if serviceAccount.DirectoryID == "" { + return nil, fmt.Errorf("directory_id is required") + } + + return &serviceAccount, nil +} diff --git a/internal/directory/azure/azure_test.go b/internal/directory/azure/azure_test.go new file mode 100644 index 000000000..a4f0e13d9 --- /dev/null +++ b/internal/directory/azure/azure_test.go @@ -0,0 +1,112 @@ +package azure + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-chi/chi" + "github.com/go-chi/chi/middleware" + "github.com/stretchr/testify/assert" + + "github.com/pomerium/pomerium/internal/grpc/directory" +) + +type M = map[string]interface{} + +func newMockAPI(t *testing.T, srv *httptest.Server) http.Handler { + r := chi.NewRouter() + r.Use(middleware.Logger) + r.Post("/DIRECTORY_ID/oauth2/v2.0/token", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "CLIENT_ID", r.FormValue("client_id")) + assert.Equal(t, "CLIENT_SECRET", r.FormValue("client_secret")) + assert.Equal(t, defaultLoginScope, r.FormValue("scope")) + assert.Equal(t, defaultLoginGrantType, r.FormValue("grant_type")) + + _ = json.NewEncoder(w).Encode(M{ + "access_token": "ACCESSTOKEN", + "token_type": "Bearer", + "refresh_token": "REFRESHTOKEN", + }) + }) + r.Route("/v1.0", func(r chi.Router) { + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer ACCESSTOKEN" { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) + }) + r.Get("/groups", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(M{ + "value": []M{ + {"id": "admin"}, + {"id": "test"}, + }, + }) + }) + r.Get("/groups/{group_name}/members", func(w http.ResponseWriter, r *http.Request) { + members := map[string][]M{ + "admin": { + {"id": "user-1"}, + }, + "test": { + {"id": "user-2"}, + {"id": "user-3"}, + }, + } + _ = json.NewEncoder(w).Encode(M{ + "value": members[chi.URLParam(r, "group_name")], + }) + }) + }) + return r +} + +func Test(t *testing.T) { + var mockAPI http.Handler + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mockAPI.ServeHTTP(w, r) + })) + defer srv.Close() + mockAPI = newMockAPI(t, srv) + + p := New( + WithGraphURL(mustParseURL(srv.URL)), + WithLoginURL(mustParseURL(srv.URL)), + WithServiceAccount(&ServiceAccount{ + ClientID: "CLIENT_ID", + ClientSecret: "CLIENT_SECRET", + DirectoryID: "DIRECTORY_ID", + }), + ) + users, err := p.UserGroups(context.Background()) + assert.NoError(t, err) + assert.Equal(t, []*directory.User{ + { + Id: "user-1", + Groups: []string{"admin"}, + }, + { + Id: "user-2", + Groups: []string{"test"}, + }, + { + Id: "user-3", + Groups: []string{"test"}, + }, + }, users) +} + +func mustParseURL(rawurl string) *url.URL { + u, err := url.Parse(rawurl) + if err != nil { + panic(err) + } + return u +} diff --git a/internal/directory/gitlab/gitlab.go b/internal/directory/gitlab/gitlab.go new file mode 100644 index 000000000..6e8460042 --- /dev/null +++ b/internal/directory/gitlab/gitlab.go @@ -0,0 +1,226 @@ +// Package gitlab contains a directory provider for gitlab. +package gitlab + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + "sort" + + "github.com/rs/zerolog" + "github.com/tomnomnom/linkheader" + + "github.com/pomerium/pomerium/internal/grpc/directory" + "github.com/pomerium/pomerium/internal/log" +) + +var ( + defaultURL = &url.URL{ + Scheme: "https", + Host: "gitlab.com", + } +) + +type config struct { + httpClient *http.Client + serviceAccount *ServiceAccount + url *url.URL +} + +// An Option updates the gitlab configuration. +type Option func(cfg *config) + +// WithServiceAccount sets the service account in the config. +func WithServiceAccount(serviceAccount *ServiceAccount) Option { + return func(cfg *config) { + cfg.serviceAccount = serviceAccount + } +} + +// WithHTTPClient sets the http client option. +func WithHTTPClient(httpClient *http.Client) Option { + return func(cfg *config) { + cfg.httpClient = httpClient + } +} + +// WithURL sets the api url in the config. +func WithURL(u *url.URL) Option { + return func(cfg *config) { + cfg.url = u + } +} + +func getConfig(options ...Option) *config { + cfg := new(config) + WithHTTPClient(http.DefaultClient)(cfg) + WithURL(defaultURL)(cfg) + for _, option := range options { + option(cfg) + } + return cfg +} + +// The Provider retrieves users and groups from gitlab. +type Provider struct { + cfg *config + log zerolog.Logger +} + +// New creates a new Provider. +func New(options ...Option) *Provider { + return &Provider{ + cfg: getConfig(options...), + log: log.With().Str("service", "directory").Str("provider", "gitlab").Logger(), + } +} + +// UserGroups gets the directory user groups for gitlab. +func (p *Provider) UserGroups(ctx context.Context) ([]*directory.User, error) { + if p.cfg.serviceAccount == nil { + return nil, fmt.Errorf("gitlab: service account not defined") + } + + p.log.Info().Msg("getting user groups") + + groupIDs, err := p.listGroupIDs(ctx) + if err != nil { + return nil, err + } + + userIDToGroupIDs := map[int][]int{} + for _, groupID := range groupIDs { + userIDs, err := p.listGroupMemberIDs(ctx, groupID) + if err != nil { + return nil, err + } + + for _, userID := range userIDs { + userIDToGroupIDs[userID] = append(userIDToGroupIDs[userID], groupID) + } + } + + var users []*directory.User + for userID, groupIDs := range userIDToGroupIDs { + user := &directory.User{ + Id: fmt.Sprint(userID), + } + for _, groupID := range groupIDs { + user.Groups = append(user.Groups, fmt.Sprint(groupID)) + } + sort.Strings(user.Groups) + users = append(users, user) + } + sort.Slice(users, func(i, j int) bool { + return users[i].GetId() < users[j].GetId() + }) + return users, nil +} + +func (p *Provider) listGroupIDs(ctx context.Context) (groupIDs []int, err error) { + nextURL := p.cfg.url.ResolveReference(&url.URL{ + Path: "/api/v4/groups", + }).String() + for nextURL != "" { + var result []struct { + ID int `json:"id"` + } + hdrs, err := p.apiGet(ctx, nextURL, &result) + if err != nil { + return nil, fmt.Errorf("gitlab: error querying groups: %w", err) + } + + for _, r := range result { + groupIDs = append(groupIDs, r.ID) + } + + nextURL = getNextLink(hdrs) + } + return groupIDs, nil +} + +func (p *Provider) listGroupMemberIDs(ctx context.Context, groupID int) (userIDs []int, err error) { + nextURL := p.cfg.url.ResolveReference(&url.URL{ + Path: fmt.Sprintf("/api/v4/groups/%d/members", groupID), + }).String() + for nextURL != "" { + var result []struct { + ID int `json:"id"` + } + hdrs, err := p.apiGet(ctx, nextURL, &result) + if err != nil { + return nil, fmt.Errorf("gitlab: error querying group members: %w", err) + } + + for _, r := range result { + userIDs = append(userIDs, r.ID) + } + + nextURL = getNextLink(hdrs) + } + return userIDs, nil +} + +func (p *Provider) apiGet(ctx context.Context, uri string, out interface{}) (http.Header, error) { + req, err := http.NewRequestWithContext(ctx, "GET", uri, nil) + if err != nil { + return nil, fmt.Errorf("gitlab: failed to create HTTP request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("PRIVATE-TOKEN", p.cfg.serviceAccount.PrivateToken) + + res, err := p.cfg.httpClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode/100 != 2 { + return nil, fmt.Errorf("gitlab: error query api status_code=%d: %s", res.StatusCode, res.Status) + } + + err = json.NewDecoder(res.Body).Decode(out) + if err != nil { + return nil, err + } + + return res.Header, nil +} + +func getNextLink(hdrs http.Header) string { + for _, link := range linkheader.ParseMultiple(hdrs.Values("Link")) { + if link.Rel == "next" { + return link.URL + } + } + return "" +} + +// A ServiceAccount is used by the Gitlab provider to query the Gitlab API. +type ServiceAccount struct { + PrivateToken string `json:"private_token"` +} + +// ParseServiceAccount parses the service account in the config options. +func ParseServiceAccount(rawServiceAccount string) (*ServiceAccount, error) { + bs, err := base64.StdEncoding.DecodeString(rawServiceAccount) + if err != nil { + return nil, err + } + + var serviceAccount ServiceAccount + err = json.Unmarshal(bs, &serviceAccount) + if err != nil { + return nil, err + } + + if serviceAccount.PrivateToken == "" { + return nil, fmt.Errorf("private_token is required") + } + + return &serviceAccount, nil +} diff --git a/internal/directory/gitlab/gitlab_test.go b/internal/directory/gitlab/gitlab_test.go new file mode 100644 index 000000000..16c217451 --- /dev/null +++ b/internal/directory/gitlab/gitlab_test.go @@ -0,0 +1,84 @@ +package gitlab + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-chi/chi" + "github.com/go-chi/chi/middleware" + "github.com/stretchr/testify/assert" + + "github.com/pomerium/pomerium/internal/testutil" +) + +type M = map[string]interface{} + +func newMockAPI(t *testing.T, srv *httptest.Server) http.Handler { + r := chi.NewRouter() + r.Use(middleware.Logger) + r.Route("/api/v4", func(r chi.Router) { + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Private-Token") != "PRIVATE_TOKEN" { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) + }) + r.Get("/groups", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode([]M{ + {"id": 1}, + {"id": 2}, + }) + }) + r.Get("/groups/{group_name}/members", func(w http.ResponseWriter, r *http.Request) { + members := map[string][]M{ + "1": { + {"id": 11}, + }, + "2": { + {"id": 12}, + {"id": 13}, + }, + } + _ = json.NewEncoder(w).Encode(members[chi.URLParam(r, "group_name")]) + }) + }) + return r +} + +func Test(t *testing.T) { + var mockAPI http.Handler + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mockAPI.ServeHTTP(w, r) + })) + defer srv.Close() + mockAPI = newMockAPI(t, srv) + + p := New( + WithURL(mustParseURL(srv.URL)), + WithServiceAccount(&ServiceAccount{ + PrivateToken: "PRIVATE_TOKEN", + }), + ) + users, err := p.UserGroups(context.Background()) + assert.NoError(t, err) + testutil.AssertProtoJSONEqual(t, `[ + { "id": "11", "groups": ["1"] }, + { "id": "12", "groups": ["2"] }, + { "id": "13", "groups": ["2"] } + ]`, users) +} + +func mustParseURL(rawurl string) *url.URL { + u, err := url.Parse(rawurl) + if err != nil { + panic(err) + } + return u +} diff --git a/internal/directory/google/google.go b/internal/directory/google/google.go new file mode 100644 index 000000000..05374a138 --- /dev/null +++ b/internal/directory/google/google.go @@ -0,0 +1,168 @@ +// Package google contains the Google directory provider. +package google + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "sort" + "sync" + + "github.com/rs/zerolog" + "golang.org/x/oauth2/google" + admin "google.golang.org/api/admin/directory/v1" + "google.golang.org/api/option" + + "github.com/pomerium/pomerium/internal/grpc/directory" + "github.com/pomerium/pomerium/internal/log" +) + +const ( + defaultProviderURL = "https://accounts.google.com" +) + +type config struct { + serviceAccount string + url string +} + +// An Option changes the configuration for the Google directory provider. +type Option func(cfg *config) + +// WithServiceAccount sets the service account in the Google configuration. +func WithServiceAccount(serviceAccount string) Option { + return func(cfg *config) { + cfg.serviceAccount = serviceAccount + } +} + +// WithURL sets the provider url to use. +func WithURL(url string) Option { + return func(cfg *config) { + cfg.url = url + } +} + +func getConfig(options ...Option) *config { + cfg := new(config) + WithURL(defaultProviderURL)(cfg) + for _, option := range options { + option(cfg) + } + return cfg +} + +// Required scopes for groups api +// https://developers.google.com/admin-sdk/directory/v1/reference/groups/list +var apiScopes = []string{admin.AdminDirectoryUserReadonlyScope, admin.AdminDirectoryGroupReadonlyScope} + +// A Provider is a Google directory provider. +type Provider struct { + cfg *config + log zerolog.Logger + + mu sync.RWMutex + apiClient *admin.Service +} + +// New creates a new Google directory provider. +func New(options ...Option) *Provider { + return &Provider{ + cfg: getConfig(options...), + log: log.With().Str("service", "directory").Str("provider", "google").Logger(), + } +} + +// UserGroups returns a slice of group names a given user is in +// NOTE: groups via Directory API is limited to 1 QPS! +// https://developers.google.com/admin-sdk/directory/v1/reference/groups/list +// https://developers.google.com/admin-sdk/directory/v1/limits +func (p *Provider) UserGroups(ctx context.Context) ([]*directory.User, error) { + apiClient, err := p.getAPIClient(ctx) + if err != nil { + return nil, fmt.Errorf("google: error getting API client: %w", err) + } + + var groups []string + err = apiClient.Groups.List(). + Context(ctx). + Customer("my_customer"). + Pages(ctx, func(res *admin.Groups) error { + for _, g := range res.Groups { + groups = append(groups, g.Id) + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("google: error getting groups: %w", err) + } + + userEmailToGroups := map[string][]string{} + for _, group := range groups { + group := group + err = apiClient.Members.List(group). + Context(ctx). + Pages(ctx, func(res *admin.Members) error { + for _, member := range res.Members { + userEmailToGroups[member.Email] = append(userEmailToGroups[member.Email], group) + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("google: error getting group members: %w", err) + } + } + + var users []*directory.User + for userEmail, groups := range userEmailToGroups { + sort.Strings(groups) + users = append(users, &directory.User{ + Id: userEmail, + Groups: groups, + }) + } + sort.Slice(users, func(i, j int) bool { + return users[i].Id < users[j].Id + }) + return users, nil +} + +func (p *Provider) getAPIClient(ctx context.Context) (*admin.Service, error) { + p.mu.RLock() + apiClient := p.apiClient + p.mu.RUnlock() + if apiClient != nil { + return apiClient, nil + } + + p.mu.Lock() + defer p.mu.Unlock() + if p.apiClient != nil { + return p.apiClient, nil + } + + apiCreds, err := base64.StdEncoding.DecodeString(p.cfg.serviceAccount) + if err != nil { + return nil, fmt.Errorf("google: could not decode service account json %w", err) + } + + var additionalFields struct { + ImpersonateUser string `json:"impersonate_user"` + } + _ = json.Unmarshal(apiCreds, &additionalFields) + + config, err := google.JWTConfigFromJSON(apiCreds, apiScopes...) + if err != nil { + return nil, fmt.Errorf("google: error reading jwt config: %w", err) + } + config.Subject = additionalFields.ImpersonateUser + + ts := config.TokenSource(ctx) + + p.apiClient, err = admin.NewService(ctx, option.WithTokenSource(ts)) + if err != nil { + return nil, fmt.Errorf("google: failed creating admin service %w", err) + } + return p.apiClient, nil +} diff --git a/internal/directory/okta/okta.go b/internal/directory/okta/okta.go new file mode 100644 index 000000000..09b480431 --- /dev/null +++ b/internal/directory/okta/okta.go @@ -0,0 +1,242 @@ +// Package okta contains the Okta directory provider. +package okta + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + "sort" + + "github.com/rs/zerolog" + "github.com/tomnomnom/linkheader" + + "github.com/pomerium/pomerium/internal/grpc/directory" + "github.com/pomerium/pomerium/internal/log" +) + +type config struct { + batchSize int + httpClient *http.Client + providerURL *url.URL + serviceAccount *ServiceAccount +} + +// An Option configures the Okta Provider. +type Option func(cfg *config) + +// WithBatchSize sets the batch size option. +func WithBatchSize(batchSize int) Option { + return func(cfg *config) { + cfg.batchSize = batchSize + } +} + +// WithHTTPClient sets the http client option. +func WithHTTPClient(httpClient *http.Client) Option { + return func(cfg *config) { + cfg.httpClient = httpClient + } +} + +// WithProviderURL sets the provider URL option. +func WithProviderURL(uri *url.URL) Option { + return func(cfg *config) { + cfg.providerURL = uri + } +} + +// WithServiceAccount sets the service account option. +func WithServiceAccount(serviceAccount *ServiceAccount) Option { + return func(cfg *config) { + cfg.serviceAccount = serviceAccount + } +} + +func getConfig(options ...Option) *config { + cfg := new(config) + WithBatchSize(100)(cfg) + WithHTTPClient(http.DefaultClient)(cfg) + for _, option := range options { + option(cfg) + } + return cfg +} + +// A Provider is an Okta user group directory provider. +type Provider struct { + cfg *config + log zerolog.Logger +} + +// New creates a new Provider. +func New(options ...Option) *Provider { + return &Provider{ + cfg: getConfig(options...), + log: log.With().Str("service", "directory").Str("provider", "okta").Logger(), + } +} + +// UserGroups fetches the groups of which the user is a member +// https://developer.okta.com/docs/reference/api/users/#get-user-s-groups +func (p *Provider) UserGroups(ctx context.Context) ([]*directory.User, error) { + if p.cfg.serviceAccount == nil { + return nil, fmt.Errorf("okta: service account not defined") + } + + p.log.Info().Msg("getting user groups") + + if p.cfg.providerURL == nil { + return nil, fmt.Errorf("okta: provider url not defined") + } + + groupIDToName, err := p.getGroups(ctx) + if err != nil { + return nil, err + } + + userEmailToGroups := map[string][]string{} + for groupID, groupName := range groupIDToName { + emails, err := p.getGroupMemberEmails(ctx, groupID) + if err != nil { + return nil, err + } + for _, email := range emails { + userEmailToGroups[email] = append(userEmailToGroups[email], groupName) + } + } + + var users []*directory.User + for userEmail, groups := range userEmailToGroups { + sort.Strings(groups) + users = append(users, &directory.User{ + Id: userEmail, + Groups: groups, + }) + } + sort.Slice(users, func(i, j int) bool { + return users[i].Id < users[j].Id + }) + return users, nil +} + +func (p *Provider) getGroups(ctx context.Context) (map[string]string, error) { + groups := map[string]string{} + + groupURL := p.cfg.providerURL.ResolveReference(&url.URL{ + Path: "/api/v1/groups", + RawQuery: fmt.Sprintf("limit=%d", p.cfg.batchSize), + }).String() + for groupURL != "" { + var out []struct { + ID string `json:"id"` + Profile struct { + Name string `json:"name"` + } `json:"profile"` + } + hdrs, err := p.apiGet(ctx, groupURL, &out) + if err != nil { + return nil, fmt.Errorf("okta: error querying for groups: %w", err) + } + + for _, el := range out { + groups[el.ID] = el.Profile.Name + } + + groupURL = getNextLink(hdrs) + } + + return groups, nil +} + +func (p *Provider) getGroupMemberEmails(ctx context.Context, groupID string) ([]string, error) { + var emails []string + + usersURL := p.cfg.providerURL.ResolveReference(&url.URL{ + Path: fmt.Sprintf("/api/v1/groups/%s/users", groupID), + RawQuery: fmt.Sprintf("limit=%d", p.cfg.batchSize), + }).String() + for usersURL != "" { + var out []struct { + ID string `json:"id"` + Profile struct { + Email string `json:"email"` + } `json:"profile"` + } + hdrs, err := p.apiGet(ctx, usersURL, &out) + if err != nil { + return nil, fmt.Errorf("okta: error querying for groups: %w", err) + } + + for _, el := range out { + emails = append(emails, el.Profile.Email) + } + + usersURL = getNextLink(hdrs) + } + + return emails, nil +} + +func (p *Provider) apiGet(ctx context.Context, uri string, out interface{}) (http.Header, error) { + req, err := http.NewRequestWithContext(ctx, "GET", uri, nil) + if err != nil { + return nil, fmt.Errorf("okta: failed to create HTTP request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "SSWS "+p.cfg.serviceAccount.APIKey) + + res, err := p.cfg.httpClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode/100 != 2 { + return nil, fmt.Errorf("okta: error query api status_code=%d: %s", res.StatusCode, res.Status) + } + + err = json.NewDecoder(res.Body).Decode(out) + if err != nil { + return nil, err + } + + return res.Header, nil +} + +func getNextLink(hdrs http.Header) string { + for _, link := range linkheader.ParseMultiple(hdrs.Values("Link")) { + if link.Rel == "next" { + return link.URL + } + } + return "" +} + +// A ServiceAccount is used by the Okta provider to query the API. +type ServiceAccount struct { + APIKey string `json:"api_key"` +} + +// ParseServiceAccount parses the service account in the config options. +func ParseServiceAccount(rawServiceAccount string) (*ServiceAccount, error) { + bs, err := base64.StdEncoding.DecodeString(rawServiceAccount) + if err != nil { + return nil, err + } + + var serviceAccount ServiceAccount + err = json.Unmarshal(bs, &serviceAccount) + if err != nil { + return nil, err + } + + if serviceAccount.APIKey == "" { + return nil, fmt.Errorf("api_key is required") + } + + return &serviceAccount, nil +} diff --git a/internal/directory/okta/okta_test.go b/internal/directory/okta/okta_test.go new file mode 100644 index 000000000..99dcc7e4a --- /dev/null +++ b/internal/directory/okta/okta_test.go @@ -0,0 +1,142 @@ +package okta + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "sort" + "testing" + + "github.com/go-chi/chi" + "github.com/go-chi/chi/middleware" + "github.com/stretchr/testify/assert" + "github.com/tomnomnom/linkheader" + + "github.com/pomerium/pomerium/internal/grpc/directory" +) + +type M = map[string]interface{} + +func newMockOkta(srv *httptest.Server, userEmailToGroups map[string][]string) http.Handler { + allGroups := map[string]struct{}{} + for _, groups := range userEmailToGroups { + for _, group := range groups { + allGroups[group] = struct{}{} + } + } + + r := chi.NewRouter() + r.Use(middleware.Logger) + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "SSWS APITOKEN" { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) + }) + r.Get("/api/v1/groups", func(w http.ResponseWriter, r *http.Request) { + var groups []string + for group := range allGroups { + groups = append(groups, group) + } + sort.Strings(groups) + + var result []M + + found := r.URL.Query().Get("after") == "" + for i := range groups { + if found { + result = append(result, M{ + "id": groups[i], + "profile": M{ + "name": groups[i], + }, + }) + break + } + found = r.URL.Query().Get("after") == groups[i] + } + + if len(result) > 0 { + nextURL := mustParseURL(srv.URL).ResolveReference(r.URL) + q := nextURL.Query() + q.Set("after", result[0]["id"].(string)) + nextURL.RawQuery = q.Encode() + w.Header().Set("Link", linkheader.Link{ + URL: nextURL.String(), + Rel: "next", + }.String()) + } + + _ = json.NewEncoder(w).Encode(result) + }) + r.Get("/api/v1/groups/{group}/users", func(w http.ResponseWriter, r *http.Request) { + group := chi.URLParam(r, "group") + + var result []M + for email, groups := range userEmailToGroups { + for _, g := range groups { + if group == g { + result = append(result, M{ + "id": email, + "profile": M{ + "email": email, + }, + }) + } + } + } + sort.Slice(result, func(i, j int) bool { + return result[i]["id"].(string) < result[j]["id"].(string) + }) + + _ = json.NewEncoder(w).Encode(result) + }) + return r +} + +func TestProvider_UserGroups(t *testing.T) { + var mockOkta http.Handler + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mockOkta.ServeHTTP(w, r) + })) + defer srv.Close() + mockOkta = newMockOkta(srv, map[string][]string{ + "a@example.com": {"user", "admin"}, + "b@example.com": {"user", "test"}, + "c@example.com": {"user"}, + }) + + p := New( + WithServiceAccount(&ServiceAccount{APIKey: "APITOKEN"}), + WithProviderURL(mustParseURL(srv.URL)), + ) + users, err := p.UserGroups(context.Background()) + assert.NoError(t, err) + assert.Equal(t, []*directory.User{ + { + Id: "a@example.com", + Groups: []string{"admin", "user"}, + }, + { + Id: "b@example.com", + Groups: []string{"test", "user"}, + }, + { + Id: "c@example.com", + Groups: []string{"user"}, + }, + }, users) +} + +func mustParseURL(rawurl string) *url.URL { + u, err := url.Parse(rawurl) + if err != nil { + panic(err) + } + return u +} diff --git a/internal/directory/onelogin/onelogin.go b/internal/directory/onelogin/onelogin.go new file mode 100644 index 000000000..a60504f28 --- /dev/null +++ b/internal/directory/onelogin/onelogin.go @@ -0,0 +1,314 @@ +// Package onelogin contains the onelogin directory provider. +package onelogin + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + "sort" + "strings" + "sync" + + "github.com/rs/zerolog" + "golang.org/x/oauth2" + + "github.com/pomerium/pomerium/internal/grpc/directory" + "github.com/pomerium/pomerium/internal/log" +) + +type config struct { + apiURL *url.URL + batchSize int + serviceAccount *ServiceAccount + httpClient *http.Client +} + +// An Option updates the onelogin configuration. +type Option func(*config) + +// WithBatchSize sets the batch size option. +func WithBatchSize(batchSize int) Option { + return func(cfg *config) { + cfg.batchSize = batchSize + } +} + +// WithHTTPClient sets the http client option. +func WithHTTPClient(httpClient *http.Client) Option { + return func(cfg *config) { + cfg.httpClient = httpClient + } +} + +// WithServiceAccount sets the service account in the config. +func WithServiceAccount(serviceAccount *ServiceAccount) Option { + return func(cfg *config) { + cfg.serviceAccount = serviceAccount + } +} + +// WithURL sets the api url in the config. +func WithURL(apiURL *url.URL) Option { + return func(cfg *config) { + cfg.apiURL = apiURL + } +} + +func getConfig(options ...Option) *config { + cfg := new(config) + WithBatchSize(20)(cfg) + WithHTTPClient(http.DefaultClient)(cfg) + WithURL(&url.URL{ + Scheme: "https", + Host: "api.us.onelogin.com", + })(cfg) + for _, option := range options { + option(cfg) + } + return cfg +} + +// The Provider retrieves users and groups from onelogin. +type Provider struct { + cfg *config + log zerolog.Logger + + mu sync.RWMutex + token *oauth2.Token +} + +// New creates a new Provider. +func New(options ...Option) *Provider { + cfg := getConfig(options...) + return &Provider{ + cfg: cfg, + log: log.With().Str("service", "directory").Str("provider", "onelogin").Logger(), + } +} + +// UserGroups gets the directory user groups for onelogin. +func (p *Provider) UserGroups(ctx context.Context) ([]*directory.User, error) { + if p.cfg.serviceAccount == nil { + return nil, fmt.Errorf("onelogin: service account not defined") + } + + p.log.Info().Msg("getting user groups") + + token, err := p.getToken(ctx) + if err != nil { + return nil, err + } + + groupIDToName, err := p.getGroupIDToName(ctx, token) + if err != nil { + return nil, err + } + + userEmailToGroupIDs, err := p.getUserEmailToGroupIDs(ctx, token) + if err != nil { + return nil, err + } + + userEmailToGroupNames := map[string][]string{} + for email, groupIDs := range userEmailToGroupIDs { + for _, groupID := range groupIDs { + if groupName, ok := groupIDToName[groupID]; ok { + userEmailToGroupNames[email] = append(userEmailToGroupNames[email], groupName) + } else { + userEmailToGroupNames[email] = append(userEmailToGroupNames[email], "NOGROUP") + } + } + } + + var users []*directory.User + for userEmail, groups := range userEmailToGroupNames { + sort.Strings(groups) + users = append(users, &directory.User{ + Id: userEmail, + Groups: groups, + }) + } + sort.Slice(users, func(i, j int) bool { + return users[i].Id < users[j].Id + }) + return users, nil +} + +func (p *Provider) getGroupIDToName(ctx context.Context, token *oauth2.Token) (map[int]string, error) { + groupIDToName := map[int]string{} + + apiURL := p.cfg.apiURL.ResolveReference(&url.URL{ + Path: "/api/1/groups", + RawQuery: fmt.Sprintf("limit=%d", p.cfg.batchSize), + }).String() + for apiURL != "" { + var result []struct { + ID int `json:"id"` + Name string `json:"name"` + } + nextLink, err := p.apiGet(ctx, token, apiURL, &result) + if err != nil { + return nil, fmt.Errorf("onelogin: error querying group api: %w", err) + } + + for _, r := range result { + groupIDToName[r.ID] = r.Name + } + + apiURL = nextLink + } + + return groupIDToName, nil +} + +func (p *Provider) getUserEmailToGroupIDs(ctx context.Context, token *oauth2.Token) (map[string][]int, error) { + userEmailToGroupIDs := map[string][]int{} + + apiURL := p.cfg.apiURL.ResolveReference(&url.URL{ + Path: "/api/1/users", + RawQuery: fmt.Sprintf("limit=%d", p.cfg.batchSize), + }).String() + for apiURL != "" { + var result []struct { + Email string `json:"email"` + GroupID *int `json:"group_id"` + } + nextLink, err := p.apiGet(ctx, token, apiURL, &result) + if err != nil { + return nil, err + } + + for _, r := range result { + groupID := 0 + if r.GroupID != nil { + groupID = *r.GroupID + } + userEmailToGroupIDs[r.Email] = append(userEmailToGroupIDs[r.Email], groupID) + } + + apiURL = nextLink + } + + return userEmailToGroupIDs, nil +} + +func (p *Provider) apiGet(ctx context.Context, token *oauth2.Token, uri string, out interface{}) (nextLink string, err error) { + req, err := http.NewRequestWithContext(ctx, "GET", uri, nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", fmt.Sprintf("bearer:%s", token.AccessToken)) + req.Header.Set("Content-Type", "application/json") + + res, err := p.cfg.httpClient.Do(req) + if err != nil { + return "", err + } + defer res.Body.Close() + + if res.StatusCode/100 != 2 { + return "", fmt.Errorf("onelogin: error querying api: %s", res.Status) + } + + var result struct { + Pagination struct { + NextLink string `json:"next_link"` + } + Data json.RawMessage `json:"data"` + } + err = json.NewDecoder(res.Body).Decode(&result) + if err != nil { + return "", err + } + + p.log.Info(). + Str("url", uri). + Interface("result", result). + Msg("api request") + + err = json.Unmarshal(result.Data, out) + if err != nil { + return "", err + } + + return result.Pagination.NextLink, nil +} + +func (p *Provider) getToken(ctx context.Context) (*oauth2.Token, error) { + p.mu.RLock() + token := p.token + p.mu.RUnlock() + + if token != nil && token.Valid() { + return token, nil + } + + p.mu.Lock() + defer p.mu.Unlock() + + token = p.token + if token != nil && token.Valid() { + return token, nil + } + + apiURL := p.cfg.apiURL.ResolveReference(&url.URL{ + Path: "/auth/oauth2/v2/token", + }) + + req, err := http.NewRequestWithContext(ctx, "POST", apiURL.String(), strings.NewReader(`{ "grant_type": "client_credentials" }`)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", fmt.Sprintf("client_id:%s, client_secret:%s", + p.cfg.serviceAccount.ClientID, p.cfg.serviceAccount.ClientSecret)) + req.Header.Set("Content-Type", "application/json") + + res, err := p.cfg.httpClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode/100 != 2 { + return nil, fmt.Errorf("onelogin: error querying oauth2 token: %s", res.Status) + } + err = json.NewDecoder(res.Body).Decode(&token) + if err != nil { + return nil, err + } + p.token = token + + return p.token, nil +} + +// A ServiceAccount is used by the OneLogin provider to query the API. +type ServiceAccount struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` +} + +// ParseServiceAccount parses the service account in the config options. +func ParseServiceAccount(rawServiceAccount string) (*ServiceAccount, error) { + bs, err := base64.StdEncoding.DecodeString(rawServiceAccount) + if err != nil { + return nil, err + } + + var serviceAccount ServiceAccount + err = json.Unmarshal(bs, &serviceAccount) + if err != nil { + return nil, err + } + + if serviceAccount.ClientID == "" { + return nil, fmt.Errorf("client_id is required") + } + if serviceAccount.ClientSecret == "" { + return nil, fmt.Errorf("client_secret is required") + } + + return &serviceAccount, nil +} diff --git a/internal/directory/onelogin/onelogin_test.go b/internal/directory/onelogin/onelogin_test.go new file mode 100644 index 000000000..5a5e46e40 --- /dev/null +++ b/internal/directory/onelogin/onelogin_test.go @@ -0,0 +1,174 @@ +package onelogin + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "sort" + "testing" + "time" + + "github.com/go-chi/chi" + "github.com/go-chi/chi/middleware" + "github.com/stretchr/testify/assert" + + "github.com/pomerium/pomerium/internal/grpc/directory" +) + +type M = map[string]interface{} + +func newMockAPI(srv *httptest.Server, userEmailToGroupName map[string]string) http.Handler { + lookup := map[string]struct{}{} + for _, group := range userEmailToGroupName { + lookup[group] = struct{}{} + } + var allGroups []string + for groupName := range lookup { + allGroups = append(allGroups, groupName) + } + sort.Strings(allGroups) + + var allEmails []string + for email := range userEmailToGroupName { + allEmails = append(allEmails, email) + } + sort.Strings(allEmails) + + r := chi.NewRouter() + r.Use(middleware.Logger) + r.Post("/auth/oauth2/v2/token", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "client_id:CLIENTID, client_secret:CLIENTSECRET" { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + + var request struct { + GrantType string `json:"grant_type"` + } + _ = json.NewDecoder(r.Body).Decode(&request) + if request.GrantType != "client_credentials" { + http.Error(w, "invalid grant_type", http.StatusBadRequest) + return + } + + _ = json.NewEncoder(w).Encode(M{ + "access_token": "ACCESSTOKEN", + "created_at": time.Now().Format(time.RFC3339), + "expires_in": 360000, + "refresh_token": "REFRESHTOKEN", + "token_type": "bearer", + }) + }) + r.Route("/api/1", func(r chi.Router) { + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "bearer:ACCESSTOKEN" { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) + }) + r.Get("/groups", func(w http.ResponseWriter, r *http.Request) { + var result struct { + Pagination struct { + NextLink string `json:"next_link"` + } `json:"pagination"` + Data []M `json:"data"` + } + + found := r.URL.Query().Get("after") == "" + for i := range allGroups { + if found { + result.Data = append(result.Data, M{ + "id": i, + "name": allGroups[i], + }) + break + } + found = r.URL.Query().Get("after") == fmt.Sprint(i) + } + + if len(result.Data) > 0 { + nextURL := mustParseURL(srv.URL).ResolveReference(r.URL) + q := nextURL.Query() + q.Set("after", fmt.Sprint(result.Data[0]["id"])) + nextURL.RawQuery = q.Encode() + result.Pagination.NextLink = nextURL.String() + } + + _ = json.NewEncoder(w).Encode(result) + }) + r.Get("/users", func(w http.ResponseWriter, r *http.Request) { + userEmailToGroupID := map[string]int{} + for email, groupName := range userEmailToGroupName { + for id, n := range allGroups { + if groupName == n { + userEmailToGroupID[email] = id + } + } + } + + var result []M + for i, email := range allEmails { + result = append(result, M{ + "id": i, + "email": email, + "group_id": userEmailToGroupID[email], + }) + } + _ = json.NewEncoder(w).Encode(M{ + "data": result, + }) + }) + }) + return r +} + +func TestProvider_UserGroups(t *testing.T) { + var mockAPI http.Handler + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mockAPI.ServeHTTP(w, r) + })) + defer srv.Close() + mockAPI = newMockAPI(srv, map[string]string{ + "a@example.com": "admin", + "b@example.com": "test", + "c@example.com": "user", + }) + + p := New( + WithServiceAccount(&ServiceAccount{ + ClientID: "CLIENTID", + ClientSecret: "CLIENTSECRET", + }), + WithURL(mustParseURL(srv.URL)), + ) + users, err := p.UserGroups(context.Background()) + assert.NoError(t, err) + assert.Equal(t, []*directory.User{ + { + Id: "a@example.com", + Groups: []string{"admin"}, + }, + { + Id: "b@example.com", + Groups: []string{"test"}, + }, + { + Id: "c@example.com", + Groups: []string{"user"}, + }, + }, users) +} + +func mustParseURL(rawurl string) *url.URL { + u, err := url.Parse(rawurl) + if err != nil { + panic(err) + } + return u +} diff --git a/internal/directory/provider.go b/internal/directory/provider.go new file mode 100644 index 000000000..b3c9d2055 --- /dev/null +++ b/internal/directory/provider.go @@ -0,0 +1,89 @@ +// Package directory implements the user group directory service. +package directory + +import ( + "context" + "net/url" + + "github.com/pomerium/pomerium/config" + "github.com/pomerium/pomerium/internal/directory/azure" + "github.com/pomerium/pomerium/internal/directory/gitlab" + "github.com/pomerium/pomerium/internal/directory/google" + "github.com/pomerium/pomerium/internal/directory/okta" + "github.com/pomerium/pomerium/internal/directory/onelogin" + "github.com/pomerium/pomerium/internal/grpc/directory" + "github.com/pomerium/pomerium/internal/log" +) + +// A User is a directory User. +type User = directory.User + +// A Provider provides user group directory information. +type Provider interface { + UserGroups(ctx context.Context) ([]*User, error) +} + +// GetProvider gets the provider for the given options. +func GetProvider(options *config.Options) Provider { + switch options.Provider { + case "azure": + serviceAccount, err := azure.ParseServiceAccount(options.ServiceAccount) + if err == nil { + return azure.New(azure.WithServiceAccount(serviceAccount)) + } + + log.Warn(). + Str("service", "directory"). + Str("provider", options.Provider). + Err(err). + Msg("invalid service account for azure directory provider") + case "gitlab": + serviceAccount, err := gitlab.ParseServiceAccount(options.ServiceAccount) + if err == nil { + return gitlab.New(gitlab.WithServiceAccount(serviceAccount)) + } + log.Warn(). + Str("service", "directory"). + Str("provider", options.Provider). + Err(err). + Msg("invalid service account for gitlab directory provider") + case "google": + if options.ServiceAccount != "" { + return google.New(google.WithServiceAccount(options.ServiceAccount)) + } + case "okta": + providerURL, _ := url.Parse(options.ProviderURL) + serviceAccount, err := okta.ParseServiceAccount(options.ServiceAccount) + if err == nil { + return okta.New( + okta.WithProviderURL(providerURL), + okta.WithServiceAccount(serviceAccount)) + } + log.Warn(). + Str("service", "directory"). + Str("provider", options.Provider). + Err(err). + Msg("invalid service account for okta directory provider") + case "onelogin": + serviceAccount, err := onelogin.ParseServiceAccount(options.ServiceAccount) + if err == nil { + return onelogin.New(onelogin.WithServiceAccount(serviceAccount)) + } + log.Warn(). + Str("service", "directory"). + Str("provider", options.Provider). + Err(err). + Msg("invalid service account for onelogin directory provider") + } + + log.Warn(). + Str("provider", options.Provider). + Msg("no directory provider implementation found, disabling support for groups") + return nullProvider{} +} + +type nullProvider struct{} + +func (nullProvider) UserGroups(ctx context.Context) ([]*User, error) { + return nil, nil +} diff --git a/internal/frontend/assets/html/dashboard.go.html b/internal/frontend/assets/html/dashboard.go.html index 01941ea44..914775ba8 100644 --- a/internal/frontend/assets/html/dashboard.go.html +++ b/internal/frontend/assets/html/dashboard.go.html @@ -12,8 +12,8 @@

Current user

- {{if .Session.Picture }} - user image + {{if .User.Claims.picture }} + user image {{else}} -
+

Your current session details.

- {{if .Session.Name}} + + + {{if .User.Name}} - {{else}} {{if .Session.GivenName}} + {{else}} {{if .User.Claims.given_name}} - {{end}} {{if .Session.FamilyName}} + {{end}} {{if .User.Claims.family_name}} - {{end}} {{end}} {{if .Session.Subject}} + {{end}} {{end}} {{if .User.Id}} - {{end}} {{if .Session.Email}} + {{end}} {{if .User.Email}} - {{end}} {{if .Session.User}} - - {{end}} {{range $i,$_:= .Session.Groups}} + {{end}} {{range $i,$_:= .DirectoryUser.Groups}} - {{end}} {{if .Session.Expiry}} + {{end}} + {{with .Session.IdToken}} + {{with .ExpiresAt}} - {{end}} {{if .Session.IssuedAt}} + {{end}} + {{with .IssuedAt}} - {{end}} {{if .Session.Issuer}} - - {{end}} {{range $i, $_:= .Session.Audience}} - - {{end}} {{if .Session.ImpersonateEmail}} + {{end}} + {{end}} + + {{with .State}} + {{with .ImpersonateEmail}} - {{end}} {{range $i,$_:= .Session.ImpersonateGroups}} + {{end}} + {{range $i,$_:= .ImpersonateGroups}} {{end}} + {{end}} +
- {{ .csrfField }}
diff --git a/internal/frontend/statik/statik.go b/internal/frontend/statik/statik.go index 5288edb4f..c80251688 100644 --- a/internal/frontend/statik/statik.go +++ b/internal/frontend/statik/statik.go @@ -10,7 +10,6 @@ import ( const Web = "web" // static asset namespace func init() { - data := "PK\x03\x04\x14\x00\x08\x00\x08\x00\xd8\x02\xbcP\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x16\x00 \x00html/dashboard.go.htmlUT\x05\x00\x01X\x04\xcf^\xecYKo\xe36\x10\xbe\xe7W\xb0D\x8e\xb5\x19\xa4=\x14\x0b\xd9h\xb0\x8fb\x81\xa2 \x9a\xe4\xb0\xa7\x80\x16\xc7\xf2\x14|\xa8$\xe580\xfc\xdf\x0b=\xec\xe8\xe5XV\xe4lP\xac/\x91\xc8\xf9\xc8\x99\xef#9\xcch\xbd\x160G\x0d\x84\n\xee\x163\xc3\xad\x18/\xbc\x92t\xb39\x0b~\xfat\xfd\xf1\xee\xdb\xcdg\x92\xb6L\xcf\x82\xf4\x0f\x91\\G\x13\n\x9a\x92p\xc1\xad\x03?\xa1\x89\x9f\x8f~\xa3\xd33B\x82\x05p\x91>\x10\x12x\xf4\x12\xa67F\x81\xc5D\x05,\x7f\xcf\xfa\xd6k\x0f*\x96\xdc\x03\xa1)\x02\xecnRB\x02\x96\x0f\x92>\xce\x8cx*\x86\x13\xb8$(&Tq\xd44o+\xb5\xa2\x9e\x9b\xd1\xcc\xacv=E_(\xb9s\x13\x1ar+J]\xcd\xceQ\xeeF\xc5&\x0d\xe7r\xfa1\xb1\x16\xb4'\x89\x03\x1b\xb0\xc5e\xd5b\xbd\xc69\x19\xdf\x82sh\xf4\xf8\x06C\x9fX Y\x1c\xa5aPE\xdb\xc904\x9a\x12g\xc3 ]\xaf\xeb\xc0\xcd\x86\x12.SF\x1dX\x82\x8aG@ \xab\xcf\x08\xd2A\xcb\x0c\x95\x06R\x99\xaf\xd6U\xcc.\xb8\xe7\xf7\x7f\xffI(\x1b\xc7\x85J\x8c;\x07\xde1T\x11\xe3ah\x12\xed\x1fB\xb4\xa1\x84\xd1\xe5\xaf\xf1j\xec\x96\x11\xddl\xea\xe3\xad\x94\xd4nB\x17\xde\xc7\x1f\x18{||\x1c?\xfe266b\x97\x17\x17\x17,\xc5T\x00\xcd\x80\xb4\xa8\xc4\x130\x81\xcbl\x01\xecZ\xe6\xc6*\xa2\xc0/\x8c\x98\xd0\x9b\xeb\xdb;Jx\xe8\xd1\xe8I\xd9{\x87\x91~0\x89\xaf\xcb\xe8 \xb3\x9d\xd6\xfc\x0e\xe2-K\n\x9cK\xc9\x9e~3\x89%a!\xb9\xcb\xd5!\x02\xf1J\xcbc\xaf\xcd\xe0\x02Wv\x95\x8a\xc1:\xa3\xb9\x87\xbeW\xe2\xe7!PG\xe4\xb5\x17\xe4\xd70\xder\xda5\xc2{#\x8e\xf7\\\xc8J\xfe\x9c\xeenV\x15\xe4\xc7M\xad\xe57\xac\xdegu\xf3\xf6\x9ac\xc0Z\xcb\x9c\xe5J\xf7\\\xc2\x8a\xd6\xe7IO\x87\xd0\xd9\xf9\x97t\xd4z\xe9\x9a\x90`\x96xo\xf4v\x88\xe2m\x9eHI\x0b\xb6]2S\xe8\xe9\xf4\x16#M\xae\x13\x1f\xb0\xdc\xa8\xee^V\xd4-7\xcc\x8dU\xcf-\x15\x83j \xf8\x0d\xca\xfc\xa9\xf3#\xd4#\xee\x9aE\xfeST\xd7]\x12\x83]\xa2\x03\xf1\x908\xb0'\xae\xb2\xf7\xae\xa8s\xa1P3|>W\xfa\x97\xd6\x1b\xcb\xfb*\x1d\x1b\x9d\xb7\xdc\x1b\xebH\xc85\xf1\xa0bc\xb9E\xf9DJ\x93\x12\xae\x8d_\x80\xcd>\xc2\x8c\x1b\xfb\xe1\x98\xaa\xfc\xa1t\xd3?\xc1h\xae\x8a\\[O\x0b{s\xefpE\x9b\xf6\xceX\xf2\x10\x16F\n\xb0\xf97\xa5\xdfa\xc5U,a\x1c\x1a\xd5\x069\xe6t:\xc4\xe4\x81\xccp,\x93yB;@\xe5\x00\x07\x7f\x07&AG\xa8\x01,\xea\xe8u$~\x9f\xa3\xbc\xe1]+\xdfW\x99\x03\xad|\x17L9h\xe1\xba%M4l*i\xa3\xd6\xdb\xe4\xae\xe4S\x83\xbe\xc1\x12M\xe51`\xf9w\xe7\x80\xe5\x1f\xbe\xb7y\xf8\xbf\x00\x00\x00\xff\xffPK\x07\x08\xaa\xa0\x16\xe1c\x04\x00\x00-\x1f\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00M\x85\xcdP\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x12\x00 \x00html/error.go.htmlUT\x05\x00\x01\xf2\x01\xe5^\x9cUKo\xdb<\x10\xbc\xe7W\xec\xc7s,\x06\xfe\x8a\xa2-d_\x92\x1c\x02\x04h\x90&\x05z\n\xd6\xe4J\" \x91.\xb9\xf2\x03\x82\xfe{A\xd9N\xf5p\x82\xb6'K\xfb\xe2\xccj\x86n\x1aM\x99\xb1\x04\x82\xbcw>)\xb8*E\xdb^\xa4\xff\xdd|\xbd~\xfa\xf1p\x0b1\xb2\xbcH\xe3\x0f\x94h\xf3\x85 +@\x15\xe8\x03\xf1B\xd4\x9c\xcd>\x89\xe5\x05@Z\x10\xea\xf8\x00\x90\xb2\xe1\x92\x96M\x93|c\xe4:\xb4-\xcc\xe0\xf5\xed\x89v\xdc\xb6\xa9<\x14u\x0dM\xc3T\xadKd\x02\x11\xc7\xd0o$\x00\xa9\x978R\xc9\xc4j\xde\xe4\x05\xc3\xc1u5\x93\x86PaYN\x96\xd1}\xcf\xee\xab\xdd\xdd\xb4-\xa4+?>\x0b\xe0\xe1\xc8:\x16\x7f'\x1f\x8c\xb3c\xfdM\xee\x8ca`\xf0\xda{y}L\xe5\xe1\xb2O\xe5\xe1\x1f\xe8\xa4\x90_\x01\x00\x00\xff\xffPK\x07\x08\xd6L6ob\x02\x00\x00\xb2\x06\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00\xd8\x02\xbcP\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x13\x00 \x00html/header.go.htmlUT\x05\x00\x01X\x04\xcf^\x8c\xcd1k\xc30\x14\x04\xe0]\xbfBhN,Z:F\xfd\x05\x9d\n\x9d\xcb\xab|\xb1\x1f\x95\x9e\x8d\xf4\x9c8\x18\xfd\xf7\x12C\xc9\xd0\xa5\xe3\x1d\xdc}\xdb\xd6\xe3\xcc\x02\xebFP\x8f\xd2\x8d\x9a\x93k\xcd\x9c2\x94\x8c\xb5B\x19\xc1]\x18\xd7y*\xea\x8c\xb5q\x12\x85hpW\xeeu\x0c=.\x1cq\xdc\xc3\xc1\xb2\xb02\xa5c\x8d\x94\x10\x9e\x0e6\xd3\xcay\xc9\x8fb\xa9({\xa2\xaf\x84 \x933\xfe\xd5\x9c\x12\xcb\xb7\xb1\xb6 \x05W\xf5\x96PG`\xe7\xf46#8\xc5\xaa>\xd6zo\xc6\x82sp\xdb\xd6\x93\xd2\xc7\xfb\x9bu\xbe\x9b\xa7\x8c\xc2K\xf6T+\xb4\xfa\xfd\xc2gb\xe9\xee\xa3\xd6\xfe*\x1c'y\xfcs\xa6\x01~\x96\xe1\x9f\x00\xe7\xc1S\x8c\xd3\"\xfa\x19\xb9\xc4\x84\xe3\xf3\xcb\xbcv\xf52\xfcj\xdb\x06\xe9[3?\x01\x00\x00\xff\xffPK\x07\x08`}f\xf1\xde\x00\x00\x00c\x01\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00\xc7\x02\xbcP\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1b\x00 \x00img/account_circle-24px.svgUT\x05\x00\x017\x04\xcf^<\x90Ao\x830\x0c\x85\xff\xcaSv\x8eq\x9c\x90\xc2Tz\xd8.\xbb\xec\xb4_0\xb5\x8c \xb5\xa5\x1a\x88T\xfc\xfa\xc9\x80&\x81\xfc\xf9\xe5=K\xf6q\x9c;\xcd\xcb\xeb\xa5\xd1\xf5/\x00\x00\xff\xffPK\x07\x08\xfc\xc6x\x8f\xb7\x00\x00\x00\xf9\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00\xc7\x02\xbcP\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x10\x00 \x00img/pomerium.svgUT\x05\x00\x017\x04\xcf^\xc4UMo#7\x0c\xfd+\x84{i\x0fz\x16I}\x16\xeb\x1c\xfaO\x06\x89\xe3Y\xc0N\x16q:Y\xf8\xd7\x17\xa4\xc6N\x8a\xdd\xf4\xd4\xa2\x08\xc2\x91\x1f)\x0d\xf9\x9e\xc8\xf9r^\x0e\xb4|\xdd\xbf\xfd\xf1\xfc}\xb7\x89\x14\x89\xb5\x93\xc6\x0d}?\x1d\x9f\xce\xbb\xcd\xfc\xfa\xfa\xed\xf7\xed\xf6\xed\xed\x0do\x8a\xe7\x97\xc3Vb\x8c\xdb\xf3r\xd8\xdc}9\xd0\xeb\xcb\xf4t~|~9\xed6\xbe\xe9\xbe\x8d\x9f\xe1\xe5\xcf\xe3~\xb7\xd9/\xfb\xa7\xe7\x87\x87\xcd\xd6\xaaZ\xc3\x1e\x1f\x1f?\x96P3\x12IA\x9190R92X\x83\xa0\xf6 \x90l\x89\xf4\x99\x91\xd3\x91-\x01E+\xc4H\x1a\x14\xad\xba\xe3r\xb2 \xb6\x04\xd3\xb4f\x99\x84\x9cv\n\x8c\x1c\x90\xf3\x02\xad\xf6\x82\xccKhHi\xf6\xa5Z|#7#~\x0d\xbfgd\x83\x04\xc5^'m\xac\x8c\xa2s`\xc4f\xcb\x16\xae`\xbb\x9c\x02TBBk\x13\xa34r\x13\xfd/0\xb8!\xeb\"\xc8b\xdeLn\x86w8\xd3=Z\xf6_)\x05T\x1d\x0bF;\x07\xe4\x1e\xd8\xe8X\xa1\xda/\xa7\xd0\xd0L<\xcb_-7m\x1f\xea\xb5\xfc\xedY)z\x86\x81!u\xac\xbc\x00F\xecC\xed\x1bf<$rs\xe3\xc1X\x0bjD\xc9\xd2\x90\xb2\xd3'\x97S4\xea\xad\x0e\xcb\xb2\xe8\x87*k@\xd6\xfb`\x12EK7\xa3\xaa?\xc9\xd2>#wb\x187\x8e\xf8\x19v\xc4;\x13v\x80\xe9\xd9\x9d?\xf3\xfb\xaa\xdf\"|s'E\xce\xc3\\\x1d\xb5\x04\xe4\xb20\xa4L\xc9\x08t\xb3r\"\x84TH\xe9\x06\xd8\xeda%A\xab\xc3\x0c\\\xd0\x9a\xd1\xa1Cm7WO\xee\xa4\xd6j\xab\x19p$\xe4r91\xd4\xf8\xd54\xb1)\xc1W9\x9c\x15\xad\x06\x94Y\x90\xeb\xe4W\xc9\xcd;k\xa6\xac\x16\xbf\xc5\x9a\xd0uQt\xe7z \x19}\\T\xe4> \xc9M\x9c\x94\xc3\xca\xcd\x87\xcb\xc4\x84\xd4\x11\x8d\x05\xcd\x13\xa3\x99@\xed&\x10\xb2\x06D\xcb\xa6\xad\xe6\x96C2\x02/\xa7P`\x9e\xc4?a\xbe\x96\xff\x8d\xfa\xf2\x1f1/ze\xbe\xa0TJh\x06H\xb0.\x13J\xa6\x81\x1e}\x16\x0e\x15l4uJ\xe4\x9a\xd9\x04\xd1\xfa\x03\x94\xf9\x18l\xf6Z\x93v\x88\x82m&u\xeb\xad\x9e>r\x10\xfb\xca\n\x13S1]\"\xfd\x18h\xb8\xf1e]\x97\xd0\xc5\x9a\xab\x8d\xe6\xea\xa8\xd9\x9f.\xf2\x19\x85i\x95|\x85\xac\x875\xb8\xa2Y\xc7\x08A\xb1-^\xa6CMG\xf1\xe2\xf7\xc1\x0b.\x8b\xa0\xb3\xcf\xe2n\xa32\xd6\xd9d\x9a\xc4^\xe0f\xa4l\x9f\x19\xb1\xb1'\x12\x9c*N\x8b\xa0\xf1l\xab\xbf\xa9a\xb16\x88j\xbe|\xf6qx\xff\xa0\xa9\xa02qB\x9f\x14]\xc9\xcd\xaa\x98\xad\x837\x87&\xf0'\x01\x06\xc8\xc2\x05\x9cf;k \x99\xa7l\xac\xb8\x19\xe93S\\\xcc3\xcb\xbf\x11\x81T/\xa7\xa0\x1d\x9a(\xe1\xe7\xa1\x97\x13\x0bz\xa6\xf8\x0f\xde\xf4\x99\xd7X\xda\x1e\xd6\xff\xf3r\xb8\xfb+\x00\x00\xff\xffPK\x07\x08K\xfe\x8b#M\x03\x00\x00d\x08\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00\x1b\x85\xcdP\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1a\x00 \x00img/pomerium_circle_96.svgUT\x05\x00\x01\x96\x01\xe5^\x8c\xfa\xc9\xce\xebJ\xb3-\x86\xbe\xca\x8f\xbfu/\xe4s(R\x14Im\x9fm Y\xd7u\xdd\xd9`]\xd7\xa4\x92\xd4\xd3\xf8Y\xfcd\x864\xe7\xda-\xc3p\xe3k\xac\xc5\xcc\x8c\x8c\x88\x11#F\xa4\xe6\xff\xda\xde\xd5\xbf\x9a\xfc?\xff\xed\x17\xd9>\xad\xff\xe5\x0c\xc9\xba\xff\x97\x91\xb6E\xb6\xff\x974$U\xf1\xef\x7f\xe5\xc9\x9e\xfc\x8f1\x19\x8a\x7fV\xfd\xeb\xb7\xea_\x7fV\xfd_\xff\xe7\xdfe\xe7\xd0\x8f\xdb\x7f\xfe\xbb\xde\xf7\xf9?\x10\x04B\xf8?\xe1\xe3\x7fNk\x85`\xf7\xfb\x1d\xd9\xde\xd5\xdf%\xffq\xf6\xcd\xd8\xfd?-D_\xaf\x17\xf2\xfb\xfa\xef\x7f\xbd\x9b\x02\xd2\xd3\xf9\x9f\xff\xbe\xff\xeb\xfe/\x0c%\xbe\x7f\xff\xfe?\xfeW\xf3\xb5\xf5\xff~\xe1\xff\x81\xfd\x7f\xbe2l\xf2\xbd\xfe\xcf\x7f\xbf\xee\xf7\x7f\xff\xab.\x9a\xaa\xde\xff\xfe\xc7\xbe&\xe3VN\xeb\xf0\x9f\xff\xde\xb2\xa4/\xfe\x7f\xf7\xff\x89\xe1\xff\xff\x7f\xff\xebw\xb9\xff\xa8\xd7\xa2\xfc\xcf\x7f\x7fm\xfc\xc7\xef>\xc8\x8f\xceIw\"\x7f\x8a\x1d.?\x8aWA`/\xbf\xdfE\x1b\x16\x8e\xac\xfa\xc9\xd0\x02\x0b\x13\x1c\x1d%\xe2\x15\x11a\x1b\xb1hea\x80\x8d\xf4G\x82\xe2\x11\xb0p\x91\xde\x13\xca\\\xc5{>)(F\xc6\x13\xb6\x0c\x01\xb1\xc19:\xf44\xe4+f}c\xdc\xb1\n^\x00\xae\x03\xc8\x19sa{\x11\xa2+qc\xc4f\x7f\xad\x1e\xbf\xe7%\xa5\x87\xc4m\xe2\xe5\x9e\x8f\xa5n\xf7\x9b\xb4\xf6\x19\x07\x05\xf5\x0c\xeb\x14\xde\x86#\xfd~\xcf\x8b\x87\xe8\xe61\xb1:\xcf+N\xf9!\xf0?\xaf\xa12\xb5\xd5\xbc\xe3\xf4\xb39oG\xe1+\xa3A\xdc\xc4{\xe7\x14\xd3\x87!\xb8n\xf1K\xa7\x06\x18\xc03\x16\x83b\x82\x8a/\xcaH\xb5.\x8d\x8b\xd7\x0e\x15p\x94\xd5\x1b\xfdxs\xa5\x0f7\xef\xecG\x10\x0b`\xd1\x94\x07\x86?m\xa5\xcf\xfc\x1c\xb1\x12GV\x1e\xba\xd5\xd4r1k\xf7(\xd4+\x1b\xa3Gk\xe7\xd3\xb0\x9c\x83\x9e\xa2\xf5\x83<\xa3\xc0e\xca\xa0\x1d\xbb6\x95p!\xae$W\x99\xbd!\x7f\xa3s\xact\xdb\xcaYN/\xc9\xb3\xd5\xa7 \xdd,g\x7f\x19{^~\xff\x92\xcf\x0b\x9b\x03:\xb2\xae\x83\xe7psL\x92K\x85\xa1\x12*\xce\x0b\xcd\x15\xae[\xc4\xea\xa8e\xda\xf7\x86\x96\x91<\xd3\xc5h1b_\xcb\xfa:X\x03\x96m\x11.\xeb\x15\x00\x8d\xa2q\x9c\x1b\x93\x96\xba1\xeeY.\xa9r\x1dp\xeb\x9d\xb2\xef\xa4\xd8\xe9Fns\xe9ZW\x1e\x07 \xc4\xd5+s\x98ct\x9e\xce\xcf^\x99\xe7g\xc4r83&\xc4mL\xda\x97\xb1\xf2v|\xcf1\xbe[\xecm;i\xc5\x9b\x88\x8e\x897\x9a\x84\"F\xbf\x93\xf9\x1d\xa6\"\xbe\x94A\x00p\n|\xa0`\x02B\x03'\xb1\xfb\xe2\xf0\xc4\x88\xads\xe2\xbe\xfe\xdal\x95\xe9\xf5$\xb3\xf7\xc6#\xfb\xf9\xb8\xcb\x02\xb9K\x81\x9b\xf1\x02\xbf\x93\xa6\xa5h\xaf\xb2\xe7\x9cl\x1a\xbe9\x8a=&\xd9X\xa2\xbe\xd1\xa8U\xdc\xeef\xaa\xbfw\xdbWn.(\x05\xa0\xe8\xcbz\xcf\x16\xdf'w\xa4\x96eo\xd1c\x86 \xe6+W8}S?\xa5\xa3/+\xd4d\x81|\xe1\xe9\x8c(1\xe3\xd7\xf0\xb9\x88\x95.\xa0(n+\xbc\x95\x0d\xddns|'\xcb\xc9\n\xee\x91S\xd7\xe8\x81X\xd8D\xb5\x14\x03\x91\xc55?L\xe6C^\x04\xa5\xa6\xccN\xec\x8d\x8f\xfe\xde8\xb7Mg\x14\xaeG\xfb&\xde\x18\xfad\x14\x89\x11AA\xf1\xf4\x98\x93]\xe2\x1a+\x00\x05}\xe05\xaf\xb9\xa4\x82zA\x1e\xdedY\xe9\x1e\xaa\xe48(wCG\xf7\x15\xf6\xe4\x8be\xd2\x03\xb0\xc7\x07\x93\xba\xfe\xb5Y3\xde\xf3\xc0 /\xce;\xc51\x0f/Y&&\x82\xf7d\x99P\xbcGT\xf1\xec\xcd\xeck\x92\xb4:\x8d0\xa3\xc6\x89\x1e\x96;V\x06\x95\x91 \xf1|*}\x16\xfe\xf6\x04\xb3\xc1{\xb2\xd4\xdc'\x87\xae\xf9;\xd2^\x92\x07\x81\x0e\xc1Y\xcd\x0d\x1a9c{\xa38\xb2\xf2\x05\xde\x98\xb7e%\x9d\xcb\x99\x1f\xcc\xc5u\xbb#\xeb\x02'\xe2\\[A\xdcr\x0en|\xf8\xf7V\x99\xf8\x02\x188\x9dV\xec\xb6\xf8N\xfc\x10\x9fEG}1[+\xef\x0f\x94\n\x96\x86lI\xbb\xd1\xbb\xdcV\xd9\x08\xad;\xc5\xe8\x10<\x00\xa0\xf0\xc2\x99\x15\xde\xe3\x15\x19u\x13\xf6f\xb3P\xcbd\x0d\x17\xd2<\xbc\x84\xd9\xe2\xf1\xe1C\x7f\n\xa0\x9f\xc9R\xca\x97\xcc\xbc\xe4:\xb0\xea\x9e\xb7\x96$\x93\x8f\x1a\xc88+\xf0\xc2]\xc8h9\xb9s\xe2\x02\xb0\x02mf\xaa\x83\x9dz\x1f\xf8\xc8\x11<_\xa8gX\xb1\x95e0.\x14E\xe0\xeeh)y\x1d\xc5\xa2P\x0d\xc1\x9d\n\x94\xd1\x11\xb7^\xf5\x86g\xcc0\x9e\xef\xce\x13\x14\xd8&\xf3\x80b\xd2i\xba \xd5,\x13\xbb\xb4Y\xed@1k\xb3z\x83\xff\xfc\xde\xe3\x08\xa4\xef=\xa4$\x93\x08H\x8b\x80\x89\x9c\x18r\x18\xf9\xbe+N1\xdco\x93}\x16L~\x1eFl{4\x1d3N\xda\xfb\x03\xec(\xf0\xae\xee\x93e\x13\xba\xe4tq\xdf\x03E*B\x8eZ\xeb\xa6\xab\x8b\xaeUe\xab\xa9f\xff\xf5\xc5\xa6!\x1d\xfa\xcb\xb45\xb8\xd7/\x0e\xb5\xd5\xb2\xba\xa8\xdd#b\xbc\x86Vo*M\xc5\xaa\x17>\x0c\xfa\x0d\xd9\xd1_f\xad\x95\xbc~q\xb3\xdf:\xa9\xbbG\x0c}\xaa\x8a$\x8b\xf8j\xd0]o\xbf\xc9\x8c\xdd-\x10\xe0k/\xf7\xbec\xbd\x1d\x9f\x0b\xacn|uM\xbcYNC\xcfPM\xedCqfs\x89\xdc\xbe6\x82\xa5\xf7\x06[S\x1dT\x0e\xbf\xb7CV\xe0\xa4\xb1\x96d\xe6)+S$V<\xc5\xea\xa8\xb3\x18t\xe39\xdb\xc9(\xf2\xb7\xa7\x81\xe8\x06\xce:)\x9a\xa2K\xabn\x89\x9b\xaf#\x9c\xba\xa0^`s\xaasM\x05C\xf0\x9e6\x8c\x0c\xa7I\x83\x96S|>\xb5\x9c\xec\x8d\xbd\x06B\x9c-\x99\xd1\x99\x83~K\x99\x0e_\x81\xe54\x81-/\xdf\x1c*#x\xed\xc2\xf7\x8e\xd9<\x1b\x01\xb7\x12\xe5\x19\xb3\x7f\xefG\xcf\xd6\xb8\x02\x9f\xaaU\x80\xa1\x0c\xb78\xc5 \xe9\xe9\xb2(&\xd7N\xd9\xf7Rl)K\x1fq\xef\xfb4U\xfb\xce\xe1\xb3\xd4\xe5A4\xe0\xf6\xc86\x9bo)M\xa4N\xbe\x05wG\x89\xb81\xd2\x1f\xce\xe4\xf4\x973\xcb(\xd9\xb4\x81\x82\xcbt\x12\xf44\x15XKW\xdd'\x1be\xb8\x10\xd6\x14\x13|}t\xc2\xfb\xc9 \x15\x9fqD\x7f\xbd\xe5(\xe0U,\xb0\x9a\xfe\xd7\xa3+0\x82\x0f\xc5?\xf6\xe7\x1e\xc8Jw\x1b+\xfb\x06z\x98%\xa1\xb2\xf4u\xe4\x8dq\xcc(A\xb6\xd4\xf7 y\x88\x95\x89\x8b\xf9\xb7N\xbaz\xad\x93\x82-+e{\xe1<\xa9\xc8\xf0\xa8{N\x9fa-\x82\x0f.\xbe\"\xb4\x97\xbby\xad\xa2\x02\x88\xbd3?NIq\xb6\xcdg\x14\xc3\x97\x86\x15 x>\xe8,\x95\\]\xe3\xa0P~\x00\xf6\xda}{+\x98nUi\xabi{K^\xa0\x86\xf1\x8f\x1d\xa7\xd84\x16p\x99\x1bv\x14\xf0\x19x\\oo\xf0\x1b\x95\xb9\xe6\xdd\xb98oJz&\xdeX\x13\xb27\xc6\xafFn\xce:\xc5;\x02\xf0\xa0@\xd9\x13\xb3\xd6fA\xbf[ \xdfIs\x85\xcd\xa2#B\xf6F\x0b\xd0q\xac\xc3\xf1\x95@u\xe8\xad2#\xb2%\x16k\xdbrF\x11|mx\x03\x19\xbeUk\x0c\xf4\xd8\x89\xe5+fTh\x16 \xc9/\xc7\xdf\xbe=Z\xf0\xb9a\x04 \xa3c\x181pu|\xc5h-\xe5\xe6'\xdd\xa5\xd4\x89\x87\x8aU`-#\x7f\x9f\xdc\x1a*7\xf0\xb91\x82\x16+]vT\x00\xa9\x92,\\\xbaK\xb6\x93\x00e\xbf\xebz\xfe\x9b\xaf*\xa4\xfa\x94\x9e>:\xad\x84\xb22Hacd\xcbc\x0f\xe6l^\xbfX\xbb\xad\x9a\xe54\x98%\x8f\x97\xb6\xb1\x1f\xef \xad\xde\xb1V\x93G\x9e\xeb\x9c\xcd\xe8\x96|\xd7H\x96\xd3\xa1\x9eqG`j\x80vK`\xe78\xfe\x9dA\x80\xfe\xb9n\xde\xf0\xf5\x8f\xf5\xe9a\x05t\x14\x8c\xd3\xfb&?\xab\x1f\xce\xb2gd\x87\xfa\xe7\xda}~+\x99\xee\xbd\x8aVS\xa1\x81\xbc\xc2 \x93Nn\x7f\xf3\xdb,uD\x18\xbaUA1%bD\xcc\xd2\x9f\xaad;\x15a\xc9\x07\x94\x11\x00\x083v|\xd9\x9bs\x01\xf4\xb7\xb7/w\x16s_\x17\xd3:j_\xc8gX\xb3Un\x00-\xe2\xaaN\xed\x06\x07\x9f#\xbb\x1dZ\"NBe\xfb\xe2l{\xc5\x8c\xe2\x11N\xad\xdd\x80 m\x8c\x0d\x82\xde\xb1\xe5\xb7\x1b\xd17~\xe9\xaf\x1e;'\xc5)6\x9bV\xf2\x18\x0c) )\xfd\x01.\xb1\xc1\xdf\x8e/\xb5\xb9\x05B.\"\xe6\x81\xdeT\xe7\xb6\x14\xb5\xcf.\xf5\xc7\xa9Y\xb7\xe2o\x00\x83\xc57\xdf\xb1\xba\xc4' \xc5\x020\xfa\x13\xb3o\xdb\x93QL\xdfNs#bF:R\x99\xfd\x8b\xebA\xa1\xd3I@m6\xb3d\xba\xc2\x99\x91\x80\x80\xf0\xcd}s\xe6\xb8\xf7\xbb\xf8\xae\xefs\xb6\xe0\x01\xcf*\xdc\xb3\xd0\x96\x9euj\x85r\xd0\xf7\x99\xd2\x8d/{\xedc\xa7\xa7\xebh\xdd\xa0\x83##x\x92o\x05\x97J\xd87\xf6\xf3\x8b\x8d/!\xa1\xe5d\xc12\\2\x9dx\xdb\xf6d\x16\xfdT\xb9G\xc4\xaf\x95\x8b\xb5\x82\xd7\x7fk\xe5\xc9\x8dU\xcd->\x95\nU/x\x87\xe0\x07\xc7lu#\x10\x0c\xc8r\x8b3\x1bi\xd2\x91\x0c\xf7\x92\xec\xc8\xd3\xa6+\x86\x8d\xef`#\x13iJ\xbd%\x7f0\xe6X(\xa6\xbfA\xce\x92r(\xf6\x9d\xd4\xbb\xfb\x0c\xdf)xf\x80\xfes\x17\xc5m\x19u\xb3\xda\xa9\xe7g\xe7:\x99\xa0\xbdf\xef\x8e{&\x90\xd0J\x90\xbd\xde\xd9k\x08|aoy\xa7{\x8c\x0c\xef\xa9\x8b%\xa7\x19;C\x91\x02\xe0\xd2O'V:\x7f\xe8\xc4\xb2r\xa9\xa5W\xf9*\x8d\xae\xcf]\x99>\xdc\xe6\xa2Uyc\xcd\x93\xfd\xd5\xf6l\xd9=4\x91@\x19.\x83N\xbc`\xeb\x99\x85\xfb\xf9\xcd\xadU\xfa\xa7\xc6b\xe9\x8a\xeb\x0fl\xbc\xc5wR\xa1B\x99\xe9f\x11\xad\xf6\xe5%\x87\xad\x1d\xd8J\x93\x13\xabW\"\xbc*W\x9e\xa1\xcd\xc0i\x8f\x16\xde\x93\x07O\x1eaz\xd0^\xc4o\xbb\xd25v\x8f\xb1l\xd5\xefkdoS\xd6\xe3p\xf9j\xe4W\xc4\"\xe0N(_\xae\xecFu\xe0\x0eV\xed\x9d\xe5}F\xb2\xb3\xa1\x95\xd4\xdb\x03J\xc1\xe0\x0d\xcaD\xff\xb3F^T\x93=\x9d9\x98\xb7\x84\xf5X\x06\x8f}'\xe9\x18+s\xc6\xba\xb8\xff\xf2\xc0\xa8i\x8f\xd0\x19\x1e \xe7\xa4[\x1a\xdf)h(\xcfPj\x81\x831\xcb\x8fG\x95\x89\xd9+\x99\x9f\xf1F\xbb\x92\x16\x0b[e\xee\x93\xa9\xd4\x11XB@\x80\xfd\xdb7\x1dgI\xd8\x83 \xa1DQ'O\xecQ\xc3{\xce\xcb\xc2Z\xb2\xb3n\xbc\xb5)\xf7\xc1\xf1E\x1a\xa1\xefOl;\x87\x9f\xe6v\xe3lH\x81\x9f17X^\xf7\xc5\x91\xe5\x90\x13\x0c\x06w\xe6 \x8c:a\x1e\x9b\x8bK\x96\xe9\x1eYHe\x0f\x8c\x18\xf0\xc9\xb7\xee\xba\xb9bE_\x99(\xc1\xda\x95W\xa1\x0c\xda\xb0$\xba\xc1K8\xe0\xb6\xea)uN|\x8f\xd9\xfd*\xe5\x11N\xfc\xec.\xa7\xa7LsW\xb9n#j\xac\x80g\x7fmE\xe7\x06\xf4=\x15\\T\x04\x81\xd5\xfa\xd9\xf0\xa6Y\x9cA\x00\xce\xd7A\xd7\xd82\x0b\xd2[\xd7\xa4\x94B'\xdem\xdbGF\xf7`\x92\xc9\x00\x17o\xb4\x87}\xb9K\xea\xf2db\x10\xde\xe8\xafe\x8eJ\xb0\xa5\x82%s\x83\xe3\x0b\xa5\xc8mIs\xb5\x9c\x1aED\xcf$\x19\x1b\xe2\xef\xfe\xfbm\x06\xe93\x18Pm\x99\xcf\xab\x7f \x99S\xeb\x06hp\x06\x1b\xfe\xd4\xcb\x12qD%k\xab_\xe2 s\xcd\xa4*Y\xcf\xa7&~\xbe6\xaf\xba2\xd7\x8d\x9a\x19L4f\xd0\x93\xe0\x9d\xb1\x02U\xfc\xf8\xcc\xab\x8d\x17\xa8Og)\xe5NU\xdam\xa3\xd9\xc110\xaa\x87\xe5Y\x80^\xfe\xf6\xe5\xfb\xdde`\xe9\x7f\xa2\x8e\xef\xab\xe7[yrN\xb2\x81'd\xe3\x98\xfe\xda\x8e\xf3\xd2\x80l\x80n\xc9~AN\x89\x00\xd63\x02B\x8f\xb8B\xfft\x9a\x13,g\x85\x03\x04\xa5\x12x\xb5\x95\x1a\xb9D\x06b\xe81\xdf\xb8s]\x9c\xd9r\xcd*\x87F\x9d#\xd3r\x9bkVh\xd6l\xb9\xea\xc8R\xa7\xf8\x14Qi\xaf`\xe9a\xc0\x103\xaaJV\xdc7\x89\x06\xb8\x88\xb1\xfe\xf2\xad\x12\x19\x01\x90\xf2p\x99.\x83\xce\xbd\x84\xdf\x14\xcd\xa9\x0d\x0c\x10+\xe8Wa\xfb\xc6\xaf\x17\x84O\xa5\xd8N\xecO\xdbb\xa4\x1f!\x93\xbek\x04n\x8c\xbc\xdd:\x95\xae\xb1f\x92\x8c\xb8u\xc1\xf4,\xf6N~\x93\xb7\xa5b\xc9\xd3S~\xf5\xe0\x1eP\x1d\xe9M]\xcan\xab\xe9\xc1\xfdj\x97\x1b\x0f7\xf9\xeb\x83\x11jN\x1a\x9ck\xabr1\xac>2w\xc7\xc1Z\xf1\xc2?\xf8s\x87;\xf3\xae\xe1\x9cM\xf9\x960 \xbc+\x11\x17\x8cL\xbb\x81(\xe2\xab?\xf83\xc2\xc8\x14X\xc4\x99}yK@\xf2\xb9\x82V\xf0i\x12\x9a\xf1\xa2J\xc67N\xcb{\x10\x81\x1e\xe0W\x07\xef\x01\xacc\xf5\xdb/(\\xI(/w\x8d3+f\xc4|\xeb\xb9\xce\xfdo=\x07\xc6y\xc7\xa5_=\x0f\x0esD\x1c.\x9d\x91#=z\x0d\xb7\xf6\x9e\x892:\xbf\xa4\x81\xaa\xa5\xef\xde\x91\x8f\xe8\x13u\x96\x07=\xa9K\xcbm5\x839\xf2\x00M\x13\xc8\xdb?\xd8\xa5\xddH\xc4\x94\x97\x13\x07\xd3\xb6\x98\xf3\xe7\n*u\x06\xe3\n\xe4\xbc\x16b\xabsb\x05\x03\x0c\xb1\xb6\xb1\xe8\xc7t\xca\xa0\xefJ\xcf\x04\xf0\x8b\xffW\xe7T\x91un\xf3\xaa\xc9\x89\xae\x7f\xb9\x02\xf0\xb0\xb8\xfe`\xa0\x7f\x81\x9b\xaf\xf4%g-S\xc9`\x9c;[\x03 *\x8a\x15\xfe\xf6o\xaf\xeav\x99>\x13\xf3\xe2\x02_\xc4\xf0X\xb5\xe5\xa4\xca\xdfH\xfdP\xa7/\x1f\xef\x8e\xdfF`5S\xe7V8\x17\xf7 5c\xe3\xb2\x7flg\xe3\xe4\x9c\xdbs\x05\xd6^q\x89')#\xfdQ\xbf\xd8q,\xb5z\xb2n\xbd\x90\x82u\xdb^#Co\x9b\x9bW\xaa\x00\xb0\x8d\xa7\xfe\xf8]\xec\xe8Z\x1b\x99<\xe2\x90G\xc3\xd2\xe0\xd8\xd8J\x8b\xc7g\xf6\xbfz{pf\xfd\x99\xb0H\x93~N\x0d\xeb\xa7\x00n\x19\xc9w\x9e\x0b\xa4K\xa0i\xcc\xf5\x9b\xcf\xba\xd9J)\x9c~\xfc\xde+\xacP=\xe9\x91\xd9\x91\xe9\xfdOL\xef\x16l[3@\xe9!\x93\xd0'\x18J\xc0\xe0\x1cz\x96\xea\x97\x0fh\xdf\xa2\x1f47g\xab\x14\xa8`S%\x0bM2\xc9\x8a\xe8\x0f\xfc\xf4\x7fc:\xe0/\xe6v\xbaK*\xdd\xf9\x06\xb2=~\xddp+\xff'.Mq\xe5\xd0nN\x8f\xdfp\x0b\x0c#\xb3nl[h\x17\xf7\xeb\xe3 U+\xf1\xd7\x8fL::\x8e\x17kp{\xa9KJ\xff\x97/T)\x02\xf3\xa7\xc5\"+\xb0&\xd6\xc4\xe7\x81\xfd\xd9\xdc\xbb\xcc>*f\xf6\xd3EN\xf4\x19V)(\x046\xfd3\x07\xf5\x0e\xeeA1_v/\xb0\xa1\xca\xf7a\x1c\xc3\x1ai\x04\xdf\xfa\xeeU\x96}gb\xac\xda\xb9\x89\xb4\x9e\xd5\xf0\x9d\x87\xaa\x8c2~\x18\x0d\x02\x8c\xd5\x9b\xd5;\x82g\xa02\xd2\xd5\x9e\xec\xcd\xbc\xfd\xde$d\xae\xefu\xdc\xb5Xo\x99{\xc9\x83\xae[ \x06\x93\x04\xdde\xcb]o)'a\xc9\"~\xf0\xcdR\xdf\x9d{\xea\xd6b@\xcb\xb8\x90G\xfdO\x1b\xc8s\xc91\x9bx\xf4\xd7.G\x1b\xd7*\x06g\xe2\xa1s\xfd\xfc\xe9vr\x13#\xe7\xf0\x98\xa5\x1f\x1a^\xe3\xf0\x88\xfd@\x9a\xfd\xbdu\x7f\xf7\xa6\x1aW\xf0E\x7f\xcdv\xb41\xad\xa2w&\x9e\\\x7f\xf7\xb6\xd3\xa3\xd7A\xec,\xda]\xc3\xed\x19N\"\x00w:\xfd\x83-g\xde\xd9\x05T\xd7\xee\x8b\xd3\xc47\x04\xf0j\x97\xd6\x13\xa0\x15\xa0K\xff\xbc\xfb\xc4\xb2\x92\xc2\x13Z\x8a\xaf\xe0\xd9\xee\x04q\xa2\x8dS\xfd\xd3\xf6\x8e\xaf\x9a$\xb8e<\xd8|\xab\xe0GF7\xb8W\xf4\x15\xa7\x7f\xf5J,/s\xa5\x90~\xf2\x9d\x87\xc2\xcf\n\xe0\xa7\xd6\x11Nk\xea\x9f\xed\x9c\x8c\xc4w\xed\xa9O\x1f\xabc\xd8\xba5\x83\x81&\xa3\x85\xbfsR\xb7\x06^\xbdqG\x7f\x1dg\xa4)7\xc5\xecL<\xfa\xc7\xaf\xf7S\xf0a\"h\x1c\x8a\xcb\x08\xe84\xb6\xf9\x83\xeb\xc5:V\xe0b+\xef\xc4\x1cx\x0f^\xcc\x8f\x8cx\x93c\x9f\xf9a\x00\x81\xb0\xc9\xa5\x9dQ\x0eK\x8b\xe2 V#{LRi9O\xa9s\x8a\xe5\xcd\x1e,p\xe6\xd0\xde\x02\xb6\xe0\xc5@\xa0\xe8\xbf\x98m\\\xdf>\x8f::m\x03\xb7\xe6z\x18\x01\x97\x89\xd5\x1f\xfd\xc2\x0d\xafLl\x92L\xb0\xb6\x9e-\xf8\xa0U\x12y\xb5\xec~Z\x8c\xe6\xf7\xb6%uK\\,\xd0$|e\xf2f\xcf\x0bR\xa9\x84x\xdf\xf1\xbf<\xb61\x07N*g\n_\x89\x84L\xbaG\x0e\x80\xec^\xbd\xfe\xf0\x97\xd2\x99\xe3\x94`\xea\xee\xc4\xbe\xb7\x0d \xe7\x87\x86\xa3\x84\xec\xa8~u\xae\xa5\xc9\x98\x83\x17\xb3p\xd6/\xcf5]\x81\x95\xfe\xf3\xfe\xd4\xd82RZ\xcd&\x1c\xfd\xb5\xca\x91f\x1c\x8a\xd6\xe9\xb82\xff\xe9}N\x86\x9d\x80\x10\xd8'O\x1e\xbf<\x98\x01\xe0q\xb0\xff\x83\xafe\xd6c(\x11\xbe\xd2\xed\x0dt\xe0\n\xabO\xbd\xfc\xed\xd92\x1b\x86}mW}\xcb\x86\x1a\xee\xa0X\xc6\xcb\x90\xff\xdb\x93\x1a\xd7\xc7\xbc\x83\x0b25\x8c6a\xbb\xde\x9b\x86\xfb\xc5\xf9\xb7^\x966yH7\xe7\xac\xd3!\x93\x12\xd2`?PO\xff\xcc\xbf\x83\x832\xccslB\x8d\x82\x9f\xda'\x81\xf6\xa7\xfe\x96}\xb5\xaa\x95\x7fhs\xff1\xb8$S\x88Z\x1f\xe9\xf4\x9f\x1e\x15,P\xc8X\xa3\x88a\xff1~\xb6\xfe\xd6\xe6\xa8\xd4\x03\x06T*g\x82\xd8!\x85LMH\x83\xe6\"}\xff\x8bS%\x89\xd9\xb9\xeeGzV\xe5\xaa/\xbe\x84\xc0e\x7f\xdf\x83\x94+\xf6\xf7\xcb\xe0\x8eh9GF\x178\x0b\xcfG\x1aO\xff\xe8a\x99\x0dc^\xf8\xfd6\xe7o\x01\x1d<\x05L\xa08\xeb\xbf\xf7&nSo7[H\x87H\xba\xe3\x16\x86\x83\xbej\xff\xf6\x9b\xae_;%`\xde\xce\xecg\xbcr\xa7>6}\xe3\xb5\xbf\xf9\x14>i\xf5\xa2\xf3\xac\xe1\xcd\xbad\xc9-\x02\xbf\x9a\x883v\xde<:x*\x925W\x9d\x08`)f\x7f\xfb\x08\xb0\x8a\x9e\x89J\xc1\xea\x07\xab\xb9+\xd2\x86\x1b\xbfXR\x8d\x17\x8aN\xfa\x19\xa8\xa5c\xc2\xcd\xd2g}n\xb0?xT\x1c\x8d\xa8\xc4\xfb\xe2\xc7\x84D\xfb\xdb\xcc~\x0c\xeeAi\xbf9Mf\xfa\x12\xab\xea\\\xde\xb5\xcb\xf4\x8cH\xae\xe0(r\x16\xa2\xfcf\xe3\xc1\x99\xa53v\xdcf*\xbbi\xc3\x989\x1e/\x91\xa2\xff\xc1T\xd9\xdd\x1b\x9d\x9e\xb5\xe6\xf8\xfc\xf0X\xa5\xc0\x17\x18\xf7\xef[\x94\xa3\x0d\x13\xbd\x89X\x7f\x1dr\xb4\xd1\xc1er&n\xff\xc3woy\xe7\xee\xca\xe3Jl\xedS3\x03c\x19\x80\xc9X\xe3\x1f^\x88\xf3\x93\x8b\xf4\\\xe9\xcb8\xda\xf4\xe12:\x13\xd7\xfe\xd9;\xcdu|W\xc5+\xbe6~\x93\x91\xa6\xf2~{\x14\xaf\xb8 \xd6q\xb7\x8e$h\x95H>\xa0n\xe2\xfb\xf0\xcf\x9e\xfes_\xfc\x19S\xeax[\x1b\xd6\xe0\x1e\xdbo\x96\xb1ee&\xddh\xe2\x87\xeb\xf5\xe5\x89\x05\x1a.\xe0\x03\xe6\xf6\x97c\x9c\xac\xb0\xeeQ\xb8t\x97~\xc6[\xbf|tN&\x94\x7f8FKP\x91`\xaf\xb8\x8b\xbb\x18\xb6v\xcd\xb1@\xc6X\xe2\xbf\xf7\x0e\x15A\xbd}\xb9\x8f\x9dO\xc4\xf2C\"\x95p\xfa\x87\x0b\xce4\xda3\x1en(s\xc6\xcad\xcf*C\xff\xfe?\x1d3q\x84D\xd7\x16Xw7\xaf\xe4B\x8e7\xde\xb7\x7f\xdf\x92\xc92`\xcdy.U0m\x9c\xf8\xe0\x1d\xfb\xdb[\x7ft?{\xb6\x904Z;P\xc7\xbd\x94\"\xe8\xfa\x8dH l\xff\xfc;\xd7\xc9t\xc8\x81\x81M\x9d\xd9\xeb\xb7\x00\xb4q\xc0\x07d\xf5\x0f\x8eU%\xb6\xcdJ#yk\xc2\x82\x9a\x13\xe2f\xe9\x9d_\x1e\x87\xe5\xd3\x0b\xd8\x81\xe5Z?\x0d+\x18\xbf\xd8\xff\xc3\x85\x1d\xff*\x9eJ\x9f\xe5\xed\xe4E1\x0f\xb6\xfb\xa0\x1al\xb26z\xbd\x890,\xf8\x1db\x82J\x06\xec'\xcd\xccP\xdf\xa1W\x0f\xb8D\x10\xb8\x99\x06\xd8\xf9\xb8\x07_}\xa1\xdb0\xdbJ;\x08TI\xe6)L\xacM\xb9\"\xcd\xf4\x1c\xdeV\xed\xca\x93\xf3v,,\x00\xfd\xcd\xac\xed\xa5\x8f\xdd/'\xc8V\x04\x12\x989p}\xf9v*`\xd7K\"\xcf\x95V\xcfO\xf8hIqee~\x15\xd6\xef\xdd\x8d5\xaf\xd8\xee\xbc13\x9e3\xd4M\xab\xe7\xc1\xb5f\xc1#\x8e\xca\x8f\xe4=H\x1d\xd2\xbd\xea\x91\xc1IN[*\\`'9\xe7\x1e\xadIcL 1\xba\xdd\xaf\xe6\x9bgt\xdb\xba\x802\xc8G}\x96@y\x985\x15\xbe\xab{\xbaWV\xed\x07k\xf19\x92\xa5\x17\xf5&\xbeO\xf8\xde@/\x9e\x9e\xd6\xa7~dt\xbd\xc5\x1f\xaf/P\xac\xb24\x84n\xb4\x1bK\xef\x07\xdf\xed\xea\x977QB \xa3\xd2\xda)\x9dE5\x03{\x06\xfc\xba\xa0+\xe6'\xc3Ax\xde\xa6|\xb1\xa8!`\xc3=ZX\xb22\xa17p#q\xcd\x1c\xeb&\xa8\xbf\x9c'\x84t\x86\x99u\xdb\xbd+*<\xea\xe9\x83\x05\xfdN4\xe0\xd5\xec\xde\x11\xc6\x81\xca\x16\xc1\xeaw/\xfav\xb2\xca\x14\xd2\xd5\xc5\x01\x9c\x10&\x84:S\x00\xdb/\x9f\xc8\xe9{\x13\xd0\xe7g\xdex\xfdI \xd3@\xd0/\xbf\x80\x0b\x11\xfa;\xa4\x0bZ\xc3T\xba\x18\xde\x04+\x08\x89F\xd38\x97WX?{\xe0\xd5\xde3\xf3\xdd\xbd\xf9/\x96g\xab\xcc\x88\x94\xf6\x83\x92\xc5d\xe1\x9cM\x87\x182\x16\xeb\x9dY\x7f\xc7\xa1\xd5\xd4##\xfd\xe2[`\x8f\xd3\xbb!\xf6#z\xba\xd893\xb6\xec\xad\xf1\xb6\xd9X\x17S\xf2\xa7\xa5\xe4\x1e\x81\xbe+S\xce\xd1\xe9Ur\xa3i\x98]g\xe4B\xbd\xa9\xac\x08\x97\xed\xd8:\x12\xec\xa3\xce\x1f\xa8\x96-m\xff\xe6:\xc5K\xc9\x83\x92\xf1sv\xdfu \x18\xba\xc4y\x8f\xb7\xd3\xa1\xb96OC\xca)\x11\xf3\x0e\xdb\x89#w\xaa&q\xb8\"\xecs\x7fs\xdd\xfc\xe3\xaf+?k\xb1~\x03\x00\x18\x00\x1c\xcf\xa6}\x90d\xc8S\x83\xc9U\xa3\x913\xd6\xae\xa6>\xb7\x80y\xe9\xe2!P\xba\x1e\xd1{ \x03\xcb\xac\x0f\x05a\xdfh\xf4\xab\x9d%\xae\xb0\xc7\x95hyD\x92E\xdd\x92\x8e\x8cF<\xd2\x88\x81\xd2\x14C\x7f\x97\x86\x0f\xce/\xc7'M\xe8\x9az\xa2\x10\xbfu\xef:\x00\xfdO\x1b'\xf5f\x9d\xed\xa74\xec\x0c{\xb4W\xe0w\x1d\xba\xbb\x1a\x9af\xbfZk5Z0\x87\x17H\xf2\x1d\xa6\xefw\x92z`\xea\x7f\xb5jp\x94a\x14$e^x\xc0K\xfd\x1d\x8a[\xa8t\x97b'[\x8d\xa5\xc6\xf9\xbc\xb8\xd8\xa7\xd7\xc2x\xd9Q>p?\xbe\x92C\xdb\xde\xcc\n{zP\x9e\xd5\xe0\x0ebJ\xad\x93\xa5\xc7\xa2\xd0\xaa\x06\xc0>(\xfa\xfd>?\x02k\xe1\xfdlu\x8e\xb3\x1cVd\xb2\x83\xd1\xb2!^\xf5\xbe+\xeb\xc4\xfd\xa9\x07\xae\x92\xc8\x074[\x89\xec}@\x94\x08\x8824w\xfc\xfb\xb7'\xe4\xfd\x83\x80>D\x8bW\xa3\xc4\xa1\xd4p\x8b\xaf\xa5\xbcU~\xfc\xb5~\xb3\\\xc0\xb1<\xfa\x8a\x84\x11\x19\x8a\xdf\x9d\x94\xeec?\xef\xce\xd9\xcd\xbb98\xfd\xc4t\xf9rp\x9fZ\x18\x19 \x80d\x13\xbdm\xc1$\x12i]\xfc\xae\x0dg\xff\xc3\xcc\x98\x97-\x9f\xdd\xf2=r\x9a\x10r+\xbd\xcf\xdeL\xaazU~\xe2\xaa.\xd9[\x00s\x89|nG`}g\x08\x19\xc7\xa9\xd1\x84\xf7\x04\xe1\xbcf\xa1W\xab\xce\x97EH<\xabB8xL\x18 _4}\x92I\x03\x7f\xef\x10\xa8K\x04u+x\xceq\xfbl\x1e\x8d\xe8\xa1+'\xf2\x04\x07\x96\xf5\x0f\xbf\xdb\x16L&P\xd8\xe2=\xa9\xd7P\xa3#\xef\xa7[\xf6%\x84\xfe\x88\xc0\x8d\xc2_\x89\x16\x97\xd1\xb5\x89\xc1\xef\xdf\xc4l\n\x95\x98\x9e\x89K\xce\xc5\xcee\x80\xe11{\xc3\xfe\xfc\xd6\xe7\xf1P\x13\xeaU\xf40<\x15\xbbf\x81\x9f\x08\xb4\x11!\xde\x12\xd1\xaaM\x16\x0f<\x96\xd3\x03\xebv{\xd5{\x94\xe2\xfa\x1f[>\x1a\xa7\xb8\x06\xe7z\xb9\xc2\xe6\x9a\xc1{\xf7T\x1d\x94\xacsp{\x114'\xe5_\xca\x92\xb8\x9f\x89\xde\xf3i\xf9\xbd\x89\xf2\xaa\xa9\xbb\x03\xbf\xcd\xb9D\xec{\xb2\x0b\x8b\\\xd71\x03%\x9cTmo\x9fN.\xafN]\xf8\xc5\xb7\x1b\xe7\xe5\xd4\xf9\xbaU|\x07\x9b\xa0@\xb6irqjtWz=\xce\x0d\x98\x16,w\xc7\xb4\xf9\xdbs_y\"\xcfxq\xf6\xdd2G9\xb6H\x91\x07]\xbb\x91\xae\xbb\xaf\xce\xc4]7\xdf\xaf\xf7f\x90\xc4\x96X\xdfY\x81H^\x0f\x14\xa2\xcd\xba:X\xe0\x03.\xf7o{\x94\xe7\x85eUU\x06\x06\x183D\x1f\xb1o\x18\x91fZ\x04\x7f4\xe4k\xcfw\x12>kuN\xb5~\nL?L4eJ\x0f\xb5\xdf.a\xa08\xf9\x81_?\x1fQ\x9e\x1aK\xd6\xd0\xde\xbeE\xb0E\x1f\xe5\xce\x8dK<)\xa5\xa4>r7\xffz(\x1d\xcc\x15=\xfa\xe9\xcc\xf2\x8e\xe6\x8a\x06_\xbd:\x97\xaa\x1a\xec\xc5\xb2\x90\x93g \x80\xdfxx\xcc\x86\x1a#\xf2\xf9iT\x8b\xfe\xbdI\x18\x85\xe5F\xd65x{\x17\x18\x0c\x08\x88\x05\x15xM\x11\\\xc1[\x8e;\x89\xef\x15\xf5\xf8\xce\x15\xba\xaf\xa7\xc1\xd9\xb0K\xe8.\x8f\xd5K<\xab\xc9\x95k\xb1\xf3\x1b.\x1eW\xf2'\xbe\xc7\xbc\xa3\x97\xa3\x9d\xbe\xb5\x888\xc5\x17\xcbq|g2\x0c<\"%\x8e\xdd,\xfc4#\xa6\xc9\xbf\xf7\x9chn\xb7\\\x8d\x97q{M\x99i\xbd\x99\xf0\x110\xe2#`\xd6\xfdn\xa4\xb6\xcf\xed\xe8\x8e\x1b\xdaU\xd2\xcd\x1daQ!\xaf\xd1\xda\x89L'\xf0`\x19\xfb[\xb2\x93\xf1\x8e\xd5\x9b\x16b\x94\x9a\xeeQ\xb1\xe5_\x87o\xea^u\x17\xda\xf8\xf7E\xdf1(\xbbr\x07\xd4\x0f\xce\xde\xafB(\x91\xd7\xe8Z\xdd\xc3:\xe6~\xc2\xc4\xba\xa0@\x80Q\x06fP\x9aA\xe2j}\xa0\x93\xb7a\xaa\xf8\xce\xf4(-\x08-\xe0%>\xb3\xe1W\x03\xa3\x01e,\xa9x\xbc?\xf09\xd8]&\xba\xe2f\x9c\xd3\x8df\xfc Bx'\xba\x05M\xe7;o\x16\x88o\xacgs\xa4\xccjb\xb2?\xc7\xb01\xc7^j3\x0d\x1f\x07b\x16\x161\x84\xbc6\xb6\xb7,\xac\x07\x96\x85$\xbf\xc4\x06\xc3\xbf\xd6Y\xf1U8S\x9f\x185\"\x06\xf7\x93 \x97\x11\x0f?\xf08\x9e\xf6\x0b\xc1\x83\xfd\x00\x1e-?\xb0&\xa7\xe8\x1dG\xa5\x05\x1e^pP\xaf\xab\xc1o\xce\xed\xec\xc1\xda\x08\x06 7\x9f\xd6\x82{.\xda\xd7\xc7]]\xbb.0f\xc7\x13A!&\xa8\xe2\x05F\x9e\xa8\xbc\xf4\x8a\xaf\xe2\xdaH\xe2\xa9@\xe2EoS\xc6\xca`t\xb0o\x19Al\xa6A\xe2\xee\xbd\xac\xc4\xbbQ\xe1\xf7G\x8b\xde\xebW\xf6\xa1\x02\x97\xc6d\xf6\xbc\xb1\x1f\x88\xa8\xcd\x89^\x84\xa2\xeb\x93\x99\xb3\xb8\xfdx\x9f\x08f\xd2\x95\xb0\x9e\x06i\x1dK|y\x08==J\xfa0\xdfL+VH\xfd\xe0\xf4\x0d \x8cM\xcfj\xbc|\x03\x03I\xb8}M#>Y\xc2\xcbCX\xf5\x83\x85\x1bwb\x0b\xef\x92(\x15.\x9e\xd5 \xb4\x0c\xc3\xeb\x1a\x11}\xc7\xb3r\xc4>\x08Q=0|\xee)\xad\xc9)\xad\xdd)\xd3\x9dH@\x80\xe3%a@L3\x0e\x89H\xf0N\x91M{UK\x99\x05\x1f)\xb5uJks\xdc\xed\xef\xe6\x83\xdaw\xff^\xe1%\x08\xab\x9c\xa5\x16\x1eV\x94\x1bgH\x18<\x95\xbcF\xa5G\x1d\x1f,\x15\xa4\xfc\xfd5\\\x97\x9dVO\xedP'M6\x8e\xf3X\xa7\xcax!4O\xc4\xc2\xfdX$]V\xfaPo\xa7\x82\xf4\xa6\x93.\x98\x11\x0e}o\xde\xc4R>\x0d\xb3\x17\xb7\xd8]\xe2\\\xc2\x0b\x93&\n\xb3\xc16\x83\xe79\x0bf\xbc\x81G\xe2\xe3\xdc\x06\xd6\x8b=\xb9xm\xbe\xcd)h\xc1\x05\x19\x10\xd7I\x1f\xed)\x1c\x01\xbeWH\xf4`\xdbX`[<\xdcs\x1c\xeb\xfd Q\x9f\x9b9\x0d\x94f\x18ud\x98L\xb4\xe9v\xd2\x86\xe2\xf9\xb9 \xb7\x05\xd3f>\xb3\xfd\xdeY\xc5\xf3\xae\xe6\x84\x9f\xd5.\xad @\xd5\x00\x8f;>\xe4\xf3\x1bW<\xe1\xf2\xe0\x83\xd5\x82<%\x11*$\xa2\xc8N\xd3\x19\xbb\x01v\xc4y\xd6\x84<\xab\xe3W\xef\xdb_\xbb\xda\xcb\xa7\xd8\xdc\xc7\xd3q\xc2\xe7\xed$\x1f\xf5%\x9b\xd5\xc3~\xc1\x17J\xbc\xa2|\xf9d\xe6^N\xe1\x9c\x1e,\xef\xf8@\x13\xadP\xeaJ\x93\xe56V\xd0\x89e'\x04\xe4S\xb2\x984\xf3\x99\xb5B\x95Vz\xf7\xf9\x1e_ii\xad\xd5\x9b\xe2\xaam\xc6T\xfc\x13\xdco\xb2\x8eO\xeb\x85 \xc7u\x13\x0f\x82\x92\xce\xbb\xe1]1Sg:-\xe2I\xedF\xba\xb6GX\xe7\xa3\xf19g\xda\xfb\x19yqggD`\x97e\xb8 \x8f\xaa\xa1\xcd\xfa\xce!\xc4*\x08\xb1Yz\xd6C\x8c\xeaV\xa4\xf0\xbe\xb9\x8a\xb0Lx\xe9C\x897\x12\xbe\xfb\xde6\xd5\x10\xc6\xefc\xd7\x11Vz\xee\xbc\xda#}3)\xc8#\x8fKW\xfa\xea\xc5\xcd\xf4HU2\xf4\xcd\xb8P\x0f\xd8\xef\xb7\xc3\x90\x02e\xdd\xb1U\xea3\xbd\xec\xa3`T\xad\xacd\xee\xfe\xed\x12\xe4T\x1e\x85\x1e\xde\x1f\x1d$\x96\x94\x10-\xcc\xd6\x11}\x80B\x88\xf0\xd6zzI n8\xf1\xf8\xae\xbb\x13fu\xabf\xadh\x94@U\xc8\xf1\x9c\xd2\x10{\x14bDRBq\xc2\xe8\xb8K\xa7\x80\xdd\x83s\xc0??\xfdk\xd3\xa2\xa5Sj\x14\xbeF\xc6\xa0\x80Y\xb7\xaaRR\x86Q\xc7\xc6-\xe4\xb69~&\xbb\xabx\x93\xfcFD@Jh\xbd ,.\xb2\xed\x94\xb7\xef\xeadu|\xe9{?SC<^\xcb\x8a2\x1f>[H{\xe3\xb7n!\xb6\x8f;j\xaa\xe5~\x8bI\xfe\x95)n\xa6\x87o\x9c\x1f\xc3 }\xf0\xed\xf8\xa6g\x12\x8f\xdb\x95\xb7\x1e\xe8\x03j\x8f\x16\x1e}\xad\xd0%\x0f\xe6l\xaa7\x9f\x0eo=\x94\xee\xb8\x8bV\xe6\x08:\xbe\x0ena\xd9\x10!\x1b\xcd\x984\xbf2\xcb}Q\x9a\x1f\xe3\xe1g\xda\x1d\xf4\xe5fJ\x8e\xafeN~L\xff\x0c\xe5\xab\xf6\xe1\x93\xf0\xe1\xfd\x890\x9a\xb3.\xd8\x98\x90P)\x10\xe6\x86d\xfa3\xf6\xc6\xe6\x8a\x00Z\"\xeaqe\x18\x95\xd5\x013\xdc\xf7\x8c\xbf\xd9\x942\xba\xa3\xe9;<\x92g\x97\x9fQ\xcb\x1e9UpVo\x90\xe1\x89\x1cm\xac\xf9\n\\\x99\x10\xe4\x94\xbe\xbf\xd9\x0d\x96Dj|\x1e\x14D>\x9eE\n\x9d\xc4\\\xf5\xbbt\xc3(2l\x8e\xba\x810\xc7\xdd\xe3.\xc7\xaf\x9b\xc3\xe2%2?\x8c\xe1\x9c\x013\x98V\x89 ~\x18\xbd\x10\xdab\xd92\x96\xc6\x84\xdf\x96\xf3\xc5\xe7'\xe6\xf9U\xf3..4\xe0\xa7\\\x0c\xf8\xb6\xd7\xcawe\xbd\x10)\x85\xd4\xb6\xf8\x0fQ=\xfck\xa0\x19\x9d\xdcj\xfe8\xb5\x12\xae5\x97\n\xa0Y\x84\x9dM\xd3X\x89c\xc7\xff\xac \x1e=U\\\x9d?\x919:\x01?\xaf\xc3\x87\x9bYi2\x11\xc7\xca\xcas\xd9\x8c\x8f\x8d\xd5\xa9\x9d{}\xa6*l\x0d\xefK\x8ecyB\xe3\xf0K3Uu7\x10\xd3\xc2\x8b\xe9\xb68]\xd4*\xe7\x881\x8f\x0f\xa7\xedL@+ijc\x05\x81\xaa\xefO\x0c\xb97\x02\x99\xd0\xec\xd3\x0b\xa3\x91\\t\xc1;\xbc\x89\xe3\x03W\xc6\x11V}\x9f\x04\xbepc\xc7\xe7\xb3\x12\x8a\xeb='\x08s\xa67\x80\xdc\x89<\xca0\xb7\xcf\xde5\x89\x95\xecJ\xfe\xf9.\xc4f\x85\xa2\x94\xa2?Ke\xaf\xf53\x84\xf7\x02\xcc\xfb\xce{\xc7l\x99\x0b\xb5\xde\x8frlx\x8d\xfbD\xecsg\x10k\x192\xcc\xd4\xab\x9b\x96\"'/'\xd8\x0b\x0b\xebK\x9dt\x82$\x92\xe1`_O\xca\xd2k\x18=9B\x1bR\x7fs\xfc\xaa1\xb3\xa9 \xc4re\xab\x87\x99G\x14n\xcc\xb1\xa7P(\x05J\xf2\x0e\x1f\xcc1\x19\xf3J\x9c\x8f\xfa\xfaP\xe6\xe75,\x05I\x91\x1f\xfb\x98\xe5\\\xa2S^\x98\xb5\x86\nB~/\x99\x17V\xbfL4yf\x9f\x01\xa3\"'\xd8\x8du\xa5\xd8\xf7\x1bb\xe18tq*9F\xbb\xd2,qQ7\xf1\x86G\xcac\x1bL\xf8@\xb2D\xee7^\x08\xd4\x89C\x90\xb35nO.\xa50\xd7\x7f\xfb%\xad\xde \x8ceS\xe5\xc3\x8dpA\xf5\xea\xa9\xbf\x1a'x\xd4\x82\xf2\xd4\xb0\x85~\x1b\xf8\x03\xdbs+\x17\xaac2\xf8\xf3\xb8\x8b\x86;\xe2JY\xda\xa9\xf0\xb0wuYo\x83\x0b!\xab\xceY\x13o{\xdeSF\x1c\xc2ud\x88\xe5H4\xcdw\xfcY7Gn\x8e\xc1=\xacKF\x17\xb8\x16\x82G\xe5`n\xfb \x0c9\x1b\xf67\x8b\xbe\xe7\x10i>\x1aS\x07\xd4\x0b}2\xe5\x07#n\xc2\xc4\xb4aY\xb5#\x1c.\x84\xad^\xa5\xd8\x0d\x0b\x17{\xef\xeaa \xac\x1e\xde$9\xf7&\x9c*\xbf\xfa/l\xab\xd9\xa6(,\xa3U\x8a\x92\xeds\x9f>\xf9\x87\xe53\xe2\xf6r\x88`A\xeeG\x0d\x18\x1arltq\xfe\x89,\xaf\xb2?\xfc\x93TC\xdc,\xcbZ=\xde\x0d\x13\xfa m,xARoz8gc\x9e\x9f\x8b\x05\xadO\xf8$\xfc\xdais\xd7\x06n\x89\x08%y\x07X>\x0d\xd8\xc3k\x03\xb1e\x87\x97\xb4\xe7\x89c\x17\"I\x85hx>\xd5~\xdbDa\x8b,%n\xe8W!\x11P\xc5\x03\xe8\xa2\x86}5\x94\x1a\x15Y\xf0\xb6?\xc6\x89\x18F,\xa6ZQ\xf3\xb7\x97\x17DZK\x04>{\xa7\xf435\x10\xae\x88\xcc\xeb\x08|U\xa3J\xfe\x88T\x1f!\x92o^\xd6v\xfe<\xd8\x9b\x8b\x89}\xd3\xd7\xce'g\x0b\xb6.\x11Q!_\xd1u\xc6b\xa7=Z\xc6\xf7,\xe3\xa6\xd2y\x1a\xf4w5\">/\xee\xe6\xa6\\E\xb1e\xb4\x11w\x8dy_xk\x9c\xb3<\xef\xb8\xfb\xc1W\xe2&\x08\x9e\x86~\x06\xfd\x011\x04Q\xf2\xcb\xf7\xc9\x88\n\xae\xf7\xc2<\x8e\x17\x95\xd6\x04\xfe\x92>x>!\xb5\xde\xc7q\xb6\xebO\xdc\x1f\x87\x16\xb3\x1e.\x9f9e@,H\xb0pJ$[8\xd7\xf5\xbdq\xaejN\xab\xf0]\xee\x10\x01\x8e\xdc\x0f\x1a0$\xb4\xb0\x06\x0f:\xa9\x1fn\xbcI\x8e#&\x06=_;AY&\xf8H\x07\x98|\xce[\xe2\x06\xba\xe9\xc77\xcdxDEx\x05\xfc\xb7\x9f\xaa.\x9e\xf6\xc9H\x94\xcb\x18\xf3\x08\x83e\x94\xf2F\x8e\x03\xd8\x87\xe1\xde\x82\xae\x9ak\xef.?\x88\xa7X\xf0\x9a\\\xb8.\xaa\x14_\x0c\x1c\xe5\xebb\xcd\xbd\x19\\\x1bQYr\xe1\xb2\xfe\xa5\xc15\xa3t\xfas?\xc2SV\x95\x06\xe1L\x88\xbe\x10\xce\xdb\xe7\xdd)\x8f\x1cM\xe6D!\x9fe\xd8\x1c\x1e\xb3\xd0\xe2X<\x1e\xb5\xcd\x8c\xe7\xcb[\xfc\x0f\xfa\xbe \x9bn\x87\xaa\xf34\xd9\xce\x1a\x8dn\xb4\xdf\xa7\xab\xe2H\x9agJ\x8fr,\n\xc5\x1d\xc5-\x07\x1b\x93\x0daF\x91\xac\xbdc\xa8\x08cS\x07\x8b}2\x13\xadNYX\xae\xae#\xde\x11N\xf0\xcf\xc9\x0cap\x0f\xcf^U\x96\xef]^\x88\xd1*\xa1Bf\x08B7\xd4\x8d\xbbo\xf3J\x90\xb7\x07I\xdb\x1d\xbb<\x12\x96\xa4\x90\xfdm\xb4u\xe9SK\x08\xdf\xaf\x1bC\x87z\xe38cH\xf4\x9b\x94\xe0YJ\xd6\xa6\xeax\x1e\xcc-\xf9\x8f\xa6\x19\xfb\x07\x89\x865.(/4\xb8\x870o\xec-v\xafU\xd1Z\x91\xe1\xae\x02\x9b\xb6\xc0\xc5s2\xe0\x07\xca\x08\xc2\xa8\x1b{,\x93\x9f{\x86]\xfe\x168\xde@\xeeX\xae\xb1\xc4\xb8{\x0c\x18>\x15\xae7\x923\xbe\xfb\xe6b2\x12\x17\x95\xd7 \xa6\xd3:n\xb6\xaf\x15\x17\x11(s\xf9\xd5\xa2\xb1t\x86e\x82\xbfYj\x18\x91wx|\x12\x8a\x92\x17X{\xa5F!l_\xde\xdc\x0b\xf6\xcf<\x06\xc5\xda2\x1bQ\xf0\x9b\x96\xbbm\xaf\x14H\xe1\x8d\x9a\x98\x13\xaa\x124\x81\xcam\xac\xd4\xe6M\xfb\x8an\xac\x92\x15x\xfc\xf8h{\x87\x8d:\xbc\x9cW\xbb\x942\x8e\x8f\x04\xfe\xfc\xce\xac\xd2\xda}\xbaw\x16s \x8f.\x9a\x11_\x82\x97\x1c\x04!j\xe9\xd3\xe5\x1e\xf3G&\x17\xee\x10}\xf5]\xcf\x01\x97\xf4\xd98\xc2bl\xf1\xbdo\x96\x1b\x9d\x1a\x15\xa2#\xc2\x9c\xbaK\x0f\x8f[G=\xee\xbc\\\xbf\x92\x96}\xa5\x96U4\xcc\x8b9\xb0\xfe*\xe9G\x8fc\x1ez4\xe9\xe4\xaf\x17(\xc5\xdc\xa7\x18\x97\xa5bGYf\xb5<\x06p\x93\x94\x12\x16\x12\xbf\xc5\x8e\x92h]c\x98\xd4\xf8\xa8NK*\x83 \xde=5\x8a\x17(\xa7\xb4\xae2e\xef\x97\xc7\x92\xe9d\xd5\x0c\xc19K\xeb\xb6\x92\xd4B\xe2\xcf\xcf\x86\x92\xf7\xbak\xe3\xd5\xc6\xad\xfe\x15\xbcJ<\x1bE\xbc\xe9}\xc1mEH\xaa6\x110X\x92\x06\xcft\xf2\xd5\xc8\x05\xefU\x00\xbd\x90=\x90md\x9f\xd0W\xf9M\xe1M\xfc\xe8\x9b\xa5hM\x1b\x9e\xdb\x8f\xa9\x86F@(\xf8\x10\xfdX\xe5=*#J\x96/\xa9w\xdfKqh\xa2\xe4`\xf6\x8c\xca\x84\xbd;\xda\xed\x10\xe3\x1e<\xea\xb7t\x84\xfe\x87\xeb\xe1\x85\x0cg\xaf\xad\xcd\x12\x95\x0c\xb5s\x97\x10v\xb1k& \xde\xfd\x8cz\x1c\xbf>\xa3\xcc\x89Z\xdep\x9d\xb3\xbc\xb5\xc7\xcbG\x85Z\xf0\x9e\x0d\xaf\xb1v\x19B\xa0\x9a\xa7\xcf\x8b4\xfa\xb9gh\xf3\x94\xe6\xd8\x1b\xfc\xf3\xe4\xd7\x8b\xd0\xe4h\xa9\xdf\xc3\x07\xd7'\xe4\xec\xa5\xa5#|\x15\xd7\xe5\xf2DU^+\xa6\xa1\x10bMU\xbck(\xab\xe8!\xe6\xfe\xf7\x8e\xce\xea\xaa\x81U\xfb\xc1;\xcf\xc5\x0c\xaf\xba=\xb9'\xec3\x0bb\xb5\x8f\x9e%\xc5\xcc\xd1\xde$\xbe\xa7OH\xc9`\xbax\xf6\xea\xf3u\x9b\xd6g9l\xcb\xa38n\xf3\x9dT\x97\xdd\x9a\x96\xedu\\\x18\x97d\x12\x8c\xc4\xfa!8\x83\xd97\x17\x97|N*@Q\x9c\x001\x8eg}\xa4\xec\xb7\xa2\x04\xac\xf0\xb0suI\xe0\x81\x10H\x93\x926J\xda\x08\xcb:\xf4\x9cz[P\xfb\xd2\xc4g6\xb6{d8\xe7\x95\x86\xbb\x84\x1d>\xf3\x8b(\xe7\xcfEj\x9b\xa7>\xc2\xff\x9b\xa8\xabH\x8f\x9eg\xb6\x0b\xf2\xc0LC33{ff\xa6v\xaf\xfe>I\xde\xef\xfe\xd3\xb4S\x92JE\x92\x8fOA\x8e\x14\x83hq\x8c!xn\x00\xff\x1e\x88\xb2\x8e\xe5L!1\x9f\xbb\xb9\x18\xd4\x07\x8c\xd9\xdb\xe3\x0d\xf9\xf2\x12]\x8aQ\xe9\x98x\xc7f\xe6\xfdU\xb6!7R\xb4>\x14V\x96\xc1*\xac\xfa\x14\x97D\x88\xb6\xd6\xfa\xbeS3\x1f\xb4\xa6WF/\xb5\x90\xa3p\x8c8\x15\xe6\xcbG\x89\xabq\xd1\xc1\xcct\xd2\xd8\xba\xc8\x9bt\x9d\xab\xf5\xaeZC\xbf\x8b\x13\x18tQ\xfa\xd9\x11\xb1\xb8\x8e\x17\xd7\xce\x01\x92\x04b\xd7\xa8\x8a=\"\x8d\x9f\xf2\xd1b\x18\x03\x08>\x85r9\x18\xf5$\x91\xf2c\xce\xa5`dnK\x10\xb6\xdd\xf6)k\xde\x1bp\\5\x0e\xc8m\xbe)C\xb18/\x11s\xc7l\xf3J \x85_\xaa\xa0\xa7\xcbz\xabh0\xf1\x87t\x81\xf6\xf6\x91h\xe8\xb5;\xc7\x82\xc7w\x1b\x834\xe5p\x87N\xbc4\xd0;y\xe6\xf5\x1ba\x84N\xbd\x81hH\xfb\x8e\xf9\xe32\xaav\x99\xfb|\xefDY\x90\x91\x15;\x1a\xf3G\xd4= &2^3\xefn\x8b\x86\xafy\xd1\xf0\x05\x11p \x19\x98G:t\x12zh\xff\x90\xe2\x8a\xd3\xaa\x9ay\xe8\xf4\xfd0{G*\\\xbc\xad\xb7A\xa5\x83F\x87$T\xae=\x96{HX\xbbO]PZ\x99x\xf3\xb6O\x14\xfe\x1dG\x0e7?-\x96\xc2\x15\x88\x88P\x8bjW\x1e\xe3\xe6\xfbAB\x8e\xd8\xee\\?vP\xb7\xa4n\x04\xa4\xaa\xe9`0\x8cM\xa848\x93\xf5Z\xaf\xa7\x1f\xcbxk18\xd5\xfc\x9bb\xda\x82\x10\xca\x9a\xf9\x06BZ{L2\xce\xd6:\x93\xed\xc8v\x9f \xe3j`g\xdc0v\xcc\x9ezZ\xde\xca\x8b\xad\xe0\xbe>\x19NJdRfA\xc4\xedy\xbe\x0f\xcd\xb8rDy]\"Ve\xca(\x99e\xb9\xdf\x17w\xc9\x17Z\xbf\xd9\xc8]\xa8\x086\x94\xee\xc7\xb2\x1c\x83&\x89km\x9a\x81\xac\x1d\xd6R0\xea\xb1u\xee-\xbd\x9c\xfdV=\x99M\xa53K\x07\xa0\x0e\xc1H\xf0\xdd\xc7x-\xc9\xe7\xdb\xf2\x9b\xa1\xe5\xb6\x1bT\xb7\x7fE\xc3\xbe\xa09\x92&\xc5\x9a\xca<\x8c\xd5\xe6\xea\xb9\xc0d\x07}\xce.u\xf7\x86~^V\x92p*E^\xe3\xf6\xa8\x01tD\xaa\x99\xba=\xfa\xcdY\xdf\x197\xdf\xf5rG,#\x11X\x05\xd1Z\x8e#N%\xad~\x7f\x81\xba\x8c\xe8\xed\x93\xb0sY\x0b\xc19~\xb1\x8f:\xf7\xd7\x02\xcc\xfd\x15\xc4\xfdg\n[\xed\xdd>(\xcc\xefU\x84:\xb0\x03\xb6V\"1\xf1\xfd\xe6\xe3\xe7\xd6F\xca\xe4\xcc#\xf5\x03\x03\xb7\xea\xb6\x9b\xfb\x84\x18\x8f*^\x82\xaf\nU\x1e4\xd0\x05\x08\x86@x\x10u\x05\xd6u\x9d\xeate\xbf\x15\x10'\x8e\xbe'Y\xee\xed\xce\xd5\x7f\x9bw\x13Z\x04\xe0\xd7\x0c\xfc\xd1\x8d\x8d\xc6g\xa8\xab\xc2I\x93\xdbw\xb3\xd6\xe5q?\x18\xb6\xe3\x89\xd6\x9f\x91n\x1b\x86U\x861\xed\xa7 q\x0b\xf4\xbb\xd1\x96\x05\xe3q\x1bs\xa8\xbec\x17\x9el|R\xc6\xf9$\xde\xaf\xf1c+\x1ea\xbc\xb4\xdc\xc4\xf5\xe4\xac\xf8\xd1\xf14%y\x9f\\\xfc\x19G\x8fosz\xbe\xda\x19`\xe4\xbc\x84N\xeb\xc7g\x94\xf6T\xc8=#]\x10\xafZ\xf3\x8f\x9e\x8b\x81\xd9\x7f09\xf7~m\x1a\x9d\xe7MI\x1b\xd0\xc2s\xad\xbd\xaf\xab\xef\xa2\x01=n\xdfw\xc5U\xaf\xc0\x8dT\xceR,T\xeb\x81\xb5\xaa7Hu\x11Q\xdc\x00\xf7)\xe2\x94\x9c\xbau4\xd4q\xe84\xacD\xd6_\x14\x1f\x08\xd9\xab\xccN\xcbft\xed!\xfd\xd7>Ge\xa2\xa3W2\x04xa\xd0\xfcR\xb9{t\x87R\xae\xa7\xf43\xf2\x94/c5~ \xda\n\xaf;\xa2\x1b\x1fJI3\x0f\x98rc\xcfE\x81\x00\xe2\x96$z\x8a\xda\xb2o1\xedst\x96\xe4@\xe1\x13\xa7\x17|;\xe7o\xe9\x0d\xa8\xda\x13\xd9\xfc\x86\xe6\xbb\x02V97\x18\x86\xd9\xa6\xe1\xe0\x11,\xb0\xf8\xc4\xc9\x1f\x83`\xa5\xf7\xdeQF\x1bt\xe7\x19K\x12\x82\x08\x0d\xf3\xc8\xd2V`-\xac(\x83\xf8H\x15\xd8!\x91\xa6\xc1\xc7M\xa2\x01\x88\x8f\x80\x0f!u\xa4#\x1cq\xbfy\x85C\x95%\xab\x91\xb0k\xab^Bw\x81iIV\xcf\x01\xd4\xe2\xd4\xdd\xd0G\xe2\x076lK\x0fu\xdc\x9fP;\x87*\x02'l\xef\x8f\xfc\xec\xbb2\xc5i\x0dO\x9ck\x03u\x99\x92V\x89\xa0\xd6\xea\x84\xc3\xa6\xa5\xeb\x91\xc3\xe9K\xf7\x93\xfc\xa4\xb7R\xfe=;\x9fB\x1a\x810\xb6\x8d\xd7\xe0'%aD\xda\x02S\xb3\x1b-\xb7\xae\xd7\xc8\x15\x06_$Sr\xc7\xd1\xceZB]Z\xf7\xec\xf8S \x89\xcc\xe0\x18\xbdI\xb9\x8aMe\xf3c[\xbf\xb1\xfc\xda@)\xb5\xde\n6\x05\xe2H\xf3k\xc4\xe76\xe6e\x7f\xc7&\xbc\xc8\xfb\x92*\xbc`\x86(\xeb\x9b\x15\x1e\xde\x8c\x1c\x92\xc9jfz\x06\xe2\x7f\xf0\xc3\xf0i\xac*\xd7-\x9c*\xe3#\x87\xaa\x03\xe6\xc0H\x88\x83\x12\xbe\x97\"\xfeb0\x84m\xeb\xf1Xw\xde\xd9\x18\xa6Z\xb2\xd86\xa2\xa0\x0fe\xca\x8fu\\\xfe\x1a\xe4\x92\x10\xf2`\xff\xa1\x1c/\xb82*\x01\xa0\xfb\xd6\x06gm\xc6\x9c\xb9\xae[N\xf57\x19\xf4\xf0\xc9\xb9\x8c\xa0G9Y\xe6\xe9=\xf7\xda\xa5\"$\xe9\xc7\xdf\xbb\xc6l#gc\xce|\xf7\x88\x11Rl\x97\x1c\x03\xc6U\xf8\xa46\x84&\xd5\x01\xdd;\x0f\xf3\x17kz\xfd\x02\x83\x96\x9a9\xc4tr\x9ffOZi9\x942\x8e\x8d\xcf\x94)\xe9\xf0\xe1\xdc\xdc\xec\x89h\x00\xc6\x99\xc8\xce\xde\xf0\x8f-5)\xc8\xdbGH\x89=b\x8be\x13q\x0d\x08I\xfd\xe7\x88\xdcV7\x8b\xaa\xc8|\xf6N\xbbg\x84\xc3*\x02O\x18\x06R\xeav\x86\x89\xcdN\x19 \x08\xa7\xd2\x97\xb16}\x94\x92\xcc\x10P\xcb\xccw\xfa\x16\xbdEU\xb7\xc7#\xd0\xb7\xdajK\xb0\x16g\xe6\xa4@C\xa3\xf7\x10\x9e\xd3&\xed\x11^\xf1\x00\xd1/^\x05\xda0\xba\xe0\x13=\xbf\xd1>\xfb\x84\xf3\xb6\x12\xb5,'\x92\xda\xbd\x17\x0d\x9fu\xa1\xd54]f\x05G\xec\xc7\xdc\xeep8A\xe6\x85\xa8\xdb\x98\xf7*'\x03~\xec\x95\xd0\xfbTv\x16D\x13\xda\xbe\x04\x92/q;\xc6\xd7\x81\xd2*\x9b]\xa5u\xa1\x8f\x15\xb09f\x8f\xbe\x9d\xef7\xa1~?\xdc&\x88\x0b.\xa8bT\x91\xa3HBv\x0dR\x1b\x176\x84\x14\x81\xe3\xec\x11\xb79\x7f`\xddSg/\xf3DJ\x82\x15k\x0f\x1bk'1\xcf\x83&\xf3$X\xb1y\x04}\xc9\xb5f\xccb\xa3\xc9\xbb\xf7\xb1\xab\xb6\x98r\x1d\xf6\xe9\xf4\xf8\xb0\x17\x1a\x8c\xce\xbb\x7f\"~\xd0\x063\x8f\xb6\xc9\x1e\xd2\x80\xcd\x8e\x88\xc9n}yG\xe9\xb0\xd8K\xd7\x1b<\x9d>\xad\xea\xac\x11\x81\x02\xaf\xc31\xa6$8\x87\x10\xc5\x857\xf3\xe2\x01\xd8j\xe6O\x91\xdd\xbe\xba)$\x1d\xf8\x18\x1agft\x05~\x9ey\xfa\xb4\xa6\x8bu\x9fZ\x10Fm\xcaUa\xe2\x95\x04M\x9b\xbefw\x17\x9e\xd3\xea\xdd\xe0\x10]\xb5\x08\xd6\xa3\xd9\xadH1\xac \x02:\xc7\xe4Da\xe0\xd5\xd4\xcc\x91\x0e\xf0\xd3\xc9\xda\xd4\x97\x9b\x80Y+\x07\xd8;\xdb\x8az\xf5\x13k\xc2\xdd\xdd\x02s\xdbl\xb8\xcc\"\xe2\xae:\x14\xab\xff\x94\xe8X\xb9c\xde\xa2\xa1\xb6\xa8\xe3\x8d\xbet\x87r4\xaey\x86\x10\x16\xfd\xfe=\xff\xdeU\xf7\x9e\xf1ck\xe3O\xae\xafX\xa8\xdc\xf3cU\xee\xd8\xc5oVR\xe2/\xbd\x04\xca\x9a\xc3x.\x0b\xa7\xf2-\xa5\xed\xa2\xac\x13\x98\xad\xf1\xcc\xc3\xce\xb0\xbed\x0c\xcf\xdd$8\xf8\xf1\x06\x98C4\\dJ\xddCq\xe3\x99\xc7\xdb]u-mlZ\xb8?q\x04F\xf8\xf0\xdd\xbe\xd0]\xf5\xd4\x14$\xb7<\xfa\x927\xc52\xf3.\xa4\xf8\\+\xf1I\xe8q9\x147\xce\x7f\xcef?\xba\xce\xf1\xa5\xa5\x8dF\xf5\xaam\xfe\xa9!\x84\x05\xf9\x14Sp(~(W\xcf]u\x04J\xa0\xc2\xe81\xf9w\x95\x02-\xee\xb9\x0d\x1d\xe7S\x8e\xda\xd9\x85\\\\\xf7\xb4\x05\xb5\xd1Z\x1b\xa6\xe8BD\x0e\xc8\x19\xec\xd9\xec\xa4\x9e\xd0\xc4\x1fy\xfd\x9c8\x90\x07 \xe0\xa7^\x94$\xf2s\xfd\xec\x1d\n6\xe7.\xed\xec\xd7\xf8\x829\x00Z\x0f\x00m\x04y\x83\xf7\xc7\x9e\x82\xdc\xb1d\xd7\xcd\xbbOv:xtFz\x1cb#{\xcaTA\x80\xd9\x8b\x94^\x9f\x03\xbb\x8f\x04\xe1ynyhm\x1a\x1fY\x87\xc2\x89\xce\xd9\n\x82\xc0q\x9c\xcaO\x9c\xcc$\xcd\xe28C\xc7\xa8*\xe7T>\xa9\xbe\xe9\x08\xd4\xc4\x13zE\xaf\x02)\xde\x92$2\xd1\xc0E|\xd7\xc8\xa9\xc5\xc4CH\xd4\xbe{p\xbf\xcf\x92\xf60\x1aM\x18\xa9\xef\xb2\x9a\xd6\xef\xef,\x85\xa2F,\xc8ls\xa6\xe6i\xe1\xcb\xf1\x9f\x88c\xa8Ty\xbb\x10i\xadz\x9ca\xd5\xaf\xc3\xfc\x8b-^0\xa3\xe7\xe9\x17\x14\xe8\x8f\xae\xb7*P7\xd8\xcd\xe1E=[~\xfd\xdfo\x12:'\xc4\x95\xf9\x898\x11jG\xee\x0f\xcb\xd9\x19{kAQ\xf502\x1b\xfd\xe1P\x83\x19*\x10\x89\xf8\xe0\xa8W\xc8\x7f\xdf\xa6\x0dj\xbf\x0eV\x8f\x81\xbf\x18aa4K\xa5\xf9\xb6\xea/N4\xf0\xd9\x94\xfb\xc3b/\x0bG\x08\xbf\xd8[3\xf5\xc4\xdf\xef\x05R%#\x96\xd9o\x1c\xab\xfb\xc5\xbat\xdeZ6D\xc2_O\xbcz\x7f\x18\xd63\xb7\xef\x92\x7f\x83\xf8\x1f.\x9b\xb0\xf8\xf5\xe1q\xd7\xfb\xfb\x9e \xb0\xc0\xd3`\xc1\xcc\x0e\xfe~\x1f\x90s\xbc\xad\x99m\xc6?\xf9\x03\\\xe0\xac\xdeN\x7f\xf8d)\xcf\x10\xc4\x98\x7f\xf1\xad\xda\x94\xf2;\xe3\xfc\xca \x95\xaf\xec3%\xc2\x9f\xda\xef\\SuK?\xa0\xad\xfc[_\xda\xaf\x8f\xb5\xb3\xf7\xffd\x92\x00\xd9+\xdb?\xac<\x04\x83\xce\xf2\xf7M\xd3\xb4\xa9H\xa1\xfc\xe2\xe0\xb5)\xa5o#u\x86_\x9dl\xeb\x0e\x97F\xb7\xfe\xae\x89\x8f\x82v\xfc\xfb~I\xa2&\x96\xf8\x87U\x9f\x18\"\xe1\x01(\xf8\xc5Jy\xa1~\x92\xd6\xe4\x05\x7f\xdfJm\x1as\xb5\xff\xf0\xe5\xc1<\x8aL0C\x06mX\x92\x99\xb3\x1f=\xfa\xe6\xdcx\xa0O\xd1(\xb4\x04\xb0X$'$\xc6\x1c'\x0d\xb0\x87UV\x85\xc5\xb4\x85\xcd\xf9I \xd2\x1f\x10\xff\xae\xd9\xbe xG\x83}u\xbe4f\x831\x9a\xee\x08\x98\xb2\xf2}\xb0\xf1}3\xb45=$&?\x87\x042NA\x0bu\x0ee\xf1\x1d>F\xfb\xd0\xe7^_\x1cI\x161\x9b\xd3=\x96H\xc7^q\xe0}\xb0\xe8}L9Z\xd1\xf42\x03\xcf7\x01\xfb\xb8\xd4\x1co=\xbbW\x08\x0e\x14\xca\x13^\xa4\x10>uH\xf6\xe5\xb2O\xc2tG\xf4\x90[\x18\xd5R\xde\xf3(\xdav\x05%\xa5\xe4[\xca\xfdf\x00\xec\xdaR\xaa?7\x92,7\xb8]K+t\x96n\xbe\xe197\xdc\xd7\x0b\x83\xe8\xfb)g2\xb1b>r\x85BV\xbaK>!\x00\xa0,\x7f^3\x9a\\A\x82\xb6\xbe2\x8a\xe2\x11\xdd\x92 \x8d\xf1K\xb6\x8dh\xbb\xd4\xed\xe0DO\xc9HG\xc5\xb7$\xe8\xd15\xab\x1du\x1b\xc0\xfb\x81_\x8c\xf0\x0d\xcfhL\xb4\x19\xf4r$\xaa\x99\xe0\x01\xcd\xfa~\xcb\x87\xd4n\">\x85\x1a\xd3\x06c7\x1c\xaf\x0d\x10cU\xcc\xda\xa2u\xac\xf89CJ\xf4am.\xa1m\xaaD?\xd2\x86\x97S\x0c\x9a\x17z\xcf(\x7fcfe\xa5_,\x9d\xed\xe6K/OnU$\xf2j\xd5T5\xf2'/\x86\x1c\x92\xe5u\xd1;\x00\x10\xbf\xee\xd5mov\xdd\x0f\x10\xd9iK\x93Q\x9f\x9d\xdaS\xd8\xb7\xf9\x00:\x14?\xb2Y\xb3;H\x03\x84\x0dJ\xa1\x82\xe6b\xc3\xd34\xc4q\x00i\xc47\xad\xe3S}w\x10\x96I\xc5xm\xd2\xa7\x98P\xd4\"\xa5\xde\xa6%uR\x1e\x86Q\x18Vu\x051\xb8+ym)\xa9\xfdb1\x0f6\x8dU##\xc2\xc0\xa2W\x06\x0f ,\xbd\x8ar\xa77\xbc\xee:\x8a\xcc\x9d\x87\x8dY\xc2\x98IS\xaf\x04D#\xca\xee\xb4\x7f\x94\x05P\x98\x9d\x8e\xd9\\\xfc$7\xc8\xab$\xf2\xb5\x1a-\x8b\x94*r84\xc6\x07\xb3i\x01,\x03\xb5o}\xc2/\xc4\xda-\x0fcZ\x0d\xb6oe\x8a;\xa6p\xfc\xf2^\x03\xe5\x1eU3O\xe8\xa3~\xf6\xe3s\xb4\xde\xa7\x94B\xd0\xafr\x10\xbf\xca\xd9\x1e\xd8\x0c\xd4\xd0\xe6$\xc96,\x97\x08\xdfNB\x13\xa4\x89\xd8\xdb\x90i9D\xfe\xc0\x1d!G/ #H:2\x00\xe7bU\xa7Qww\xa2\xd4\xa5a\xc1Y\xb1\x14\x8d\xf9\xab\x17\xb9w\xed\xd8\xe5Hbv\xb2\x1f@T\x87\xbeH[\xfdw\xe6\xec\xae\xb71\xa3\x8f\x9bB\xb6\xdf\x89p\xce=\xc4\x18\xbe(=\xb4(\xc20b\xed\x03q\xc7*\xf8\xa0\x98\xfc\xdcf\xcdy4\xae\xe7\xdf'\xcc\xb0FK41Pd\x18\xc20 \x99\x1c\xfd\x90\x80 \x94\xcd\x94S\x8e\xcf\xc5A9(\xe2\xa0}|\xf4\x0d\x0e\xb8\x12(\xd2/&\xc6w\x1bV\xf4\x90`\x183\xba\xd1\x80\xa0\x89\x1f\x1bw\xd3]\xf7\xc3T\xb5\xd7j#\x0dBv\xc5\xe3;\xc6\xc67\x13V\x00W\xa7x\x8bz\x91\x9a\x17#\x89\x029\x89 \xe6\x06\xd8\xd0\x15\xa1 $?P\x0f\xc8!\x89\x8d\x15\xf5a\xeeHhC\x89\xa7O\xcd\xb2%q\x90-\xde\xf7\xf3qP*\xe4\x86\x8d\xb5\x85\xc5\xf3\xf38y|1\x03(\xcc\xf3\xcdv\x18\xc0\xc1 \x007\x8f~\x127\x99c\xb5\xee\x1f\xf8\xe4`\x99\xb0\xc4\x01$\xdb\xae\x0c\xe0\x16\xfaLsM\xdej\x88\x98\xbd\x97\x88\x05\xb3\x7f\x8f\xaeb\x89w_\xbfT\xde\xcf\xcb-\xdb\xc3\x94>\x08\xf9\x0d\xb4\xa5QB\xc7p\xce6\x7f\xdfbx\x10\x8e$\x9b\x17y\xa3KsHRA\xfb\x13 \xd5q-\xad\xc9\x8d\xe6\x10\xae\x99\x07\xb5\xb5=gy\n\x10\xa0\x9d\x98\xbe\xf6]q\nL\x199\xda\xd0v \x8d\xf0\x82\xc0\x15\x0fW5\x03W\xd5\\ \x96I\x85\xac\x8f\xb9\xf1\xfd1+\xea\xc5\xb0\x97\x8b\xc3\xf8g\xdd\x06\xcbmj'\x1e\xfaQ\xdd\xecq\xf9\x16WI\x849\xdc5\x1a\x1d\x00\x0f\xd6\x9c\x93\xcb\xe2aL\x88\x89I\x12AH\x1c \x04q*x\xa3iNOrW}L\xcd\x84I\xc3{\xba\x9a\x13\xc0x\x10hv\x17\x8f\x14_)\xcf,\x9a>>3o\xa2_\xc6}\x16\xe2g\x9d\x8f\xa5\xae\xee\x87\xfaP]\xff\x9c\\\xb7\xe2\xbb\xe2\x94G\xd5U\x80L \xc0\xcf\xd8\xeap|\xbf=\x0f0g\x1e\xf7\xed\x0c\xa5jXB\xe7\x8d)\xba?}\x10!~)\xb2\x88\x9f\xbc\xa6P\x04gU\xba\x8ck\x16\xaf\xcd\x86\xbb\xad\xdd\x02{&\xb5>_\x90|R\x00\x90L\x10/\xcfN2\xd7\x03\xe76\x83\xef\xb2\xa0\xa8\x8d\\Q,\xde\xd5\xb6}8Z\x15\x81\x13\x075@\x96,(\xe5$r\xd6\xf7\xf5\xa4\x02E\x93\xa4\xb7\xea\xeb$t\xac7h\xcd#%h\x94\xc77$\xb4\xfc\x1e<\xa4\xad@\xdb=tP\x0eO_{\x99\xbe\xa88\xa9\xa0Ax\xd2\x1d\xa4\xdc\x14\xabb%\x90\xd6\xf4\xef\x96\xfb\xecY\xad~\x14q\xaf\xc0\x8d\x8d\"\xe6\x86S\x1e\xf9\x13\xc8 \nXJ\xfd\x94\x89\xa4?W\xd6Y\xb3\x8f\x05`\xc5D =I\x15\x0e\x17\x92\x04\xc7\xfcu\x02\xd2\x14#\x19\x9f\x1c\x81e\xb1}E5\xe0C\xec\xf4\x1b\x88\x8f\xccS/{\x90\x18\xdb\x01\xa9\xe4E(JK5_})\xf3\x8b\xe2\x9f\xd1\xf4\xaf\x88i\xc2\x88\xff\x9e\x04gJxc\xdb\xfd\xaanzs\xb70\x0fA\"D\xd2\xc9\x9c5g\x97_\x18W\x8e\x0c\xa6\xf4q[\x8ca\x98\xc7\xf1\xc3\xcf\xb5q\x8dEj\xc3\x15\x05\xc878\x14\xa0\x9d}\x97\xcd\xd76\x9d\xb2\xbf\xf7Q\xc5rdi\x901%\xf4\x02Uf\x9cc\x19\x05\x90\xbd\xee\xbd9\xe3h;\xfb \xa7(\xf7\xc6\xc3\xc6-3\xc2\xe1\x13=\xc7\x90\x13J\x95\xda\x18\x9b\xa6\xdft\xc7i\xf6\xaaJi\xc7b\x14d\x97\x82R\x83m\xd3\xc5\x84f\xb6\x92\x96\xaa\xbc\x14\xba Db\xe3\xaeLC\xc6\x12\xa8\xfe\xca\xc4\x95\xed=\xc9\xf6=\xad\x91\xfc\x04y\xdd\xfar\xa2\xdd\xd6%\xad\xde\xc7\x01\xd9m\xca\xb1\x11\x96\xd1\xbb\xebb\x9f\x82'\x9f\xce\xdb6\\W<\xff@\x1c`\xfe\x99\xb5\xfcqG\xd3\x15d\x99yj\x8b\xfb\"\xfe$i{\x91\xeb\x8b\xf7\xbdO\xf2\x98]\x8b\xc4J\x9e\xc7<\xb3n\xb2\x1a?\x84j\xe5\xa3\x97\x9fA\x18-WO\xb0\xae+\xcc1\x19\x05\xb9\xfb8` \x10\xc5\x9f\xfd\\?\xd6\xce\x9c\xde0e\x93q\xda-\x01\xd2s\x14=\xd27\xbd\x1b\xd6\x00y\xdf\x02\x98\xf3B\xdci\x08\x93\xe2\x98,{H\x00\x93\xba#&#\x93Z\xa0,;\xbb\xb1z\xff.\x84\xa7Q7O\x17\xd6\x97\xd4\x13\xbb,nHD\xe2\xb3\xa1\xf2g\xd1\xa2\xa1\x00\xb6\x82\x9f\x08+XmyE\x1e\xbe\x06\xfc\x9e\"\xc5\xe3!4$\xda\xf7\x1d\xc1A.\xbcI\x11\x1a)\xb4\xabjn\xa4!\x91\xc8R?\x08m\xf7nB\x0b\x1c\xd9\xf8\x06;\xd8\x8dv\x18\"\xe3\xf99s@L\x1dZ\xf0\x02T\xa4\x8e\xf0A\x99\xea\x9b|\x8f\xc1/\xc6\x89#\xe4@I\xa8\xa3\x91\x0c\x10\xd3'\xee\xce\xfd\xa3\xc9\x9e\x00\x9dGs\x14\x9d\xfb\xabcJ\x7fS\xcf.b\x99Z\x06+\x91\xc5i\xb6x!\x0f\x95jY\xdeN\xa2\xa7oRd\x1f$V\xdd\xb2\x04\x1bG\xce\x84\xaf7\xd5\x14\x08NP\xebC\xbeS\x13\x0b\xee\x0dG\x16\xc5i\\l\xe1\x17\xf2\xd42\\\xa3o\xcds)\xa8\xf1}\x1b\x1bI&\x1fH!\xfa\xe0t\xe7\x9c1\xd2e\xee\xb5\xe5d\n\x11-\x89QMkw/A\xe5\xac\xb1u\\\x91\xca\xdd\x12\x1f\x1a\xc1>\xf8:\xdc\xdb\xcc\xe0W7j~ \x00\xc1J V\x9c\xbb\xe1\x962\xce\x05L\x12RK\xf8b\x08\xdb>\x80\x1b\xfa\xd85 \xc3_\x1cz\xbb5\xbf\xf6|\xb0Q\x10s\x17\xdd+\xa8\xb3v8\x00\xd8\xebTJ\x8c\xed\xbb\xa9\xe4A\xcf\xafZ\x97\xa2\x17L\xf6\x81\n\x04\xc6\xd9 \xd4y\x06o\xd8k\x8cE)\nV\x0e\x99\xab\x8a\x8e\x85w\xed}sX\xf0\xcb\x13\x11\xcc\xda\xb2\xef9\x1d\xb6\x0e\x90[R\xee\xf8\xc9\x07\x94\xe1\xda\x02 \xaf9\xca\x05\xb8\xb3\xb8\xfaU^[0\xdb\xa9\x8a\xd6\xcc \xd4=~\xcf\xb0T\xf4\xb2V\x11\x01\xcb\x12kL]\xbf\x0b\xe1\x12dM\xe6\x98\xde\xd8O\xbe\x84A\xa8\xfaG\xad\xb7\xa4DP\x17\xf8\x86\xdf\x97\x06y\x1e\xc4o\xa6jJ\xf8$\xb4\xf8K\xca$\xc4\x86~\x9a\x03\x00O\xceTg\xdd\xa9\x00\xe97\xea7 \xecz\xa7\xda\xb3\xf7\xf6]\xc3\xf1JE\xb5z?\x04\x93\x93\x19\x9d\xd1\xd4\xe9}j\x0e\xfe\x82\x1d\xde\x8bcy\x19\xb7bO\x86Gt$\x9e\xa5\xf5\xb3P\xc0\x84\xc0\xdbr+\xa9\xb4\xb8\xfe\x0d\xb4f\x84L\x859<\xb2m\x8c\xba\xb4\xd5\xc0\xbd=\xc6w?Fo[\x17\x0dO\x07 D0\x00Qg=\x84\x14\x10D\xdd\\\x9a\xa2\xf7B\x11\xed\x95\xf1S\x8b\x1e\x88\xc0p\x93\xbe\xc3\xe2lRDTr)\x11\xbew\x93\xc6\xb3\x0b\x8dk\x91\xe7\xc4\x96\xbcP\xdf\x9e\x08\xbe\xd03\x92\xa4\xbbB\xc0\x1a\xc0\xe5.|h\x16\x12\x92R\xf9b\xe6\xb0m\xbaEP\xec@@\xa2\x9c\x1bfUNja\xcf\x9f\xedlS\xc6\xb7l\x16\xbb\xa6\x8f0\xaeA\x9e\xdb,\x9c\x88D4\xac\xed\x91\xb5\x16\x0d\n-AY\xd0\xb6\xe5fv56\x88O\x06\x15\xf8g\xac\xb5/\xc2\x87~1Q\xa0\xfbBx[j\x1c\x13`b\xe0\xf5\xb8\x15rH\x04\x91\xb07W\x8d\x9f\x9f:~\x05\xdf\xf8\x8b\xdd\x01\x9cE\xf33\x07\xdf\x05\xf6\xbeq\xad\xf8)U\xef\xd9\xdc(\xfe\xbd\x19\x06I\xf7\x92Kj\xe5T\x96w\xd7j\xdb\xb2Fz\x12\xf8\xf6\x15\xe0\xc3\xa8^h\xed\x0c\xa9\xd3\xd8+^\x02ym\x12\x96-\xd6\xb1\xa7\x06%RT\xf3'\x04\xc9j\xaf&@\xa6d\xc8Y\xc3p\xae?\xdb\xf7\xa1%@\x1d\x0e\x18\"\xe7\xba\xb9{W\xdb\x8a%\xfa.\xd2\xf7\xfeL\xe3j\xee\xf9\xa89\x86\x7f\x80\xe0\x83D\xbdpO\xd6r\xd4\x0cr\x85n0\x06\xe8\xc5!\xb9ZN\x94\x12,\xc8K/!e\x98\xbd-{$\x97&\xf4 !\xd9\x9a\xa2\xa5\xd9\\\x97\xc5\x99\xda\xe6\x10\x97\xdd\x94S\xfcy\xc7U\\s\x96\xe4hA_\x8f\x90Q\x81\"W\xc8\x81\x8fUb\x8aq\xb57.\xde\xc9^?r-`\xf9\xca\xc5\xd7U\xabn6\xac\xee\xd4\xcc\xa2~?5\x07\xce`I:\xabUPa\x98\\=\xabyy\x04\xb3-\xb6\xb72Im\xa0|y\x80\xd4\xc9P\xb0\xc2\x05D\x9b\x88C\xea\xcf:\x81\x0dS]\xdc\xa9m\x11i\x81~rL.0\x12\x13\xfd0\x02 \x98t\x9f\x89o\x18\xaa \x9cc\x9a\x0b6\x98x\x06\xa1\xb6\xa9Q\xa1\xac\x15\x82\xe4\xc6\x82\x0e\xb1\x94\xc6\x9cY\x16\x9cB\xa16\xc3R\xf57\xc7t%\x0d\x82\x156\xf9\xce\xf9\xa6\x8em\xce\x1f\xa66$U\xbc\xac)\xba\xb3&E\xbd\xed\x88\x93\xad\xd5n\x99\x136\xb8\x04\xd7\xb8\x8e\x88\xe7u2\xd4\x8cO\xe6\x96\x02\x7f\xb6\x15<\xc7\x1a\x08\xc9\x15\xf9X\xe0`\xbav\x00\x81\x9f\x15\x92\xddxvP\x14e\xbbq5\xd7\x94M\xa8\xf0Y\xc3\x9e\xd5\xc2\xcc{\x01>B\xb1p\x0d]\xd4\xfb\x00\x0cEb\xf5Z\xed\xeb\xae\x81\xfc4T\xfdh\xed\xd7\xbb\x82\xdc\xcdd\x95O\xf75\xfe,nZ\x00\xe6|\xefb\xce\x94\xd5\x16\x88\x88\xb68\xfb\xa7i\xb5\xb3\x8cP&& \xda\xeb\xc6uma\xb5\xfbl\x88(\x04\x05R\xf3\xa4ru\xa3\xba6)\xe3N\xb5\x16\xe2.@\x1a\xa0 !O\xeaAy\x17S\xb7!|\xf1h\x1b\xf0\x8a\xf3\x02?.\xac\x9a\xe2%:\x154?\x16)\x9e+\xe8+\x9ed}x \x95:G\x1e\xec\x1b\x14/\xd5`\x89{\xde\xb1\x99\n\xb1\xb7G~\x8d\xdceg\x94\xc6\xc5l\xef\xd6\xec2_\xfd(\xba\xa9\x86[\x01\x13\xb7G\xdb\xf2\x8c\x9f\x96a\x85\\\xf1\xf3\x8dg\xb7\x1bV\x8ab\xcb\xc2\xaa\xc3 [\xb3}\xa5\xd4\xf7S\x8a\x9a7~\xdf\x1a\xe4\xf0;t\xb7q\xb7lJ\x1e\xa1v\xab\x87J>hO\x0fa\x8dB\xc5\x12\xf9\x08fJqO\xb0\"p\xec\xce\x85\x01 nUD*h\xb6X\"a\x95R\xbb\xef\xbc\x1a5L\x17\xd2d\xa6\xcd\x10\xa3\xe1\x05N\xdf\xef\xb6\xcf\xe7E\xb1\x8d\x06\x97\xf9K\xfa`z\xb8\x0c`\x0b\xab\xb3-H\xc8\xa3\x07s\xbe\xf9\x85\xd5w\x0b\x1fW\x17\xf2aQ\xc1\x04\xc2\x8b\\\xd6q\xb7\xbd\x1f4\xbeWU\x1d\xa8Tn[[\xc9\xd1\x1f\xd4\xb1c\x88\xc0\xac\xb9\xec^\xc1\x83\x12\xd5\xdb\xc3\x0f\x95\xd0\x85\xba\xf9*P\x04\xf6\xf6\x05\x14\xd9|\xa3\xdb\xa9\x86\xae\xda\xef\x06:\x01a\x8b5\x92\x92\xc8\x83\x1e\xaa,\xd3'\x14\xdf\xca\xd8\xfb\x86\xbd\x12:\xdf\\\x9f\x82\xb4\xa5\x8b\xd4\xf3\xdc\xbd>\x98\xbc\xef\xdf\xae\x0d\xf3\xbe\xacp6\xdf\xa6\xfd\xbb\xa7SB\x9ab\x9d\xea\x10\xa0\n\xa7~U\x81i\x9a\xb5\xbf\xc3\x99@\x8a\x87..\x8f7F{\xdf\xf1R!\x9b~\x14\x7f\x93\xc6\xc3\xfd\xe0\x0d.\x18\xbc\xb1=F\x9f2\x9e?\xd6\xf8N7\xae\xac\xed7O=\xaf\xbc\xc0j\"U\x99\xd3\x9cL\x8a# \x92\n%\x0c\xce\xdc<$\xd8 \xac\xc0\xd43\xb1 \xb5[[\xd7\xef\xee_zv\xad\x13\xe8\xbby5~\xc4\x91B\x95ry\x83uS-\xe9\xfd\xa8\xa4\x968\xd6`-\xd1\xbeO\xa8\xf5\xbd\xce\xde\x0dG\x1c\xcb\xd4b[\xb7\x9b\xbe\xc6\xe2\xfa9\x8f\xf9:a\x06@\xf4)\x08,\xed\x88\xe5\x93\xd7M\xbc\xa3\xaaY\xd3\xb9\xbc\x0f\xa70\xa4\xae\xf5\x94\xa7\x87\x85\xa2\xd0+(\x07\xe3\x0f\xb19@Z0\xc8\xb1\x9bm\x9b\\-\x13\xa1\xac4\xae\xb6\xb4b\xbb\xee\x12H\xce9B8\x9bcp=\x90\x9d\xe6\x07\xec\xf1\xd1G\x85\x8e\xca\x92\xcd\xcf\xa7m\xf9\"\x12\x12\xb8\xa0\x00\xf3\x90\xd7uTu)\xfd\xf1e\xba\xa3\xbd\x8d\xc4\xab\xd4s>\x13\xe8\xde9\xbb\xe4\xab\xb3\xf5\xb8\xe4\xee,\xab\xcd\xb1^~\xad\xf7\xebh\x1b6\x87\x99\x145m\x98\xd1\x05!\x89\x99\x1c\xc7%P\xa6\xb8\x88\xecI\x98\xdf\x1fqT\x95i\x1d\xb6]\xbf\xf2\x92\xd7\x8e\xca\xa9\xb6\xa4\xf4\xeeV\xef\x8b%\xb8\xe3\xf3\xd4-\xa6\x0d9\xf8\x8c\x89\xf0\xa2\xb6\x95\xa2\x93\xab\xfd\xce\xb1\x9b\x8d\xefe\x85\x1e\xa2\xffT\xf3\x83?\xc0\x98\x16\xbd\xf7\x1b\xdb ,z\xdb\xbd5x\x1co\x8d\xa4+\xee\xd6\xae\xc7Me\x8dZ\x16\xa5)\xfa\xb3\x7ft;\xe8\x8f\xa1\x0e~2\xac\x0e\xb3@\x1b\xc9\xfas\xed\xc5-J\xf0W\x87\\s#\xd4`\xd4\xd6>\x1f\x07X\x85\xa5\xd0=\xd0\xf8\xa3\x8c\xab1\xa5\x05\x82\xe2)\xd4G{\xe2\xe3'\x14\xacY\xac^@\x18\x05\x9a\x89\x9f!\xec\xa3\x13\xf3\x86\x08\xc8<\xabZ\x8aZ6\x07\xf2\xb9\x7f\xec\x0f^s,\xcdl\x8b\xdc\xeb2\xb6uv\xbd(\xb6,\xed\xc7\xd0\xbc\xd8%\xe6n\x17)\x91\xa6\xa1l\xcd|\xb5\x02\xd9\xcb|?\xdeZ\xa1\x01\xf0\x93\xfb\x84Y[06\x17\xb4\xfe\xcd;\x10'\x15d\xcbi\xd2<\xa4\xd0\x1f\xb9]\x9a\x86\xea\x8b=\xd2\x90\xef\xfb\xd9u\xad;\xf3\xd7U,i\x81\xfa\xc1;\xec\xa8\xb52\xfb\xe6\xc1ZD\xd6\x8d\xde\xe3T\xcbJ\x85\x1a\xc6\xd2>RJ\xcf\x11Z`\xf2!\xdf\xa0\xbc\xcd\x87\xd2&\xc9e%\xbc\xd1\xb0\x85\xe7\xe3\x92\xe9\xc7\xfb\xcc\xb8<27*\x8f\xf0\xa8\xb2Z\x96VW\xfa\x8a\x01\xe97N\xa0\x01\x01\x1dv\xbe\x95c2\xcc\xd8\xbc\x03D\x94Vj [\xe1\xfa\x82;P\xe9\xdcW\xa3cf\x10,\xdb\x1c\x0b\xe1\xb3D=\xa8\xae\x81\x14\x05\xe5\xaa\x9d\x00\xb9/@\x85g\x9bo\xef$*\xc2\xc1\x1d\xda\x92\x84\xb2\xdf\x94\xbc\xc8M^\xfb\x8a\xed/\xfd\x12M\xc1\x9b\x9e\xf7\xbb\x11\x90\xb3\xb7`fS\xd3\xe8\xa8\x0c\x08?7f\xb6t\xd0\xd7\xec\xdc?{\xd7\x87\x11h^\x85\xcf\x1d\xe8:\x92 \x98\x1ca:\x17u\x8c\x04\x03\xbf\xcb\xce\xd6h\xc8\x16\xef}\x98(8\x06\xbe\x19\xac\x8c\xba\xfe\xf8lSu\xa3\xe9\xe0iy\xab\x87\xf4\x05\xe53\xce\xf1\x06}?b\xa4+\xb8w\x87RD\x13Z\x96\x96\x12\x1d\x04\xca\xd6\x87\x02\x89^\x98\xb6x\xf4\x19\x168\xcf/i8\xe8\x8a\xe1\xb4H\xb0q\xed\x18\xa9\xc8'\xf7\x8e\x83z\xddQ\x8cx19\xa8>\x94\"\xc3\x1a\xd5\xa7\x1d9\x18\x8b\xd8\xc4\xf5f=#\xa8\xe3\x13\xfaf\x80\xe51\xba\xea8\xee\x1fqh \x9fW\x95\xbe\xdb$r\x06\xe6\x8c\x97Y\xcc~\xf1\xf2\xaa\xdb9\x9b:F:7\x03\xe4\xcdij\xce\x8e\x01\xeey\xd7d\xba\\\x90\x98\xfa:FC\xf8\x8e\xab\xb2$t~\xc3d\xb3\xb7\xb5\xc1\xcdG\xe8\xa4\x0c\x88<\xc5Z\xd0\x03\xe1WZ\xbc\xa2_\xa8\xd0\xc0\xfb\x0es)\xc4\xc04nQ\x82\x9f\xa8;\x9a\xb9\xb6\x9dL\xa06{xQ\x06\xdc;\x1f\xc5\xea\xc1\x14\x0f\xdc\x85\xba\x8a\xb9\x14\xc5\xeav[B\x15Jp>Fe\x83N\x07\x8c\xf4\x85\xf6\xd0:Z\x96O\x12f\xe6\xc8\xff\xe8\x07eL\xfcC\xdf\"9%\n\x93~\xa9p\n0A\xe4\xdd\xae#\x04\xb7\x06ih\xb3\xf4epQ\xc0\xc5\x9d!M@\\\x81\xd7\xb1\xc4/\x99\xc0\xb2\xed\xc3\x13\xae\x9al\x0f&1\xdd\x98C\xbc \xbd9\xb6-\x89\x10\x01,\x9d\xdd\xefB\x80\x83\xb2\xbc\x07\x03G\xf6g\x1bWA?\xcb]\xbdwA\xc5\xe0O\x07\xaax\xa4\xb5!+F0\xf9\x90B\xef\xd6$\x00\xdd\x06\xf7\xe5\x94\x9b\xba\x88\xf8\x94\x95k\xc1\xe5<'\xc3\xe7\xdd\xc65\xc1`\x08\x17\xa3\xef\xd9]Z\xc4!h\xc5\xd8p\xbe\xac~\x10GW\xe4&\xf3\xa5\xd5\xfa=\xee\xe9W\xd74o\xa2=\x0f\xaag\xe6a;P\xc7ciZ\x14\xd4\xc09\xa9%\xd51\x83\xc2G\xd0\x94W\xb3u\xb20\x85\x1c\x19\x1a&\x03\xe1j\xb7\x95\x8c\xb1\xc8\x1c\x8d\xc0\xebl\x99IslL\xd6\x84m1:\xf6\xd2\xbd~ir\xe1\xc7~\xd8!\xb0\xda\x1dZ\x97^\x7f\xca\xac\xf1&\xfcD/X2\xcd\xc0P\n\xa9~\x00Xo\xe1\x0d:\xf2m\xaaN\xcd\xea\xd8\xc6\x14\xc1\x1c\xf5\xfa>\xbd\x1b \xe9\xf3\xa9\xaf\xea\x1c\x93C&\xddC\xc8Q7\x9f\xed\xcb}[\x04\x03S\xeb\x91\xdaP%\x0f\x83@\xdf\xfa\x04\xde\x7fu\xcf\xe2N\xe4\xfapt\xfe\x9c\xf9W\x05\x0d\xeb\x80\x80\xa9\xac}\x87\xf0\xe9/\x0e\xc5\x0b\xedDGzTY\"\x01-I3\x85\xb2vb\xfa.\xf5^\xacc\xfa\xd2:0\xa47w\x87\xac\x10Q\xbbR\xef6Ao\xc7\x90\x88gd4\xb6n3D&\x08\xc2\xa0\xda\xdf\xeen\xe9[\xbb:Z\x05\x90e3+\xd0d\xf0~\xbf\x97\xe2\xf80\x93\x96]\xac\x1a\x99\xe3\xaa)\xb8$\xa6\xa9\xc8\x87~H\xfb\xb5\xf6\x85+\xc1/EwI[\x02H\x165\x8ekLaeF\x9b\x02y\xc0\xbd\xcf\xf9\x01\x8ctiY\xbd\x81\xb9\xa1\x15\x8b\x9e;\x8e\xae\xdf\xc8\x9dA\xdd\xd0\x89\xdc\xfc\x91\x81>\x15\xb9lD\xbb\xb9\xb1=Ez^\x0c\x99\x05\x13\x8b\xd8\xcc\xe9x\x07\x16\xb9l@\xcf;\x15\xbb\nKP6\x19W\x13K4\x8b\x83 \xebm\xde\xc9\xc5\x0caU\xec\xb2r\x9d\xb6\x07\x94Ps\xcf\xb9E#\x06\x9d}W\n?%{\xf9\xe4\xe4M\x87\xf0\xe9\x82e\x99\xcd\xf7+\x08\xab\xd7>_\xc8\xc5\xe6\x8bR\x87p\x0c\x02\x17\xff0\x11\x9d\x13\xfe\xaa\xac\xca\x02\xb1 \xa7Z\x01^\xf0\x9e\xc6\x15\xe1\xa7\n_\xad9\xeb\x0e\xd6o \xc7\xfd\xc9y\x08\x8f\xc0\xdf(]\xd1\xcb\x97\xc4\xd6g\xd1\xe0\xe39\xdd\xa5\x97\x89\x1f\x96Bn\xbc\xd0\xd4\x15{\x98\xfbyl\x1a\xcb\xac\xa4!V\xe4\xf9+\x15\xb2\x13\x0c^\x11?\x97\x87\x84m\x01|\xbd\x9f\x9c\xaf\xdd|\xe44\x7f\x1co1)\xc1\x95,EO\xeb?\n\x0c\xfd\xbd\x1f\x9e\xa9\xb6\xedg|\xe3{mqU\xaf\xa5\x9f\x10\xf7\xfc\x7f\xdcK\x17\xd9\xb9c\xccUp\xbf\x1d\x0e>\x11\x16\xf3\xef\xfd\xf6\xb3\xc0\xfb\xe5\xb5\xc6\xcer\x16\xf0\x00\xe1\xdf\xf3\xe1\x84\x9a\xf0\xa9,\xf1\xf3\xe9\x9a\xe6\xf7\xfd\xb1\xb7\x17\x97~\xe7.\xb3\xb7\xb5\xc4\x9a\xe2\xbfw\xd7b\x94\xd0Q\\\xe4#*\xc4\xbf\xf1\\\x16\xaa\xfc\x97\x7f\xcfU\xb5\xc37\xab\xff~;\x9f3\x08\xfep\x00\xa1\xda~\xf4O%vn\xcde\x84\xc6\xfc\xbd?\x1f\x1e\x18\xcaX9@ \xc1\xc4\x94\\9\xb9_\xee\xac\xcd\xab$\x82\xcb\x0d\xcc\xc7\x99:\\G\xf5\x97\xb3z\xa0\xcb\xf7\xc4\xbe\x88S\xef\x87*\x16Z\xf1B\xc9?\xdd\xbcW\xdfhv\xfaJ\xbf\xdc\x95\xab\xbc%\x85X\xe5>'Qr\xdbi\xce\xbfu\xcf\xed\xd0\x11\xc8\xe2\x1b\x97\xc8M\x9a{0\x12a\x17\xff\xfd\xb6\x0f_/\x1c\xea\xd6\xc5\xefg \x1e\x13\x90V\xef\x0f\x8b\xe0\xc1\xd0\x17\x03\x02\xe9\x11\xfe\xf8A<9x\xb7\x05B\x10A\xc4t^(\xda\x7f\xef\xda\xe3\xde\xe7Y\x06\x98\xd0 vZ_\xd4#F.e+\xf8'\x83F\xcaU{-g\xb3\xd2\x90\x93\x8d\xd8\xc5D\xf5O~YBA\x80\xa8\xc8*\x1a\xba\xbe,\x7f\x9cl[uhZ\xe2\xc6\x0c\x08\xbb\xde?\x1c\x84\xe9\x98F\xb1\x94\xee$(\xad/j\x08c\x96\xe2\xff\xe4\xbf\xc5*\xbd\x86\xb3\x1f_\"\xd0\x92\x82\x9e\x96\x7f\xfb:\x87\xad\x93\xbc\xb0\xa3/\xee\xfd\xcb\xdb\xec\x85\xeaG_ .\xc1\xc7M\xc9\x9f2b\xa3\x7fs\x19\xca\xf3C$r\x12K\xc6\x16r\x93\xe6\x18\xd8\xfb\xb6\xff\xec\xa9\xed:w4\x98\xcdL\x83M4\xac\xf2\xff\xf1\x19\x15lfz\xb3\x95Z\xe2[\xff\xe9\x88\x84!\x8f\xf5T__\\\xf5\x9d\xd4&^\xff\xd9\xa5\x1c\x1b7\xe7\x8e\x81\xebi\xbe>8xGh\xff\xd9e\x7f\x8d\n\x0b\x9c\x81\xe2? 7t\xbfvc]9\xa7\xf1L5{\x9b`>\xec\xbe\xac\x7f\xfa\xc9\x8a=\xfaY\xffR\xcb\x8b\xc7\x89F\xb0\xe4\xcb\xc3X\xe6\x9f\xfd\xf8\x8e\x1a\x0f\xf2Dj\x96h\xa8\x85Kg\xcf\x1f\xd6C\x97`]\xbe\xe0b\xe4*f\x84\xd6-\xfc\xe52\x19\x86<\x84\xca(\x95\xbdK\xf0\xca\x0d/\xb3\xd7\xfb\xe39\x95\xcbA\xae\xee\xfej\x13\xa5\xf8\xc0\xa9V\xe4\xbf{]j\x8b5\xef\x9cC\x14zR\xd4\xd11l\xbf\xcf\x97\xa7\xec~\xcf\xd8u\xd6n\xf82F\xe4\xf6\x7f\\\x87\xd0\xe2\x9c_\xfe\x8c?\xa8\x07 V:\xb9\x8d\xf8\xef\xef.|K'\xbc|\xb8Jz\xfe8\xe5<\xd7\x8e\xa8\x8c\x0c\xfb@\xc1$\xb4\x19\xb8_\xec\xc7\xe4}.\xc8\xc5\xb4d1E\xa1\xd41W}\x9b\xfe\xa1\xdef\xfe\xfb\x9f\x0d\xa5p-\xc0\xbc\x06\n\\\xe8n\xf5\xc9\xf2\x91\xbf\xb5\x05\x8b \xaf\x91 o\x1f\xb6b\xdf\xd7\xfe\xcf\x17 fo\x90\xae\x81\xa4\x10Z\x7fjr\x81\x85\x00C\x94~\xc7\x92\xa6uBw\x81\xd8\xbbK4\xcc*\xd4\x02\xf8\x8f\x0bn\xed\xae2;/\xf1P\xf6&\x0f\xff\xd9\xa5\x06E\xc8\xad^c\x18\x97\x1a\xe4\xa8\xdd\xc0_\xd2'\xf8\xdb\x87\xbc\x03\xf5O^\x06q\xccM\xd2\x95\xc1V\xfe\xc7=\xea0q\x05\x07\xa8G\x08\xe3\xa3\xfeoO\xddr\x7f\xf95\x84Q\x0f\x16\xb8dE@\xce\xa9\xfe\xd6\x9f\xf5\xb6\xfd\xfag\x00\xc7\x1e\"\\\xbe\xd57\xbfX\x1fw\x15\x91Rh\xb2ys=\x89\x92\xe6\x06\xfaO_\xf5\x05\xbbGG\x86a\x08\xdd\xad=\xb0]\x0f\x98\xff\xf9z\xfc\xbd\xc8\x08\xab]\x92\xe8\xf7\xd6\xe7c:\x1b\xad_=f\x9b\x0b\x11&\xa3\x82\xde\xf03\xbe\xf9\xd8\xdf\x05\xff\xe3\x07\xec\xea\xfb\xcb\x9f!l{\x93p\xa5\x18\x14\x0b\x7fX\x9d\x8b\x1d\xddE4\x89\xc8ig\x863\xff\xe9D\xc9r\xc5\xa9\xfc-\x87w\xf6\xa7f\xa0\x98\x83\xb0\x8a\x7f\xd8\x9b\x84\xe4\xdc1q\xfdmT\xb1\xf09\xbf\xe0\x7f\xf6\x90\xed\xac\xbf@\xccL\xe7\x8b37\xf0\xd1\x07\xffx+\x056'j\xc9\xad}\xe8\xfc\xd9\xabhb\xa7\xbf\x98\xa8\x0d\xec\xbe4$\xf4\xd0\x10mr\x93u\xaf\xa2\xd6\xfc\xce7,\\{\x19\xb8Y\x0c\x05E\x7f\xa2\xc8\xed\xfe\x8d\xb1\xb2\xe3#\x85\xeef\x9aD\xdct\xfe\xc8I\x85\xfa\x0f\xa3$\xa7H\xb62\x1fi \xc8\xbd\xf2d\x03t{[U\x9d_y\xaf\xbe\xbb=a:\xe6\xc7\xf5~r \x80\x89\xfb\x7f\xba1\x0b\x16\xf6\x8a\x9d\xd2L\xc7l]\x9fC\x19\xe0\xdf\xfe8\xf0\xa3\x00\x9d\xa3\x17A\xe4\xf46[\xa6\xf2\x7f\x18\xa7\xc3A\x99/\xa1:\xe7\xc7\x8d~\xe4\xd9\x17\x0fi\\\xf9;G3N t\x814#\x82\x87U\xef\xe8\xb8\\\xce\xb8\xe1\x8d.\xfe\xd3G(\x1a\xb7/\xeaW7\xe8ep6\x86\x12\xf0\x80\x0d~\x89\xff\xfd\xbfK\x0fQ\xe0p\x1d!\\\xae\xd55\xffxC!et\x17\xf3\xd6\x02\xa6\xb7\x990\x15\xff\x9b\xcb\x10\x06\xc1\xa5\xceF\xb9\x04\xf1\xd3y\xe3\x8c)V\xf2O'\xcd\xfaz%!rb\x88)\xc5[\xb0\x0f,\xfe\xf2\x83\x0f\xd3\xdaL\xda\xc3\x8b\x19\x16\xb4>\xabFN[\x88\xd7\x9f\x0foNr\xf9\xb0\x12\xb9\x9aXiB\xd0~\xd9\x18p\xb4\xbf\xdf\xe8\x80]\xaf\x01H\xb9K\xb8\x1b\xa5\xea\x87?;\x0e\x11\xc3f\xdaPZBh\xf5\x7f\xf4\xe1x\x9cZ\xfc\xc3\xa7%\xc1\\\xd5\xc1\xd6\x9aJ\xf0\x94\xaf\xf6\xa8\xce\x1f\x06,h\xc2s |\xdd\xcd\xc4\xe1o\x0e\xb1\xd1\xc9\xfft$\x08_\xea\xc0}i\x0b\xb6V\x9fA&\xc8\xba\xff\xc6\xfa\xda\xd5o\x8b\xb7\xfd\xa7\x9e\x8d-v#\xb4?\xdb\x14c%\x0cfm\x0e\xa5\x05\x86\xd6\x98\x93\xed\xbao\xfe\xc9\xe3dPx\"-0S\xae\xe2\xa4\x00#X\xe9\x0f\xe7\xb6ul\xea/\x9b2\x94c\xae8j7\xf3\x8f\\u\xf3\xbf\xb1\x1e\xb9~\xfb\xd1 V\xef\x15\xae\xd0b\xca\xf0\x9f\xfe\x86\xf5\x90\xc3 \xd5\xb2\xd8\x13\x0feH]\xfby\xfe\x8bg^\x8c<\xe47\x1fEu\xe5\x10\xf9\xba(\xb6\xf9\xab=\xb4Ah\x97\x81\xf8zcX\xad?\xf9\xe9`\x0f\xc2\xf8OO\xceT\x8fs\x96A\xde\x08)\x05U\xf7N\x1d\xfd\xce}\x9b\"a7\xae \xba\x06=I\xd5\xd7DY\xf7?\xff\x11\xf6\x01\xf3\xf2a\xe7\xda\xe8\xa4\x7f\xf4\x0b?\xc1\xaf\x9e6\xc7\xdb\x02O\xde\x82\x9f\\\xae]\x0c\xf1_\xad\xa0\x06L_\x0cZYJ\x8b\xaf-.'\x1ba\xa6\xb6\xbf\x1c\xbf\xd1F\xbd\xb3\x17km\xcc\xc5\x0f\xfb]p\xe6_\xbd\xd4\\\x85$\x04\xdark\xa2\xa1\x0ci\xe2hf\xf4\x17S\xfb\xd8-\xd0\x11\xda\x16_\x13\x0d\xb9\x98F\xfb(\xf8?\xde_3\xcc\xc7/\xcc\x9d\xe1\x8f=0\x15\xbf\x07\x01\xf1\xd7/\xe0\xe6\xcc[7\x03w\x0b\xb4\xa4\"\xe4\xc7T\x96\x7f\xf9\x9b>Y\x07\xb3\x9c-\xf4\xab@KR\xed\x15\xeb6\xfbo\xadr\xbf\\\xe4\xbb\x85\xc1>r\x93lE\x7f|\xcbj\xaak\x16w`\xa6\x03\x05Z\xb2J\x0f\xdb/\xf4\xfb/N\xf3\x1f\xa0(\xd7X\xd9\"\xa7\xfd\xaa\x8fd\xff\x17G\x88M\x95\x1b\xcf\xd1\xc60\x1c9D\xae\x1e\x1e\x89\xff\xe4i\xee=\x92!\x12HK\xc1\xc9\x86\xea\xb7\xba\xf6W\xeb%/&\xf8\xcb\xc8\xa5a\xbe8\xf8\x80\x16\xc53\xfc\xd6ob&{?\xe3S\x81\x96X\x98tL\xc1\xf5[C\xec\xc4\xdb`\xbb\xc5\x07\x8a\xc9M|\xfd\xeeP\x82\xff\xdaG\xb55\xfb\xeb\xacu(-$\xb7\xd6\x9cl\xcc\xccr\xfdg\xdf\xf0\xc3i/?\xaa\xf8\xe9u\x82\x17, e\x0f\xcb?\xfe\xd0\x922\"\xaa\x81\xddJw\xd6\x96\xec\xdd&o\xff8GWnJX\xfc\xc9\xdal\xeb\xcc%x\xaa@\xfd\xd5\xff\xb4miUT\xcb\x1c.\xd1s\xbe\xa0\x83\xc2\x08\x88\x0b\xbf\xf6\xd9 \xd2\x9b\x8f\xa2~z\x88\xe0\x15+\xfd\x08\xa3\xeb\xff\xab\x7f\nQ\xaf\xf4h[\xcf%x\xe8'l\x9c\xcf\xbf\xf5\x8b\xe2\xa1XGp\x86\xf1\xfeS\xbf\xec\x0cg\xf5\xe8\x9f\xef\x9b\xca\xdeS\x10\xeeFdp\xb5\xfax3\xc1\x7f\xba\xf6OL\x92^}\x14\xf7\xd3\x03~t]\x9b%\xd4\xfd\xf2\x1f\x87z,\xab&\xda\x85\xdb^{\xb21;d!+\x7f1\x90\xcb\x1bqb%\x91\xd8\x18\xf7g]\x92#\x00MR\x87\xbf>\xe4\xee9gs\xea-\xd8A\xdc\x19\x9a\x12p^\xd1\x98O\xf9\xaf~\x84\xa5vV\xdf9v \x89\xc8\x84\xa7\xfd\xcaPF\xec\x94\x15\xa9\xbfu\xd9@\xab\"\xdc\x1e\xe9\x12\xfe\xd4\x84m\xc2\xc7\xd8\xf9g\xcb\xed\xbef\xe88g\xcaG\x12\x0b\xbd\x801A\xfe\xf3Omp\xe7\xc9\xd9\xb9\x03\xf2.\xe3?\x9f\x19\xfe|F\x0f4\x154.H\xeb}\xf6H\xb3\xc2\x9f\xac\xfc\\N\xb0\x11)\xb5\xfc\xd3i\xa8\x9f\xa0\xb7\xaaUd\x04\xdbfK_\x97\xbf0\xd6d\xea\x7fz\x0d\xa5\x9b~xZ\\\xf2u\xcb\x7f\xe6ta\xf2\xdd\xc6\x7fve\xc6\xd9 \xaa\x17$t\xa6\x9exx\x82\x89\xf2_-\xa4\x0d\xd1\xbc ;k@\xe1f\xe8?s\xea\x92\xac4\xde\x9f\xbd\xf7W\xf8K\x94%\xa6K\xd9\xfc\xe3\x94\x90\x1a\xb3\x88\xf8O\x9e\x06Gn\xb9\x98BvQ\xf7\xd2\x9f\xcb\xc97\xf5\xc5\xd8\x99\xfa\xc7\xbb\xael\xfb\x87\xc6\x8aT}\xf3\x8fwXH\xa14\x9e;\xca3[\xfc\xadW\x0d8\xaa\xcc\xd9\xde\x88\x9e\xa3\xa4\xc4\xb4\xa9[\xef_~\x9a\xbf\x8d\x06K\x8b\xf6\x13\xc7=w\xd4@\xc6n\xa4s\xf9\xe5\xd0\x93\x97\x8c\xf1\x89\xa8\x07\x11\xb1\x8d\x99\xd9+\x96#\x11\xb2\xe0\x8f\xa7\xdeU\x0d\\\x0b4Bu\x06\xb8QJ0X\x139\xf8\xab\x0fGg\xdd\xa8Y\x02\xce\x8dmk\xf63\xac^X\xf1\xd9\xefy#\xe4\xab\xfa-j\x02Y$\xb1\x90\xbd\x193'\xe17\xe7\x8dp\xc1\x80\x0f\xc6\xf5\x91\x8b\x7f\xf8\xcb\xddo\xb1\xc0\xe4\x9c\x8a>\xbf\x18\xf1\xadX\xa8\x02e\x8co\xcb_\xeeMp@\xa4\xfd\xcc\xc3'S\x10a\x18&\x1a\x87Qp\xc2\xdfym1\xe9\x18\xe4J\x88\x85\xf4\x95\x19\xb2\x94\xfe\xd5\x03c\xc9\xdf\xe9$\x12d\xe2 \x85\x83+/'?\x82\xa4t\xef\xbf\xbc52v\xeb\xd8[\x1e\xe7c\xa30\xd0\xac\xc9\x05\x1b\xff\xd5B\xda\x10\xdc\xe3f\xafy,q\xaf\x80\xa1\xc6Q\x88\xc5\x7fk\xd5J\\217z\x04\x05\xe7\x87]1\x01\x8e\xc1P\xef\xa3\x0e\x9d\x17\xa2\x1c\xff<\xf1c\xec\x18'\x16\xea\xeb\x8d\xe2\xc5\xe6\x90\xf3\x17\xef\x92\x98 \xd1\xef\xb1\xcf?gP\xd2\x101\x16\x0f\x82_\x9e`O\xa9\x12\x16\xfcD\"\xcd6:\xe68\x83\x16^,\xff\x88|\x10\xfe\xf1 \xfa\x0f\xe2\x92\xdeF\x98\xce\xbe\x19n\xceq\xc1\xea\xe5\x8dgu\xff|\xbb\xbav\xac\xd6\xde\x8b\xcb\x19\xc7?\xb0\xf8e\x08\xfc\x17W>z\x94\x85\xf1\xb6\xfb\xcagv\xee\xca+t\x01\xe4\xf5\x99^c\x8e\xa7\x88\xf4B\xfc\xadw?\x07\xed\xe6]\xf5\xf2\x0f-\x1bkK\x10po\x98\x15\xbb\x10\xde\xbf\xfd?\xdd\x10D\x84t\xe6\x1f\x1d+}\x8aG\xd9Q\xfcwn\x14\xc3\xca\xeb\x8b\x01\xf6<,\x9d3\x15\xe9 \x8b\xb50\x91_\xb6?\xd9\xfd8\xd87\xd7\xaa\x91\xeb\x88g|\xff\xc8v\x87]I\x00\xb9\xfbO\xb6+\x93\x85\xe1\xaf\xd0\xb7eQ/\xbf\x85/\xa6\xc2\x7f~Yj\xc1\x9c^C\xbfC\xe4\xe3\xce\xdc\xf7\x1e\x96\x83{\xfea\xf37/\x0d\x18\x92\x9bFJW\x9cr\xd8\xbc\x83=1\x9b\x17\xc2?\xce\xf7\xd4xY\xfd\x01e\xf4\xda\x99\xf7\x15\xe4\xd4\x1b\xbe\x8c\xf6\xff\xf1P\xbdb\xac6\xde\x93\xdf\x05\xa7<\xc9\xb9a\xff\xc5\xc3\xcdQ\xafD\xa7\xbe+q\x90\x92\xab\x89v\xa6\xae\xf1\xc4\x1b\xfe\xd8\xa5\x99\x07\x99G-\xc2\xb2j\xe3\xb7\xddY\x149\x18\xfa\x9f\xedm\x97|+\x9f\x83\xdc\x18\xe7l{\xc0\xddg\x9aRs\xe5\xfd\xeb 4\x98'k\x92k\x81Yt\xf2\xca\xc7\xf2\x13gn\xce\xfd\xab\xd1\x1d\x18\x16\xea\xd7\xc8M\xe2#\xe8I\x02\xcd\x1aR\xb0\xe8\x9f\x0f\xe9\x81a\x82\xe6\x87\xda7\xc59[\xa3\xc6\xc4\xe9_|[\x95,\xf1\xfdT\n\x17\x00\x7f\x91\x96\xfd5\x98\x08\x13\xe7\xf63\xfe\xc6!v\xcdY\xf7uW\xee\xb5\x9ak\xa1\xd2\xa7\xd1\x87\x1dl\xd0\x872\xdcp\xfc\xfd6d]2\x86\x87\x1b\xa9C\x8cV\x179\x8f\xea\xee\x86\x82E\xebg\xce\xdb6\xdb\xea;\xc5.'\xc2=\x16=\x95Qp8\xe4\xfc\xde\xeflQn)\xee\xa1o\x10\x038p\xc3\x91n\"3|\xc1\xe9X\xf7\xaf\x07\x12Z\xb0\xf5\xe73\xec\n\xcem\x1b\xa9+\xce\x08E\xe4\x07\xa0x\xf3Q\xad\xee\xb7w\xc1\xe8\xb6\x19\xc6\xc5\xaa7\xe8\x0b\xc1\x11\xdf7\xea\xd5T\x02\x95\x92\xd0'\xea\xf3\xdb\xeb\xe9^2\x86\x86\x17\xe9\xa5L.\xe2\xd4\x9f\xe90\xe9\xbfZ!\x14\xa5\xfa-*Jk\xa6\xb8WS\xe7\xa2\x02\x03\xd3\x80\xfe\xb7\xd6\x1e\x9d6}\xbd5\xa1\xdf\xb7H\xb2gV0)_\xfe\xee\xd3\x82!.\xd8\xfbC\x8b\xa2~[\x81\x99\x04\x17\x14\xf9Oy\xb1)\x96\xfd\xf6\xe5\xdc\x1c/z&J\x15F\xdcb\xb6\x8d\xc6\x9fV\xd7f\x8a\xaf\x8dh\xe1@\xed\xf7\xee\xc4\x8bC\x96\xfc\xa6\xc6\xd8V\xe2\xa1x\x1e\xac^L\x8f\x19\x96\x02\xed?6\xb8\xb5\x03k\xaf\x9a\x9d\x85\xa1'\x1e\x9a\xe7\xc1\xba\xc5\xdc\xd8\xf7\x1d\xfezHQ\xe40\xe0\xc5\xb3\xac\x92\x9ar\xda\xddj\x14\xbf\xfe\xd9\xd1\xb0u\xa5\xc5&\x84\xe5tW5\x19m\xec\xe6\xbb\x80\x16l\xfc| \xf17vj\xa7z`f\xa8\xbe{\xb2l\x96\x9ar\xdc5\xa0\x95\xb2>\x0c\xca$\xa1\x18\xfd\xdd;\xb2\x1c\xa6\x00\xfe\xa8\x86n`\xc5\xcdp\n\xda[\xd2 \xf9\xf0\x91\x0b\xff\xed\xfd\x87N\xf8\x9c,\x8cp\xb9\xc4C\xd2\xbcQ\xbf\x15o\x13}\xea'\x1ey\x9b\x9e\x17\x0cI\xed\xd2\xa6\x1d.\x17 \xa2\xfav\xdf\xc6A\x9b\xe27\x07.\x0b\xca\xcc\xcf\"\x13\xd6\x96t\xaf \xa6\xd103X!\x00\x7f}4\xf4\xfe\x98d\xfc\xda\x9a\xf3\x13\xac\x97\x9e\xe91\xc6\x80\x82\xf5\x17\xa7\xf6\xbdi9\x7fL`Q\xc1\xab\xc0H\xea\x98\x16P\x96\xfdw\x9fJ\x90\x8c\xf6\x1eY\xb3}\xd9\x99\x93\xa6\x0eEX a\xdd'\xfd\xb77\xd6\xc5\x0c\x986\xcck\x87)\x9b\xf9\x9c\xad\x1f\xb7\xd2\xe2\xfd\xf2&\x0fL\xcdh=^-,^\x19\xf0\xe0\x03\xed\xfc\xf8\x80\x91\x05\xbfw\x82\xc4#\xa0\x93\x93\xee\x0c\xf5\xe3d\x83\xe6\\\x8c\xf9X\xf9_\xecV '\xbdX\x13({\xff\x8c\x846g\xa7\xe1'5\xe8u\xf3\xfc\x9e\x9b\xe6m\x96P&\x02\xb5\xcf:\xed\x823\xb6<\x90I\x0d\xf9H\xd2_|V\x02\x89\xd1+\x8c\x92\xbft\xe4\xb4\xbb6\x0c\xa9\x97?B\xc1\x96\xcd\xcf\x9a{7U(V\xa6vi\xcc\x8e\x96\x8b$S}\x07\x80\xf1\xa1\xe07\xa7e~\x01\x8c \xb5\x86\xa1\x11=\xeb\xaa\x07;&\xd6\xfc\xbfX\x97\x80_<~\x8e\xcd\xdd\xbf\xb2\xa1I?I\xc7\xbe\xb9\xcf\xbf\xd8\xc1\xdaP\xeb\x81\x13\x15\x04w\x80;tS\x80OW\xc8\xcb_?\xa6J\xab\xfc\xdeEC\xc6\xc0\x1cs\xe8\xc8OJ2V\xc1\xfe\xb7\xe6\x8c\x88\xbfM\x1c\xcc\x9a\xc3\xba\x81\x1d6\x8aD\xe4e2\xd1\x8dzs\x93\xf6\xdb\xbf\xd1\x95\x00/~\xce\xce\x87m\xeb\x98\x0e\x97A\x02\xfd\x16\xef\x83\x11\x1f\xeb\xea\xf7?n\xe3\xd6\xb8\xb8\xd3+\xb6H\xd3Z\xe2\xe7\x99P\xbfE\xf0`\xc5G\xba\xb8\xdf\xde]\xd7~j\x17s\x0f\xb8\x07hZ\xff\xfbL\xa4\xdf\"S0\xe3\xa3]\x7f\xf1`\xdaW\xeb\xe2L\xaf\xd8\xa7\x1f9\xc5Z\xbb\xecq\xd7\x80L\xa9_\x85\xf8\xd7\xd3\x14MS\xde\xe2\xc7C\x90\n,uN\xf1\xa0\x98\xbc)\xb2\x7f\xb9\n\x0e\x14\xed\xe6p5r)1\x8c\xa8\xc8id'\xbfE!a\xf3\xc7\x85\xff|c\x8c\x13\xb9\x10=\xef\xe8\x13m\xbb\xf1\xc2\xe8\x16\x86\x9a(e\x7fk\x92\xfd9\xcfm.q5&\xd6 \xa1\x83\xc0\x83BG\xa7\xfav{S\x16\xfc\xf9WGLa\xf1\xbdS\xe3\x94\xc2 \xe4\xcd\xc4\x93\x03\x7f\xd5\x02\xb8`\xf7&\xfc\xa7\x93n\xef\xc3]y/~\xd1\xb6q8Zs\x15\xef\xc5o\xca\x82\xf9O\xc6x\xa2\x10?\xf9\xa1\x1b\x18p\xaf\x94\xde2\xac.\x9c\x1aT\x10\xb7\xf4o\xfc\x0e\xc5\x05\x1d\xd4\x98-C\xd1\xf9FN\x0bK\xe4\x88|8P\xc8\x9b\xad\x13\xb6\xdf\x1e\xd7{\x93\x03\xa8f\x8c&\x9b\xe8K\x91\x05\xab\xf4t\x16\xf9\x05\xc2\x7fk]ka\xc78\xf0=\xd2\x17\x8a>)\xd7\xa6Z`_\xdc\xf90\xa1\xfb{.I\"R\x90\xdb\xdd^2\xff39-\xac7\x83V\xa3\x8c\x85\xc9\xfe?\x1b\xd1\x8a7\x7fBpN|gM9>\xbc\xbcba(Fn\x80\x7fc\xd8q\xe1Q\x8aH}\xb9\xcc|I\xcf\x1dRaQ\x00\xa4C+M\x7fdZ\xb2\xfe\xfa\xe7\n&\xc6\xda\xea;Xd\x1f\xb4\xe2\xa0\x9a\xd1~\x8b*\xc6\xde\xcc\xf9/\x1f\xec\xfbt@\xb3v\x84\xa2\"-\x88\x18\x88\xb6\x97\xdf\x12\xfa(\x00cg\xdao\x0f\x0d\xeb\xffX\xfa\x92\xe6uyh\xe9\xef\xf2nY\xa0\x12\x14\xab\xde{\xab\x12& \xc8\x14\x19w\x80\x8caFD\xfd\xf4\xb7\xf8\xfd\x9f\xfd\xa9\xe4D\xba\xfbt\xbb9\xdd\xa01\xf7\x8c\xf2\x993\xe8\xd6\xc9\xaa|\xf48v\x97\x8f\xb9\xc2%\xc2\xc2\xf0\xb7+U\xbbDh\xdeXR\xb4\xb1\x82\x1fjT+\x9e{q\xd3\x1b\x0c\xef\x08\x9e\xf5\x7f\xde\x7f\"\x8fLJ)\xff0\x863\xf4\x16W\x9c\xa2\xeb\xf0\n\xe9x\xba,\xdc\xe3?Nw\xf1\xa3\xdf\xf8\xfa\xd1\xb6u94Q\xaex\x88\xf3\xd3\xb7\xbc\x00d\x94\xa7\xff\xb4V\xe64V\xd0#\x1a\x07\xb2\xd7\xdeS#\xdb\x1a\xdf\xf3z\x1d\np.\xe3\xff\xb0\xbd\xa4/\xbc\x8a~K\x95c7\xf8\xce-\xce\xe9\x0c\x89\xa5\xb5\x0b\xa3\xd3\x8e\x8cz\xc7\xcbR9\xddN&\xbe\x05^\xe5+\x19\xd5C\x0e\x9e\x80L\xbft\"X\xed\xb8Gm\xa4\x8e\x1e\xbdE:=\xc0\xc3\x92\xcf\xf3x\xaa\x05A\x0c\x05G\xba\x88>\xa6K<#\xbb~c\xcb7<_L\xc4r\xad\xf2\xc9x\x82\x95\xd5.\xbd\xf4R<\xa7%\xb1\x9e\xa4%\x04\x16\xedy\xe2\x12o\\Fq\xc2\xfc\xf8\xe2J)\x93\xd2\xad\x12\xcby\xd7\x0d\xcfZ\xd1\xf3\x9b\x8d\xed!F\x93{_*k\x1a\x9f\xc3\xc2\xe2\xdb\xb21\xef\xbc\xfe\xb7\x17\xfd.\x90[\xaa\x02\xe9\xe8z\xd6\xe0\x99\xbc\xef>\x98\xb0\x00\n\xb0B\xf4\x0f\xc3\xea\xea\xa8\xc0\n\x02\xa7\x08\x92\xca\x87\xd7\xf6L\x16\xa8\x00\xe3\xf7_~\x90\x1e\xa2tQj\xac\xf8\x8asn\x1ccp&w\xb4\x1d\xa9\xd8L\xa1\xe9\xb6\xd0\xd8}\x8c\x1e\xc5\xbb\xd7`\xbd\x03c\x8e\xf8\xfc\xeb2\xcdO\xabS\xcdfp\xdc\xa4\xcb\x9f\x97\x99Hr\x82X\xd0e\xca\xeb\xdb\xa8s\x81S\xf9bw\x08.\xdb\xddB,\xf8\x91\xfd]\xd8\xcb\"AL\x0fG\x92\x0eg\xd1\x9bs>VHKoW\xc9F:0\xb2Q\xff\xcbv\x16\x07i\x1c\xa36\n\x14O\x0f\xdcQ\xdc\xd4\xb0\x0c29\xf8\xdb\xe73\x9d\xe1\xafl\x8b.r\xbf\x83\xd2\x82\xef\xd4\x9a\xc3\xcb.\xbd\x0c1\x9b\xfb7\xa3\xe8\xb8\x8av\xcda\xcbK=_\x9aLg\xad\xc8\xf4~\x82w\x08\x83L\xfc\x87-\x1f\xe3\xd9\xb9\xe5\xdd\xd9\xcd\x942h\xb5\xb6\xd4\xfc`\xde\xe5L\xfc\x95f\xae\xfe\xed\xb2\xc2z\\^\xb6\xa6v\xdb\xc8%^\xbdj\xbfJ\x9c\x8e\xd8\xf2\x10\xc0!\xfc\x9dwN\xb4\xa4\x8e?\xd2u\xfe\x96\xb1/\xb6q\xadxg\xe2\x8d\x1d\x908x\xcf\xe0vP\xb2}\xd6\x93\xbbI#>\xe6\xf8\xd3=\x9a\x9env\x9fF=I\x9f\x00^\xa9\x15\xa6\xc2\xbf\xbe\xfbq1\x0b\x91\xc1VHt\xb1\xf6\xab\xc8w\xda\xa7\xb9f\xa4\xd9J1\x97w\x9e>u\xf9\xe7\x14\x8c\xe6u\xb1\xe2xm\x13]\x0dw\xf2\xaco\xc3\x94!\xadO\\\x11\x9c4\x91\xdfgJ\xaf^\xdb\xfe\x0c\x96\xa5<\xe2\xde\xf2\xee\xc3\xd3\xeb.\x1fW\x80\x98\xd7\xae\xf1\xb7\xc2\xb4N{}\x15\x1b\x92\x8dW\xcf\x97\xc8Gj%\xfd\xe6cvQ\x8e\x8a\x0d\x99H\xa5N\xfbu\xb1u|\xe9\xd7\xaa=\xb7\xaa\xef\x9c+\xbe\x17u/O\x8c\x0cP\xeb#\x8c\xb2 \xe6\xdb\xf3\xf37\x9f\xd0{3r\xc9 \xd9\xd4\xed\xfd'\xf5W\xf6J\xe6\x18|6\xb5@O\x10\xf1\xc4\xc7z\x87\x8cRf\xfa\xe9\x91\xdd6\xaf}F\x89\xe2\x89#\\\x05?-UA\xac\x0e\x7f~0\x89\xc4\xdb6\x91G\xcb\x8a\xe50\xa5\xf7\xa5R0:\xd5\xdcI;\xbeD\xa6baA\x95\x8cN\xce\xf9Tb\x01\x15UGg\xad\x15\xe9{\xb2\xe1Z\xf9\xc6\xbfH=o\xb7\xf7\xc7\xb3_\xd8\xeb\xcd;\xd4\xf7\\\x9b\xd2Y\x03{\xdd\xad\\+\x1f\x19/\xa5\x16\xfa\x1c4\x0c\xbfk\xd3\xe4\xe8\xddv\x144\xda\xc57\xc7k\x9f1{{\xd0\xb2\xa5\xa1\xb0\xbdK\x0c\xf0\x05\x96\xb5\xbc\x12_\xf2\x8c\xd2\xce\xe0|\x81w\xb2\x86\x95\xa4+\x9e\xb3\x1e\x82|\xb3Yt\xdb\x02K$\x1a\xe1\xb1\x17\xaf+46\xbbx\xf8\xfa\xc9\x0d\x14\xfc\x0e\xbc\xaa\x95\xa7\x85\xcb\x0d\xbcY94G\xbe\xd0\xe5nj;\x0e~3\xa9\xa7\xbc\xeb{~E\xeed\xff\xddB\xca\x7f\xb2\\\xbal\xd7\xddG\xf8\xd0\xe8a|\xb5\xbc\x8aA\xf4>[N]\xf9\xc4x+4x\xa4\xd9G\xf0w\xa9\xd2\x17\xb8\x00\xe9*\xb8\xad8I\xfb\xfb\xe4\xdbK\x99\x84[S\xfa\xb9\xday-\xc9'\xa4HeGR\xba\x88t\x98\x14.\x8b[2\x0c\xac\x00\xaf\xb4\xfb\xdc\x0e\xfd*\xad\xa2\x8b\xb9P{\x0b\xe2\xa5\x8c\xe3\x18M\xf1\x94i\xb13\xcd\xafc\xfd`\xa4\xeb\x86r\xe8\x00\x0b`\xb9{.\xa8\x01\xf6\x0d%\xb4\xd1\xa2X\x0c\xfcN\xf3\x9di\xe8\x85I\x1bY\xa8\x01\xd4W\xb3\x8c\xbd.i\x90O\x7f\x17\xbd\xe8\xf4\xc7\xc1=)\x98\x0f\xb2\xaaU\xa7\x8e\x0b0\x0bt\x1b\x8e@\xedQ\xa8\xec\x98m\xac\x02\x1d\x97Z\x07\x963\x1aS \x8c\xc1w\x1f\x177\xbd\x08~\xf9a\xef\x87\xd2\xa0=I\x10h-6\x1a\x88o\x9c\x87Y\xa4\xe7YvH\x13\x84\x8a`5\xdfh\xae\xd2?\xe6\x1f\x91\xd6C/\xeb\xcf\x8fL\x89\xbc\xd6\x8cV\xc53\xf1\xfa\xf2\x17AfK \xa8\xb7R\xdcu\xa6uV\x15\xc0\x02\xbe\xae\xd8\xabb%\xf2z3\x16UOQ'\xce\x82\x0d\xd0ldo\xaa\x82B\x8c\xbd\x19\x86\xf0 \x94\x07\x1d\x89\xb9\xe3r9R\x0dG\xc7t\x15\xfc\x03\x10\x19\xc4nY\xa6dNK\xd2\xc3Qd\xcb\x93\xa0-4~\x94:\xf9\xe0H\x14}\xd3\xfb\x9b\xd9\n\x8b^\xe0vAo\x19{\xfdsq=\x00\xd9\xeaD\x1b\xad\x17\xe9\x9b\x80\xfdL\xef\x88W\xc1\x7f\x94a\xca\\\x1f/\x14b\xc3S#\x01\x8a1\xa4:Y\xebE|\xbc\x9a\xc1w<\x9a\x1f\x02\xe6\x83\x9f\xe3\xa5r\xc1-\x04\x17\xd9\xa7/w\xd4\x04\xd4\xc6r\xb8\x95\xb5\xdb\xde\xef\x95\xe1-\x95\xa8\xeb}\xb3\x8b\x80\xc6/\x1fVz\x97\xdf\xc0\xea\xb1\xd7!?C\xf6W\x8d$e\xb7|\xf1\xe0\x88g\x95\xce]\xc7\x9d\xf6\x97^\xbb\x05\x90?\x0e\x8c\xf15\x7f\xfc\x9c$\xef\x93\x7f\x98\xed\xca\xcd\x18\x12\x92\x7f\xa5\xbcbH\x13\xe3\x86\xe4 \xa9\xf0p|\xd9/\xda\xdf\xeb\x00\x10\xedL\xc3\xfd\xfb\xe4\xd3\xfc\x8c\x1a\xb6\xbc\x05\x0f\x83D\x828\x0eW\xbe$\x0f\x9f\xfbI1ue\x16\x86\xe7|T\xb7\xd8\xfa\xf2\xb8\x0f\xeeU\xab\x90\xe89\xbczX *[E\xc0\xd4*\xc3{\x1d\x16\xf4>\x87\x17(\xf1\xe8lm\xf8\x16H\x95\x8f\x08(4\xe9\x02\x84\xba\xfb\xf9\x02\xbaHE\x87\x9e\x9a\xd7\xfb/\x18\xf2\x96Y-\xe0\xbb\xd9\x14\xbb\xa1\xe9\xd4\xf5\xe8\x9b\xc3\xab/E\xa1:o\xd9\xbb\xfa\xda3\x8f\xbc\x82\xd8054\xc2\x98w\x1a\x17\\\x94y\xfdts\xd6j4\xf1\xf4b\xcb\xebB\x8f\x03Wf\x82\x01\xf6\x1e\xfa;~.%\xbfl\x94\x8f\xe4\xc1\x90[eAH\x9f\x06\xf7T\x7f2T\x1c\x0e\xa7\x9ci~\xd5h\xe8I\xd9nJ\x8f\xce\xb4\xc7\xbd\x82\x13:\xb4T\xc3\xbe\xaf\xb3\xf3\xaf\xd2\x85\xb6\x93\x9e\x82i\xab\xfd\xa2\x7f\xc9x\xbeF0\xde\xcc\x1e\xbe\n>\xd4E\xaa\xef\xfc\x9d\xa6\xe59\xac\xd7R\x12z\x1d\xc4oT\x82\x98\xd9F|\x1e\x9b\xe3\x06C\xf8\x03\xc8\xa0#A\x9ax\xc5c\xe0T\xad\xe2\x18\x7fT\xd5\x85\x1bWv\x82\x92\xad\xd5x\xe7S\xc4ov\x0f\xcd\xac\x9d\x1e\x07\xc4\xb5Xo\x0e\xc6\x90<\x92$-\xa2\x8e\xd9\xa2\xfa\xb2\xa7%+\xb7\xde\x91Og7\xd5\x81\x183\xcf\xcc'\xd9t\xda\xcfn\x03\xadj\x15w\xb6~\x9f\x1fc\xa4\xc2\xf9\xd86\xe1W\xc16\xac3\xf7\x18\x893\x9b\xc7\x8e\xdc\x1e\xe3Z\xf5\x8eb\xb3\xfb\xfe\x1b7b\xf6\xed\x92\xc8\xde\xcf\xac\xc6R5j\xe5\x82{(\xf2\xba\xb7\x02K\xc0f\xa0\xee\xdf.\xba\xf8\xa9\xad\x1d\x97;?p\xb4;Z\x19#E\xfe\xa1\x9e\xa8\xc7\xc11\x83\xef\xd2Y\x1f\xad\xe5\xa3p\xc4\xd3\xd7\xd3\x06R\xfc\xcekf\x860\x14\x10+\xfa\x1bcV!\xd1\xa72\xcf$e\x93}\xfa9\xd8\xc0\xd6E\xfa\x99\xef\x0eyi\xc3\xa9>2\nWI\xec\xdb;\x03\xd86a-\x8e\x8d\x19I\xedf\x850\xbd\xea^\x05\x92\x1ds\xbf\x1ds\xba\x0bW!\xc8\xb7\xfb[k\xc7\x80Q\x9a\x92\x9cn\xf6\xbd\xfe:~\xe4\x8a@Lk1\x95\xfa\xe1\xe1\xf5:0\xeb\x90(\xfa/p\xca6\x89\x8c\xc4p\x80\x1cR3\x90Z\xc1\xb0\x11\x13\x99\xaf\xe8H\xe7\xb1FF\x9d\xce\x10\xe5\xd0\xff>Gu\xcb\x98\x0f\xc6\x87\x01,\x15\xd6\x0en~\x08\xde\x87\xdb\x89\xb5\x94\xb9\x94\x04%,\x1f\xcb\xfd\x19\x1di\x1d\xb7\xa2Q>V\x98\x80z\xdd\xf8\xcd\xf4\x9b\xb0V\xb0:Z\x8a\x87\xb5\xf3lrud\xe8\"#\x16p\x02r\xb0\x98y\xa4\x1ch\xf2sD\x07\xb6\x99t\xe45\x7f\x83\xc7&\xfcb\xdc\x95\xa51\x10\xf7\x18\xa4o\x05f\x92\xf1\x0d\";\xe4S\x96\xc5\xf0c\xf0z\x92\xb8\x87M\xbd\xd4q\x8a\xe2\xed9\xea\xdf<\xff\x18Xn\x1f\x11Q\xbd\x11\xf1\xf3\x8b\xaf\xd4\xe7\xf4S\xae\xbd\x01>\x02,\x00\x8d\x8e\xc9\x18#\x80.\xa5[\x81\xb9,,\xa2\xc0\xac\xa3/WR(\xc6\xfa\xa0\x08\xca\xac(~%\xda?\xe3\xca\xe4\xc2\xd7\x18\xd51&s$\x9d\xe5#E\x0ft\xbcR\x86\xd4]V\xb5:!\xc9\\\x80G.j\xac\xb5\xb4Lt\xb5o\xec\xbd\x9e\xdc-G\x1dP\xd2\xd7\x9b\x89x\xfd\x9bZ\x7f=\xfd\xf1\xcc\x1b\x82C\xf0\xfb\xc6)*\x1e\xf6\x86\xf3\xcef\x85<\xad\xc6&\x1a>\xf0\xbe\xb5'\xb6\xfdI=\xbcejN\x96n\xbeO_\x17\x8f\x81\xbc\xdf\xa5'\x97\"\x12\x7f\xf5\xfd\"\xfdd\x9e\xd7\xae\x0d[\n@\xea!\xd7\x99\x99>p\xa1\xd9A&\x13\xd9x\x0dY\x8e6\x1c\xfb\xe4\x18ro\xa4\xa0\x8au\x9a\xedxj\xb1\xf9\xf2\xdf\x8b\xf8\xf8\xa63\xda\x98\xe6\xbc\x0d\xb7\xf1\xc2\x9e\xde|\x13\xeasw.\xed\x151\x89\xfb+~O\xca2`\xb3\xb4\x9e\x14>\xd5bBQ7q\x0c\xe2\xca+k\xbd7V\x7f_\n\xf6E\xdd\xe6\xf8*y\x00\x95z\xdb\xaeO1RU\",U\xa4\x1c&r^\x9e\xa2.\xf7 )\xce\x19x'$\x17\x9b\xd8m\x92\x1b\\\xd8{q\xe2+\xc3x\x1ec\xbc\x9e\x0d\xa8\x05\xfc\xefZ<\x94\x0bS\x18\xbcMg\xa7\x11\xcf2M\xbc?LR\x1d4vd\xe5_Fz\x94/ \xf6\x90\xb2(\xfbx\x97\xd4E\x91\x98l\xb0-o\xc6)\xed\x0f\x86#\x07\xf8\xdb\xa5\xcf\xf7\xd2\x13l\x1c,\x14\x8b\xc4\xc7\xe1N\xe6*+t\x10\x08\xd7\xcb\xfa\xb4\xb5\xf85r\xf6m\x1e\xcc\x1b;\x9asB\xa4$}1vHf\xfd\xfb:\xd9\x9f`\xeb\x16\xa2\xe0\xe0\xb2\xa66%\xeex\xa8\xbf2\x9d\\\x92\x18ET\xf2\x07\x03T&\xc7\xd8J\x19)\xcc\xf7\xd0\x97\xce\x82\xae[d_\x9a\xf7\x02\x99\x18\xbd\xdb\x0bd\xac\xae\x84\x10B\x84]\x8f\x97g\x8a\xcb\xb2\xfc\x9f\xff\xf9\x7f\xec\xff\xfe\x7fvy\x97\xff\xfb\x7f\x01\x00\x00\xff\xffPK\x07\x08\xff9\xbd(rt\x00\x00\x12\xa0\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00\xc7\x02\xbcP\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00#\x00 \x00img/supervised_user_circle-24px.svgUT\x05\x00\x017\x04\xcf^D\x92\xdd\x8e\x9c0\x0c\x85_\xc5\xca\xbd\x0f\x8e\xf3G\xaae/z\xd5\x9b>D\xc5N\x87\x91\xe6gU\x10\xacx\xfa* 3+\xa1\xe4\xe3\x9c\xc4\x18\xdbo\xf3z\xa6\xaf\xdb\xf5>\x0ffZ\x96\xcf\x1f]\xb7m\x1b6\x87\xc7\xbfs\xa7\"\xd2\xcd\xeb\xd9\xd0v\xf9X\xa6\xc1\xa874\x9d.\xe7ii\xbc^N\xdb\xcf\xc7\xd7`\x84\x84\xd4\x93z\xf3\xfe\xf6\xf9g\x99\xe8c0\xbf\xadE\xce\xa4#\x07\x04%a+\xe4\xe1\xfb\xb2[\x99\x0bR\xc5\xf6\xf0!\xf0\x81\xdc\xd0\xca~s\x88\x96\"\x9c\x1f-$\x91\x90Ev\xe8c\xdd\xdbR4I\xfc\x14\xf9\xe5p\xbbR\x85b\xf3\xcbf\x88\xad\xeeS\xfd\xbe\xb9\xdf\xb8H\xa1\x1f-Jl\x85+q%6\xaaK\xf9b\x8d\xde\xde\xf9\xe5\xcc\x15\xab\xc3/g,\x198[\x82\x84\xef\xa3\x95\xf6\x9bP\x86u\xabC\n#+<#\x05\xf6p\xac\x88\x1c`={\xe4\xd8\xeeZX%\x87\x98\xd8\"f\xaan!\x84V\x04\x85\xf4\xe5G\xa0Zt\x8f\xbe\x9c\xcc\xa4\x10= \xf6\xfb\xb3722\xb4\x94\x07\xa1\xd5\x03)3\xc4\xaf\xec!\xa9&\xed\x95\x14\xd9\xb3\xc2\xba\xd2\xbf\x06G#\x14Y\xe129\xf4\x9e,l\xcd/\x159\x95\x08\x91\x02$s\x82\x0f\x95v\xd3\x1d\xf3\xf1\xf7r\xbd\x0e\xe6\xfe\xb8\x9fL\x9d\x15!\x99\xd4\xaf\xea\x7fI=T\xc6\xee\xfd\x7f\x00\x00\x00\xff\xffPK\x07\x08uq\x02\xd2d\x01\x00\x00\x9e\x02\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00P\x85\xcdP\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0e\x00 \x00style/main.cssUT\x05\x00\x01\xf8\x01\xe5^\xbcX[o\xdc\xbc\x11}\xf7\xaf`\xd7\xf8\x00;\x10\x15]V\xbb\x8e\xf2\xd6\x14A\x0b\xd4yH\xd0\x87>R\xd2h\xc5\x9a\"\x05\x92\xf2\xaeS\xe4\xbf\x17\xbc\xe8\xba\xbb\xb6\x9b\x87/\x88\x01\x8b\xd7\x993g\xce\x0c\xfd\x01\xfd\xf7\x06\xa1\x96\xc8\x03\xe59\x8a>\xdf \xd4\x91\xaa\xa2\xfc\xe0\xbf\xf0\x11\x8a'\xaaq-\xb8\xc6\xaa\x15B7v\x92pM \xa3DAe\x97\xb5\xe2'\x16\xeat\xb6\xee \xc9\x8b* \x83\xf9a\x1aN\x1a+\xfa\x130\xa9\xfe\xd3+\x9d#.\xb8]Q\x88\x93\x99\xb0[\x0b!+\x90\xb8\x10'3c\x0f\xaeIK\xd9K\x8e0\xe9:\x06X\xbd(\x0dm\x80\xfe\xca(\x7fz$\xe5\x0f\xfb\xfdUp\x1d\xa0\xcd\x0f8\x08@\xff\xfa\xc7&@\xdfE!\xb4\x08n\x10Bh\xf3w`\xcf\xa0iI\xd07\xe8a\x13 E\xb8\xc2\n$\xad\xc7{\x8cm9\x8a%\xb4f\x88Q\x0e\xb8\x01zht\x8e\xe2pkF\x7f\xdd\xdc\x84\x9d\xa4-\x91/\x16\xc2R0!st\xbb\x83m\n\x0ff>df\x83\x9d\xfc\xf8\x01\x91l\x1f\xd75\xfa\xf0qZ+\x0f\xc5]\xbc\xcb\x02\x14\xc7i\x80\x92,\xbb\xb7\xdb*\"\x9f\x86]\xb7\xdb$\xf9\xdbnw\xb6m\xb7\x0b\xd0\xd6\xec\x8c\x92{g\x8b\x85\xb4\x15\\\xa8\x8e\x94`\xf7\xcf<\x89\xc2\x87\xcc;\xb3\xc0\xf1\xc7\xd7G\xc1\x05\xfe\x0e\x87\x9e\x11\x19\xa0G\xe0L\x04\xe8QpR\x8a\x00}\x11\\ FT\x806\xff\xa4\x05H\xa2\xa9\xe0fVl<\x98_D/)H\xf4\x0d\x8e\x9b\x00M\xf7\xff\x85\xb6\x9d\x90\x9apm\xcd+D\xe5`\xaa\xa8\xea\x18y\xc9Q\xcd\xc0\x85\x95\xc1 WTBi\xce\xce\x91\x14G3L\x18=pL5\xb4*G%p\x0d\xd2\xd2\x83\x94O\x07)z^Y\x1c\xc8\x12\xbf\x00EadP\\`\x95\xa6\x01J\xf7\x01\xda\xc6v\xc6\xf0\x8d\xd6/\xb8\x14\\\x03\xd79\xb2\x06\xe3\x02\xf4\x11\x80[ko[B\xf9\xfb\xcc-\x05\xeb[~\xf1\xdc\xc9\xea#\xadt\x93\xa38\x8a\xfe0\x9f-\xe5\x13\x9b\xa2\xe8\xb9q\x97R^\x0bCv{\xf1+\xa7]\x81\xe6\xccT\x9f\xc7\xb8\x10Z\x8b6GI\x98H\xcf\\\xe5\xcc\xff\x7f]\xec\x84\xa2>J\xc0\x88\xa6\xcf6e-\xf3\xacQ9bP\xeb3\x17\x13\x7fk\x13\xafi\x99\x84\x0bV\x1e\xfd\x8em\x14\xad\x0f\x9e\xfcd\xa05Hl\xa2\xe6T*L\xbb\xd3\xb8\\K\xc2U-d\x9b\xa3\xbe\xeb@\x96D\xc1\x8a\x0fq\x1c\x05h\xb7\x0fP\x92\xfa\xe4i\xe2PS\xcd\\\xd6\\\xbeu\x12\xc5polFq\x98d\x03\x9cMr\xb6s\x00b\x90\x85\xd4\xfe{\xd3\xcc\xab\xce\xcdUi\xb8y\x85\xda.\x8b\x9c\x14\x94DV\xd6 /\xa0\x92T\xb4W\xe6\xb0a\xdfjb\xeb\xeep\xa39\x8a\xbb\x13R\x82\xd1\xca\xa5X\x14 \xff?\x8c\x13\x97]\x96\"\x07)\x8e9\x8a\xc7o\xd5H\xca\x9f\xfc\xc8XQ\x10N#w|KN\xd8g\xc2vJ\x84a\xe4\xc1\xaf\x1a\x81\xf6n\xce\xdd\xb5\xc5\xa1!\x95\xb97r\xe6\x98\x15\x91_\xb9\xb66\xda;E\xbd\xa9)\xb0J\x81\x9e\xd5\xba)+\xfc\xbdse\xb9\xad\xcb\xba\xac\xeb\xac\xfa\xedK\xdf\x82x(ws\x89^\x9a\xcaH\x01\xcc\x1a|9\xeb\xdeL[/\xa4C\x16n\x935\xbeQwr%\xfe\x8a\xa0\xbc\"A\xe7\xd9zfz\xce\x85\xbe\xcb\x19Q\x1a\x97\x0de\xd5\xfd\x9c\x91\x03\xf8\xaf0\xed\xfe\x12\x1c\xaa#N\xb4f\xcc\x89\x93l\xe5Y\x84\xe2l& >#\xa5\xb1\xd6\x1eJ\xdbCHK/\x7f\xfe\x14\xd2k1\x87+\xdd\xcd\x036\x8613\xc459\xd6\x82R\xe4\xe0\x14c\xbc8 3\xcf\x8d\xfd\xa8\x0d\xa1u\xc1 \x1f\x83\x93O\x8f\x0b\xb6^R\xc0\xab\x15\xff\x15\xbe\x8a^\x9b~e\xe2X\xd9KeN1`\x98\xefcC5X\x89\xb1\x8b\x8e\x92tv\xdf3\xc8\x9a\x19\x967\xb4\xaa\x80\x8f\xf8M\x13\xc0\x18\xed\x14U\xcb\xd0\x84\n\x18\x94:\xcfI\xadA\xfaf\xc8Sf\xb3\xf9\xbc`0)\x94`\xbd\xb6vy\xe4?9\xf7\x07\xe0=\x1a\xd2}\x0d\xd9\xa9E\xe7\xc1\x1f\x13\xd8\x0e\xe1\x81\xd5\x82\x1anbx\x06\xae\xd5\xe0\xbb\x895\xefz=\xa7\x9e\xd2/l\x06\xce\n-[\x1b\x8d7\xeb\x80\xbdk\xf7\xd4\xe0\x92\xae\x03\" /\xe7s\xa6E\xbe4qi\xec<\x8aC!\x89\xd3\"\xad\xe7q\xf5\xbe\xafIaKLG$\xf8\x16,4\xde\xbc\xaf\xe2{\xe9\xb0\xc3\x86\x1e9\x1aH\xf2\x8e\xe6),z\xad}r\x0dF;~\x9e\xd1v\xe8\x95\xd7\"\xbb\xedNh\xd7\x9d\x9c(dQ\x80\xcc\xcf\xa7\xd4\xeaB|\x1f\x98\xa4\xe9N(\x1dV\xcc\xd5\xf7\xe1\x1d\xe2\x1b\x9d\xa5\xdb\xde\xa5\xdb\xa8\x95\x9euWSi\x06\xf9BO\xc7\x9c\xa9\xa0\x14\xae[\x9e1\xcb#\x136\x84\xd5#\xbf|\x19\x8d>\xaf\xcb\xe84R\x10E\x8d2\x13V\xdee\xd1\x1f\x08\xdb\xbb\xee\x17g\xd6=c\xeb3\xe3\xf9\x8a\xbc1y\xec\xfa\x94\xa9\xf9\xb0\xbf2\xa2\xe1\xdfw8\xb6g\xaec\xb1\xefN(6\x01\x89.\x86\xe3\xde\xbd\x03\"\x1b\x8d\xdd\xb4l\x1d\x93_7\xa1\xa8kl \xe1\xd2qN\x84l\x9ffE\xe6i*\x84\xc9\xe4Q\xa0G\xb6Rn\xdfa\x9a\x14\xee1\xb9\xd0\x818Y\xcaH\xe2c\xbe\x92\xf7g\x90\xe6\xe5\xc7\x86\xa2\xa0E7\x7f>\xf5\x1a\xaa\xe5k\xae\xdcg\xfbj\xf1\x9aY\xbd\xabfJo\xfa.\xec\xcc\xbf\x9cgoe\xcf\xaa\x14\x8f\xcd\xf4\xcc\xd1\xb1\x97\xb2C^%\xf1\xacGZ\xb57\x17\xa6\xcc\xb1\xcb\x89\xeb\xcd\xed\xd5\xaf2\x86\x9f-\x1a\x81o\x80T\xd7\x80\x7fo\x87s\x16\x8f\xdf\x81\xde\x13\xf2O\xc6\xfd\x1d-\xd5\x12z-\xba\xb7p7K\xae\x82n3F\x8e\x7f\xe2\xb8\xd8j\xfd/\x00\x00\xff\xffPK\x07\x08L\xbb\xd3^\xd3\x05\x00\x00^\x12\x00\x00PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\xd8\x02\xbcP\xaa\xa0\x16\xe1c\x04\x00\x00-\x1f\x00\x00\x16\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x00\x00\x00\x00html/dashboard.go.htmlUT\x05\x00\x01X\x04\xcf^PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00M\x85\xcdP\xd6L6ob\x02\x00\x00\xb2\x06\x00\x00\x12\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xb0\x04\x00\x00html/error.go.htmlUT\x05\x00\x01\xf2\x01\xe5^PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\xd8\x02\xbcP`}f\xf1\xde\x00\x00\x00c\x01\x00\x00\x13\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81[\x07\x00\x00html/header.go.htmlUT\x05\x00\x01X\x04\xcf^PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\xc7\x02\xbcP\x83\xba\x83\xe4\xf5\x00\x00\x00|\x01\x00\x00\x1b\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x83\x08\x00\x00img/account_circle-24px.svgUT\x05\x00\x017\x04\xcf^PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\xc7\x02\xbcP\xfc\xc6x\x8f\xb7\x00\x00\x00\xf9\x00\x00\x00\x12\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xca \x00\x00img/error-24px.svgUT\x05\x00\x017\x04\xcf^PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\xc7\x02\xbcPK\xfe\x8b#M\x03\x00\x00d\x08\x00\x00\x10\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xca\n\x00\x00img/pomerium.svgUT\x05\x00\x017\x04\xcf^PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\x1b\x85\xcdP\xff9\xbd(rt\x00\x00\x12\xa0\x00\x00\x1a\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81^\x0e\x00\x00img/pomerium_circle_96.svgUT\x05\x00\x01\x96\x01\xe5^PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\xc7\x02\xbcPuq\x02\xd2d\x01\x00\x00\x9e\x02\x00\x00#\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81!\x83\x00\x00img/supervised_user_circle-24px.svgUT\x05\x00\x017\x04\xcf^PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00P\x85\xcdPL\xbb\xd3^\xd3\x05\x00\x00^\x12\x00\x00\x0e\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xdf\x84\x00\x00style/main.cssUT\x05\x00\x01\xf8\x01\xe5^PK\x05\x06\x00\x00\x00\x00 \x00 \x00\xb2\x02\x00\x00\xf7\x8a\x00\x00\x00\x00" + data := "PK\x03\x04\x14\x00\x08\x00\x08\x00\x97l\xd2P\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x16\x00 \x00html/dashboard.go.htmlUT\x05\x00\x01\xeem\xeb^\xecYMo\xe36\x13\xbe\xef\xaf\xe0K\xec\xf1\xb5\x18\xa4=\x14\x0b\xd9h\x90d\x83\x00A\x13\xe4\xe3\xb0\xa7\x80\x16G2Q~\xa8$\xe580\xfc\xdf\x0bJ\xb2-K\x8cc;\xf2\"@\xd7\x97(\xf3A\xce<\x0f9c\x8f\xe6s\x06)W\x800\xa3v2\xd6\xd4\xb0h\xe2\xa4\xc0\x8b\xc5\x97\xf8\x7f\x17\xb7\xe7\x8f?\xee.\x91\x97\x8c\xbe\xc4\xfe\x0f\x12TeC\x0c\n\xa3dB\x8d\x057\xc4\x85K\x07\x7f\xe0\xd1\x17\x84\xe2 P\xe6\x1f\x10\x8a\x1dw\x02FwZ\x82\xe1\x85\x8cI\xf5\x7f\xa9\x9b\xcf\x1d\xc8\\P\x07\x08{\x0f0\xabM\x11\x8aI\xb5\x88\x7f\x1ck\xf6Z/\xc7\xf8\x14q6\xc4\x92r\x85+YC\xcaU\xaa\x07c=[ij]\"\xa8\xb5C\x9cP\xc3\x1a\xaa\xaerP\x85\xb1a\xe3\xd39\x1d\x9d\x17\xc6\x80r\xa8\xb0`b29\xdd\xb4\x98\xcfy\x8a\xa2'\x0b&:\x17\x94K\x1b\xe5\x8b\xec4\xf3A\xb6\xd6\x9bI\xa1\xec\x10O\x9c\xcb\xbf\x11\xf2\xf2\xf2\x12\xbd\xfc\x16i\x93\x91\xd3\x93\x93\x13\xe2}6\x1c\xba )\xb6\x91OL\x18\x9f\x96\x87a%I\xb5\x91H\x82\x9bh6\xc4W\x97\x8f\x18\xd1\xc4q\xadJ4\x1fx\xa6n\x0b\xf7t\x7f\xb3X\xb4\xd9\xb4P\xda\x8dZ!\xc7\xf9\x12 \xd6z\x9cG?taPR3o\xc1Z\xae\x15b\xe0(\x176\x8aI\xdeY\"\xe5 \x98\x05\xd7V \x14\x0b:\x06\xd1\x95\xfbxr\xaaFO\xf771)\x9fB&\x14M\x0c\xa4ef\xf7\xc0\xb8\x81d\x99Z[\x12\x13\x1a\xd8\x9c\xd4\xbbw4\x8d\x83\xfb\x17\x95\xed3\x84v\x08\xdc\xbbm\x89\x9c\xab\xbcp\x01\x05B\xee5\x87!v0s\xed\xc3S}j2JH\xc3\x16S*\nX_\x9e*\x81\xb0iYuv3e\xdc\xd2\xb1\x00\x16P\x92-\xd0\xb6\x15\xcbK\x19\xa8\x0d\x19\x9f\x82zV\x87\xe1}\xe5\x9d\xd1\xe7A}]\xb2\xd6i\x05\nB\xf5i\xd1\xb0\x97o\x7f\xbc\xf8\xda\x12\xa0%\xa5\x92\x8b\xd7Cy\xf9^z\x7fJb\x1a\x89\xed\xcf\xcc.\xce\xbdS\xd3\xa1\xe8\x9a\x1d@\x89\xf7\xbc\xbe\xf8\x1cl\xf8\x04vB\xfem\xc3\xdeQ^\xa1{))\x17\x07\x00\\\xfa}\x00_\xf0\xfe=\x01\\\xe7\xb0\x13\xc6[m{\x87\xd9P\x95\x01\xfa\xca\xff\xff\xf5\xf9\xdb\x10E\x17e\xb3\xd6\xe6\xb5\xbahF\x17\xb9\xdd\x0b\xfb\x928\xf8\x07}\xe5\xe8$\xe0\xb8j\x14~\xe5\xb7\xc9 ~il\xfaouU\xa1\xfb\xb8\x03\xe3\xfd\xdc\xa8\xf7i\xfe9\xe4\x06\xe4/\xdcMP\xf4P}W\x8c\xae\xd9\xa3\xfe\x1b\xd4\x16\xcb\xcbY\xce\x0d\xd83\xb7\xd7\x11\xa8\xaf_\xe5\x8b\xce\xdc\x07\xee\xe0\x7f\x84\x91kk\x0b`\x07\xc1\\\xb9\xfeBy+\xca\x95\xfc\xed\x0b\xe1\xa8\x0b\x15\x9b\x15=2\x07c\xb5\xa2\x0e\x0emF\xeb%\xb8\xca\xd0G[\xd3q ;:\x1d\xad\x9e\xd3\x80\xf7x\x0dg\x93\x80_\xed'\xf09:\xef\xc1k\x18\x93\xf0\\\"&\xc1QHs(\x96\n\x98\xe1v\x00\xf1\xb8pN\xab\xa5M\xfd_Z\x08\x81k\x9cm1\x96\xdc\xe1\xd1\x03\xcf\x14\xba-\\L*\xa3\xf6\xfe\xe5P\xa7)H\xb5\x91k\xc9\x86\xc1\xe6\x08\xe8'\x8c\xfc|\xf0\x03\xae\x06\xd4v\x07~\xc7\x98\xae\xd9\"\x073\xe5\x16\xd8sa\xc1\x1cy\xca\xf6\xdeD\xed\xee\xf6\xa11RkF\xcc$W\x84\xaf+\xca\xe1\xf3\xb5\xce\x01>\xf3ks\xeb\x0cu\xdaX\x94P\x85\x1c\xc8\\\x1bj\xfc\x8f\xeb\xc6\xa6\x88*\xed&`\xca\x81l\xd49\xf0}\x8e\xe6\x0eo%\xfeG\xb3'\xbe\xdb\xdf\xd0\x9b5\xa4\xb7\x1fFae.h\x02\x13-\x18\x98j\xa6\xfc'\xcc\xa8\xcc\x05D\x89\x96!\x97}\xea\xd2{H\xbe\xd3\x13\xf6E\xb2je\xef@\xd9C\xc9\xdf\x01IP\x19W\x00\x86\xab\xecc \xf6]\xab=d\x895\xe9w\xbfj\xfbm\xc4\xaa\x94w\xa2\x0b\xe2}V\x06\x10\xc4\xbbF\xcaB\x00\xeb@\x9b\xe8\xd8l\xb4\x8d\x96\xb6\x8b]#\xa6\x0e|\xbd5\x9a\x8d\xc7\x98T\xef\xa0bR\xbd\x04[v\xda\x7f\x03\x00\x00\xff\xffPK\x07\x08\xfa=\x01\xd5a\x04\x00\x009\x1b\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00\x88\xbd\xbbP\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x12\x00 \x00html/error.go.htmlUT\x05\x00\x01P\xfb\xce^\x9cUKo\xdb<\x10\xbc\xe7W\xec\xc7s,\x06\xfe\x8a\xa2-d_\x92\x1c\x02\x04h\x90&\x05z\n\xd6\xe4J\" \x91.\xb9\xf2\x03\x82\xfe{A\xd9N\xf5p\x82\xb6'K\xfb\xe2\xccj\x86n\x1aM\x99\xb1\x04\x82\xbcw>)\xb8*E\xdb^\xa4\xff\xdd|\xbd~\xfa\xf1p\x0b1\xb2\xbcH\xe3\x0f\x94h\xf3\x85 +@\x15\xe8\x03\xf1B\xd4\x9c\xcd>\x89\xe5\x05@Z\x10\xea\xf8\x00\x90\xb2\xe1\x92\x96M\x93|c\xe4:\xb4-\xcc\xe0\xf5\xed\x89v\xdc\xb6\xa9<\x14u\x0dM\xc3T\xadKd\x02\x11\xc7\xd0o$\x00\xa9\x978R\xc9\xc4j\xde\xe4\x05\xc3\xc1u5\x93\x86PaYN\x96\xd1}\xcf\xee\xab\xdd\xdd\xb4-\xa4+?>\x0b\xe0\xe1\xc8:\x16\x7f'\x1f\x8c\xb3c\xfdM\xee\x8ca`\xf0\xda{y}L\xe5\xe1\xb2O\xe5\xe1\x1f\xe8\xa4\x90_\x01\x00\x00\xff\xffPK\x07\x08\xd6L6ob\x02\x00\x00\xb2\x06\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00\x88\xbd\xbbP\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x13\x00 \x00html/header.go.htmlUT\x05\x00\x01P\xfb\xce^\x8c\xcd1k\xc30\x14\x04\xe0]\xbfBhN,Z:F\xfd\x05\x9d\n\x9d\xcb\xab|\xb1\x1f\x95\x9e\x8d\xf4\x9c8\x18\xfd\xf7\x12C\xc9\xd0\xa5\xe3\x1d\xdc}\xdb\xd6\xe3\xcc\x02\xebFP\x8f\xd2\x8d\x9a\x93k\xcd\x9c2\x94\x8c\xb5B\x19\xc1]\x18\xd7y*\xea\x8c\xb5q\x12\x85hpW\xeeu\x0c=.\x1cq\xdc\xc3\xc1\xb2\xb02\xa5c\x8d\x94\x10\x9e\x0e6\xd3\xcay\xc9\x8fb\xa9({\xa2\xaf\x84 \x933\xfe\xd5\x9c\x12\xcb\xb7\xb1\xb6 \x05W\xf5\x96PG`\xe7\xf46#8\xc5\xaa>\xd6zo\xc6\x82sp\xdb\xd6\x93\xd2\xc7\xfb\x9bu\xbe\x9b\xa7\x8c\xc2K\xf6T+\xb4\xfa\xfd\xc2gb\xe9\xee\xa3\xd6\xfe*\x1c'y\xfcs\xa6\x01~\x96\xe1\x9f\x00\xe7\xc1S\x8c\xd3\"\xfa\x19\xb9\xc4\x84\xe3\xf3\xcb\xbcv\xf52\xfcj\xdb\x06\xe9[3?\x01\x00\x00\xff\xffPK\x07\x08`}f\xf1\xde\x00\x00\x00c\x01\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00r\x9b\xb2P\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1b\x00 \x00img/account_circle-24px.svgUT\x05\x00\x01\xa9\xe1\xc2^<\x90Ao\x830\x0c\x85\xff\xcaSv\x8eq\x9c\x90\xc2Tz\xd8.\xbb\xec\xb4_0\xb5\x8c \xb5\xa5\x1a\x88T\xfc\xfa\xc9\x80&\x81\xfc\xf9\xe5=K\xf6q\x9c;\xcd\xcb\xeb\xa5\xd1\xf5/\x00\x00\xff\xffPK\x07\x08\xfc\xc6x\x8f\xb7\x00\x00\x00\xf9\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00r\x9b\xb2P\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x10\x00 \x00img/pomerium.svgUT\x05\x00\x01\xa9\xe1\xc2^\xc4UMo#7\x0c\xfd+\x84{i\x0fz\x16I}\x16\xeb\x1c\xfaO\x06\x89\xe3Y\xc0N\x16q:Y\xf8\xd7\x17\xa4\xc6N\x8a\xdd\xf4\xd4\xa2\x08\xc2\x91\x1f)\x0d\xf9\x9e\xc8\xf9r^\x0e\xb4|\xdd\xbf\xfd\xf1\xfc}\xb7\x89\x14\x89\xb5\x93\xc6\x0d}?\x1d\x9f\xce\xbb\xcd\xfc\xfa\xfa\xed\xf7\xed\xf6\xed\xed\x0do\x8a\xe7\x97\xc3Vb\x8c\xdb\xf3r\xd8\xdc}9\xd0\xeb\xcb\xf4t~|~9\xed6\xbe\xe9\xbe\x8d\x9f\xe1\xe5\xcf\xe3~\xb7\xd9/\xfb\xa7\xe7\x87\x87\xcd\xd6\xaaZ\xc3\x1e\x1f\x1f?\x96P3\x12IA\x9190R92X\x83\xa0\xf6 \x90l\x89\xf4\x99\x91\xd3\x91-\x01E+\xc4H\x1a\x14\xad\xba\xe3r\xb2 \xb6\x04\xd3\xb4f\x99\x84\x9cv\n\x8c\x1c\x90\xf3\x02\xad\xf6\x82\xccKhHi\xf6\xa5Z|#7#~\x0d\xbfgd\x83\x04\xc5^'m\xac\x8c\xa2s`\xc4f\xcb\x16\xae`\xbb\x9c\x02TBBk\x13\xa34r\x13\xfd/0\xb8!\xeb\"\xc8b\xdeLn\x86w8\xd3=Z\xf6_)\x05T\x1d\x0bF;\x07\xe4\x1e\xd8\xe8X\xa1\xda/\xa7\xd0\xd0L<\xcb_-7m\x1f\xea\xb5\xfc\xedY)z\x86\x81!u\xac\xbc\x00F\xecC\xed\x1bf<$rs\xe3\xc1X\x0bjD\xc9\xd2\x90\xb2\xd3'\x97S4\xea\xad\x0e\xcb\xb2\xe8\x87*k@\xd6\xfb`\x12EK7\xa3\xaa?\xc9\xd2>#wb\x187\x8e\xf8\x19v\xc4;\x13v\x80\xe9\xd9\x9d?\xf3\xfb\xaa\xdf\"|s'E\xce\xc3\\\x1d\xb5\x04\xe4\xb20\xa4L\xc9\x08t\xb3r\"\x84TH\xe9\x06\xd8\xeda%A\xab\xc3\x0c\\\xd0\x9a\xd1\xa1Cm7WO\xee\xa4\xd6j\xab\x19p$\xe4r91\xd4\xf8\xd54\xb1)\xc1W9\x9c\x15\xad\x06\x94Y\x90\xeb\xe4W\xc9\xcd;k\xa6\xac\x16\xbf\xc5\x9a\xd0uQt\xe7z \x19}\\T\xe4> \xc9M\x9c\x94\xc3\xca\xcd\x87\xcb\xc4\x84\xd4\x11\x8d\x05\xcd\x13\xa3\x99@\xed&\x10\xb2\x06D\xcb\xa6\xad\xe6\x96C2\x02/\xa7P`\x9e\xc4?a\xbe\x96\xff\x8d\xfa\xf2\x1f1/ze\xbe\xa0TJh\x06H\xb0.\x13J\xa6\x81\x1e}\x16\x0e\x15l4uJ\xe4\x9a\xd9\x04\xd1\xfa\x03\x94\xf9\x18l\xf6Z\x93v\x88\x82m&u\xeb\xad\x9e>r\x10\xfb\xca\n\x13S1]\"\xfd\x18h\xb8\xf1e]\x97\xd0\xc5\x9a\xab\x8d\xe6\xea\xa8\xd9\x9f.\xf2\x19\x85i\x95|\x85\xac\x875\xb8\xa2Y\xc7\x08A\xb1-^\xa6CMG\xf1\xe2\xf7\xc1\x0b.\x8b\xa0\xb3\xcf\xe2n\xa32\xd6\xd9d\x9a\xc4^\xe0f\xa4l\x9f\x19\xb1\xb1'\x12\x9c*N\x8b\xa0\xf1l\xab\xbf\xa9a\xb16\x88j\xbe|\xf6qx\xff\xa0\xa9\xa02qB\x9f\x14]\xc9\xcd\xaa\x98\xad\x837\x87&\xf0'\x01\x06\xc8\xc2\x05\x9cf;k \x99\xa7l\xac\xb8\x19\xe93S\\\xcc3\xcb\xbf\x11\x81T/\xa7\xa0\x1d\x9a(\xe1\xe7\xa1\x97\x13\x0bz\xa6\xf8\x0f\xde\xf4\x99\xd7X\xda\x1e\xd6\xff\xf3r\xb8\xfb+\x00\x00\xff\xffPK\x07\x08K\xfe\x8b#M\x03\x00\x00d\x08\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00\xb1r\xd0P\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1a\x00 \x00img/pomerium_circle_96.svgUT\x05\x00\x01n\xd5\xe8^\x8c\xfa\xc9\xce\xebJ\xb3-\x86\xbe\xca\x8f\xbfu/\xe4s(R\x14Im\x9fm Y\xd7u\xdd\xd9`]\xd7\xa4\x92\xd4\xd3\xf8Y\xfcd\x864\xe7\xda-\xc3p\xe3k\xac\xc5\xcc\x8c\x8c\x88\x11#F\xa4\xe6\xff\xda\xde\xd5\xbf\x9a\xfc?\xff\xed\x17\xd9>\xad\xff\xe5\x0c\xc9\xba\xff\x97\x91\xb6E\xb6\xff\x974$U\xf1\xef\x7f\xe5\xc9\x9e\xfc\x8f1\x19\x8a\x7fV\xfd\xeb\xb7\xea_\x7fV\xfd_\xff\xe7\xdfe\xe7\xd0\x8f\xdb\x7f\xfe\xbb\xde\xf7\xf9?\x10\x04B\xf8?\xe1\xe3\x7fNk\x85`\xf7\xfb\x1d\xd9\xde\xd5\xdf%\xffq\xf6\xcd\xd8\xfd?-D_\xaf\x17\xf2\xfb\xfa\xef\x7f\xbd\x9b\x02\xd2\xd3\xf9\x9f\xff\xbe\xff\xeb\xfe/\x0c%\xbe\x7f\xff\xfe?\xfeW\xf3\xb5\xf5\xff~\xe1\xff\x81\xfd\x7f\xbe2l\xf2\xbd\xfe\xcf\x7f\xbf\xee\xf7\x7f\xff\xab.\x9a\xaa\xde\xff\xfe\xc7\xbe&\xe3VN\xeb\xf0\x9f\xff\xde\xb2\xa4/\xfe\x7f\xf7\xff\x89\xe1\xff\xff\x7f\xff\xebw\xb9\xff\xa8\xd7\xa2\xfc\xcf\x7f\x7fm\xfc\xc7\xef>\xc8\x8f\xceIw\"\x7f\x8a\x1d.?\x8aWA`/\xbf\xdfE\x1b\x16\x8e\xac\xfa\xc9\xd0\x02\x0b\x13\x1c\x1d%\xe2\x15\x11a\x1b\xb1hea\x80\x8d\xf4G\x82\xe2\x11\xb0p\x91\xde\x13\xca\\\xc5{>)(F\xc6\x13\xb6\x0c\x01\xb1\xc19:\xf44\xe4+f}c\xdc\xb1\n^\x00\xae\x03\xc8\x19sa{\x11\xa2+qc\xc4f\x7f\xad\x1e\xbf\xe7%\xa5\x87\xc4m\xe2\xe5\x9e\x8f\xa5n\xf7\x9b\xb4\xf6\x19\x07\x05\xf5\x0c\xeb\x14\xde\x86#\xfd~\xcf\x8b\x87\xe8\xe61\xb1:\xcf+N\xf9!\xf0?\xaf\xa12\xb5\xd5\xbc\xe3\xf4\xb39oG\xe1+\xa3A\xdc\xc4{\xe7\x14\xd3\x87!\xb8n\xf1K\xa7\x06\x18\xc03\x16\x83b\x82\x8a/\xcaH\xb5.\x8d\x8b\xd7\x0e\x15p\x94\xd5\x1b\xfdxs\xa5\x0f7\xef\xecG\x10\x0b`\xd1\x94\x07\x86?m\xa5\xcf\xfc\x1c\xb1\x12GV\x1e\xba\xd5\xd4r1k\xf7(\xd4+\x1b\xa3Gk\xe7\xd3\xb0\x9c\x83\x9e\xa2\xf5\x83<\xa3\xc0e\xca\xa0\x1d\xbb6\x95p!\xae$W\x99\xbd!\x7f\xa3s\xact\xdb\xcaYN/\xc9\xb3\xd5\xa7 \xdd,g\x7f\x19{^~\xff\x92\xcf\x0b\x9b\x03:\xb2\xae\x83\xe7psL\x92K\x85\xa1\x12*\xce\x0b\xcd\x15\xae[\xc4\xea\xa8e\xda\xf7\x86\x96\x91<\xd3\xc5h1b_\xcb\xfa:X\x03\x96m\x11.\xeb\x15\x00\x8d\xa2q\x9c\x1b\x93\x96\xba1\xeeY.\xa9r\x1dp\xeb\x9d\xb2\xef\xa4\xd8\xe9Fns\xe9ZW\x1e\x07 \xc4\xd5+s\x98ct\x9e\xce\xcf^\x99\xe7g\xc4r83&\xc4mL\xda\x97\xb1\xf2v|\xcf1\xbe[\xecm;i\xc5\x9b\x88\x8e\x897\x9a\x84\"F\xbf\x93\xf9\x1d\xa6\"\xbe\x94A\x00p\n|\xa0`\x02B\x03'\xb1\xfb\xe2\xf0\xc4\x88\xads\xe2\xbe\xfe\xdal\x95\xe9\xf5$\xb3\xf7\xc6#\xfb\xf9\xb8\xcb\x02\xb9K\x81\x9b\xf1\x02\xbf\x93\xa6\xa5h\xaf\xb2\xe7\x9cl\x1a\xbe9\x8a=&\xd9X\xa2\xbe\xd1\xa8U\xdc\xeef\xaa\xbfw\xdbWn.(\x05\xa0\xe8\xcbz\xcf\x16\xdf'w\xa4\x96eo\xd1c\x86 \xe6+W8}S?\xa5\xa3/+\xd4d\x81|\xe1\xe9\x8c(1\xe3\xd7\xf0\xb9\x88\x95.\xa0(n+\xbc\x95\x0d\xddns|'\xcb\xc9\n\xee\x91S\xd7\xe8\x81X\xd8D\xb5\x14\x03\x91\xc55?L\xe6C^\x04\xa5\xa6\xccN\xec\x8d\x8f\xfe\xde8\xb7Mg\x14\xaeG\xfb&\xde\x18\xfad\x14\x89\x11AA\xf1\xf4\x98\x93]\xe2\x1a+\x00\x05}\xe05\xaf\xb9\xa4\x82zA\x1e\xdedY\xe9\x1e\xaa\xe48(wCG\xf7\x15\xf6\xe4\x8be\xd2\x03\xb0\xc7\x07\x93\xba\xfe\xb5Y3\xde\xf3\xc0 /\xce;\xc51\x0f/Y&&\x82\xf7d\x99P\xbcGT\xf1\xec\xcd\xeck\x92\xb4:\x8d0\xa3\xc6\x89\x1e\x96;V\x06\x95\x91 \xf1|*}\x16\xfe\xf6\x04\xb3\xc1{\xb2\xd4\xdc'\x87\xae\xf9;\xd2^\x92\x07\x81\x0e\xc1Y\xcd\x0d\x1a9c{\xa38\xb2\xf2\x05\xde\x98\xb7e%\x9d\xcb\x99\x1f\xcc\xc5u\xbb#\xeb\x02'\xe2\\[A\xdcr\x0en|\xf8\xf7V\x99\xf8\x02\x188\x9dV\xec\xb6\xf8N\xfc\x10\x9fEG}1[+\xef\x0f\x94\n\x96\x86lI\xbb\xd1\xbb\xdcV\xd9\x08\xad;\xc5\xe8\x10<\x00\xa0\xf0\xc2\x99\x15\xde\xe3\x15\x19u\x13\xf6f\xb3P\xcbd\x0d\x17\xd2<\xbc\x84\xd9\xe2\xf1\xe1C\x7f\n\xa0\x9f\xc9R\xca\x97\xcc\xbc\xe4:\xb0\xea\x9e\xb7\x96$\x93\x8f\x1a\xc88+\xf0\xc2]\xc8h9\xb9s\xe2\x02\xb0\x02mf\xaa\x83\x9dz\x1f\xf8\xc8\x11<_\xa8gX\xb1\x95e0.\x14E\xe0\xeeh)y\x1d\xc5\xa2P\x0d\xc1\x9d\n\x94\xd1\x11\xb7^\xf5\x86g\xcc0\x9e\xef\xce\x13\x14\xd8&\xf3\x80b\xd2i\xba \xd5,\x13\xbb\xb4Y\xed@1k\xb3z\x83\xff\xfc\xde\xe3\x08\xa4\xef=\xa4$\x93\x08H\x8b\x80\x89\x9c\x18r\x18\xf9\xbe+N1\xdco\x93}\x16L~\x1eFl{4\x1d3N\xda\xfb\x03\xec(\xf0\xae\xee\x93e\x13\xba\xe4tq\xdf\x03E*B\x8eZ\xeb\xa6\xab\x8b\xaeUe\xab\xa9f\xff\xf5\xc5\xa6!\x1d\xfa\xcb\xb45\xb8\xd7/\x0e\xb5\xd5\xb2\xba\xa8\xdd#b\xbc\x86Vo*M\xc5\xaa\x17>\x0c\xfa\x0d\xd9\xd1_f\xad\x95\xbc~q\xb3\xdf:\xa9\xbbG\x0c}\xaa\x8a$\x8b\xf8j\xd0]o\xbf\xc9\x8c\xdd-\x10\xe0k/\xf7\xbec\xbd\x1d\x9f\x0b\xacn|uM\xbcYNC\xcfPM\xedCqfs\x89\xdc\xbe6\x82\xa5\xf7\x06[S\x1dT\x0e\xbf\xb7CV\xe0\xa4\xb1\x96d\xe6)+S$V<\xc5\xea\xa8\xb3\x18t\xe39\xdb\xc9(\xf2\xb7\xa7\x81\xe8\x06\xce:)\x9a\xa2K\xabn\x89\x9b\xaf#\x9c\xba\xa0^`s\xaasM\x05C\xf0\x9e6\x8c\x0c\xa7I\x83\x96S|>\xb5\x9c\xec\x8d\xbd\x06B\x9c-\x99\xd1\x99\x83~K\x99\x0e_\x81\xe54\x81-/\xdf\x1c*#x\xed\xc2\xf7\x8e\xd9<\x1b\x01\xb7\x12\xe5\x19\xb3\x7f\xefG\xcf\xd6\xb8\x02\x9f\xaaU\x80\xa1\x0c\xb78\xc5 \xe9\xe9\xb2(&\xd7N\xd9\xf7Rl)K\x1fq\xef\xfb4U\xfb\xce\xe1\xb3\xd4\xe5A4\xe0\xf6\xc86\x9bo)M\xa4N\xbe\x05wG\x89\xb81\xd2\x1f\xce\xe4\xf4\x973\xcb(\xd9\xb4\x81\x82\xcbt\x12\xf44\x15XKW\xdd'\x1be\xb8\x10\xd6\x14\x13|}t\xc2\xfb\xc9 \x15\x9fqD\x7f\xbd\xe5(\xe0U,\xb0\x9a\xfe\xd7\xa3+0\x82\x0f\xc5?\xf6\xe7\x1e\xc8Jw\x1b+\xfb\x06z\x98%\xa1\xb2\xf4u\xe4\x8dq\xcc(A\xb6\xd4\xf7 y\x88\x95\x89\x8b\xf9\xb7N\xbaz\xad\x93\x82-+e{\xe1<\xa9\xc8\xf0\xa8{N\x9fa-\x82\x0f.\xbe\"\xb4\x97\xbby\xad\xa2\x02\x88\xbd3?NIq\xb6\xcdg\x14\xc3\x97\x86\x15 x>\xe8,\x95\\]\xe3\xa0P~\x00\xf6\xda}{+\x98nUi\xabi{K^\xa0\x86\xf1\x8f\x1d\xa7\xd84\x16p\x99\x1bv\x14\xf0\x19x\\oo\xf0\x1b\x95\xb9\xe6\xdd\xb98oJz&\xdeX\x13\xb27\xc6\xafFn\xce:\xc5;\x02\xf0\xa0@\xd9\x13\xb3\xd6fA\xbf[ \xdfIs\x85\xcd\xa2#B\xf6F\x0b\xd0q\xac\xc3\xf1\x95@u\xe8\xad2#\xb2%\x16k\xdbrF\x11|mx\x03\x19\xbeUk\x0c\xf4\xd8\x89\xe5+fTh\x16 \xc9/\xc7\xdf\xbe=Z\xf0\xb9a\x04 \xa3c\x181pu|\xc5h-\xe5\xe6'\xdd\xa5\xd4\x89\x87\x8aU`-#\x7f\x9f\xdc\x1a*7\xf0\xb91\x82\x16+]vT\x00\xa9\x92,\\\xbaK\xb6\x93\x00e\xbf\xebz\xfe\x9b\xaf*\xa4\xfa\x94\x9e>:\xad\x84\xb22Hacd\xcbc\x0f\xe6l^\xbfX\xbb\xad\x9a\xe54\x98%\x8f\x97\xb6\xb1\x1f\xef \xad\xde\xb1V\x93G\x9e\xeb\x9c\xcd\xe8\x96|\xd7H\x96\xd3\xa1\x9eqG`j\x80vK`\xe78\xfe\x9dA\x80\xfe\xb9n\xde\xf0\xf5\x8f\xf5\xe9a\x05t\x14\x8c\xd3\xfb&?\xab\x1f\xce\xb2gd\x87\xfa\xe7\xda}~+\x99\xee\xbd\x8aVS\xa1\x81\xbc\xc2 \x93Nn\x7f\xf3\xdb,uD\x18\xbaUA1%bD\xcc\xd2\x9f\xaad;\x15a\xc9\x07\x94\x11\x00\x083v|\xd9\x9bs\x01\xf4\xb7\xb7/w\x16s_\x17\xd3:j_\xc8gX\xb3Un\x00-\xe2\xaaN\xed\x06\x07\x9f#\xbb\x1dZ\"NBe\xfb\xe2l{\xc5\x8c\xe2\x11N\xad\xdd\x80 m\x8c\x0d\x82\xde\xb1\xe5\xb7\x1b\xd17~\xe9\xaf\x1e;'\xc5)6\x9bV\xf2\x18\x0c) )\xfd\x01.\xb1\xc1\xdf\x8e/\xb5\xb9\x05B.\"\xe6\x81\xdeT\xe7\xb6\x14\xb5\xcf.\xf5\xc7\xa9Y\xb7\xe2o\x00\x83\xc57\xdf\xb1\xba\xc4' \xc5\x020\xfa\x13\xb3o\xdb\x93QL\xdfNs#bF:R\x99\xfd\x8b\xebA\xa1\xd3I@m6\xb3d\xba\xc2\x99\x91\x80\x80\xf0\xcd}s\xe6\xb8\xf7\xbb\xf8\xae\xefs\xb6\xe0\x01\xcf*\xdc\xb3\xd0\x96\x9euj\x85r\xd0\xf7\x99\xd2\x8d/{\xedc\xa7\xa7\xebh\xdd\xa0\x83##x\x92o\x05\x97J\xd87\xf6\xf3\x8b\x8d/!\xa1\xe5d\xc12\\2\x9dx\xdb\xf6d\x16\xfdT\xb9G\xc4\xaf\x95\x8b\xb5\x82\xd7\x7fk\xe5\xc9\x8dU\xcd->\x95\nU/x\x87\xe0\x07\xc7lu#\x10\x0c\xc8r\x8b3\x1bi\xd2\x91\x0c\xf7\x92\xec\xc8\xd3\xa6+\x86\x8d\xef`#\x13iJ\xbd%\x7f0\xe6X(\xa6\xbfA\xce\x92r(\xf6\x9d\xd4\xbb\xfb\x0c\xdf)xf\x80\xfes\x17\xc5m\x19u\xb3\xda\xa9\xe7g\xe7:\x99\xa0\xbdf\xef\x8e{&\x90\xd0J\x90\xbd\xde\xd9k\x08|aoy\xa7{\x8c\x0c\xef\xa9\x8b%\xa7\x19;C\x91\x02\xe0\xd2O'V:\x7f\xe8\xc4\xb2r\xa9\xa5W\xf9*\x8d\xae\xcf]\x99>\xdc\xe6\xa2Uyc\xcd\x93\xfd\xd5\xf6l\xd9=4\x91@\x19.\x83N\xbc`\xeb\x99\x85\xfb\xf9\xcd\xadU\xfa\xa7\xc6b\xe9\x8a\xeb\x0fl\xbc\xc5wR\xa1B\x99\xe9f\x11\xad\xf6\xe5%\x87\xad\x1d\xd8J\x93\x13\xabW\"\xbc*W\x9e\xa1\xcd\xc0i\x8f\x16\xde\x93\x07O\x1eaz\xd0^\xc4o\xbb\xd25v\x8f\xb1l\xd5\xefkdoS\xd6\xe3p\xf9j\xe4W\xc4\"\xe0N(_\xae\xecFu\xe0\x0eV\xed\x9d\xe5}F\xb2\xb3\xa1\x95\xd4\xdb\x03J\xc1\xe0\x0d\xcaD\xff\xb3F^T\x93=\x9d9\x98\xb7\x84\xf5X\x06\x8f}'\xe9\x18+s\xc6\xba\xb8\xff\xf2\xc0\xa8i\x8f\xd0\x19\x1e \xe7\xa4[\x1a\xdf)h(\xcfPj\x81\x831\xcb\x8fG\x95\x89\xd9+\x99\x9f\xf1F\xbb\x92\x16\x0b[e\xee\x93\xa9\xd4\x11XB@\x80\xfd\xdb7\x1dgI\xd8\x83 \xa1DQ'O\xecQ\xc3{\xce\xcb\xc2Z\xb2\xb3n\xbc\xb5)\xf7\xc1\xf1E\x1a\xa1\xefOl;\x87\x9f\xe6v\xe3lH\x81\x9f17X^\xf7\xc5\x91\xe5\x90\x13\x0c\x06w\xe6 \x8c:a\x1e\x9b\x8bK\x96\xe9\x1eYHe\x0f\x8c\x18\xf0\xc9\xb7\xee\xba\xb9bE_\x99(\xc1\xda\x95W\xa1\x0c\xda\xb0$\xba\xc1K8\xe0\xb6\xea)uN|\x8f\xd9\xfd*\xe5\x11N\xfc\xec.\xa7\xa7LsW\xb9n#j\xac\x80g\x7fmE\xe7\x06\xf4=\x15\\T\x04\x81\xd5\xfa\xd9\xf0\xa6Y\x9cA\x00\xce\xd7A\xd7\xd82\x0b\xd2[\xd7\xa4\x94B'\xdem\xdbGF\xf7`\x92\xc9\x00\x17o\xb4\x87}\xb9K\xea\xf2db\x10\xde\xe8\xafe\x8eJ\xb0\xa5\x82%s\x83\xe3\x0b\xa5\xc8mIs\xb5\x9c\x1aED\xcf$\x19\x1b\xe2\xef\xfe\xfbm\x06\xe93\x18Pm\x99\xcf\xab\x7f \x99S\xeb\x06hp\x06\x1b\xfe\xd4\xcb\x12qD%k\xab_\xe2 s\xcd\xa4*Y\xcf\xa7&~\xbe6\xaf\xba2\xd7\x8d\x9a\x19L4f\xd0\x93\xe0\x9d\xb1\x02U\xfc\xf8\xcc\xab\x8d\x17\xa8Og)\xe5NU\xdam\xa3\xd9\xc110\xaa\x87\xe5Y\x80^\xfe\xf6\xe5\xfb\xdde`\xe9\x7f\xa2\x8e\xef\xab\xe7[yrN\xb2\x81'd\xe3\x98\xfe\xda\x8e\xf3\xd2\x80l\x80n\xc9~AN\x89\x00\xd63\x02B\x8f\xb8B\xfft\x9a\x13,g\x85\x03\x04\xa5\x12x\xb5\x95\x1a\xb9D\x06b\xe81\xdf\xb8s]\x9c\xd9r\xcd*\x87F\x9d#\xd3r\x9bkVh\xd6l\xb9\xea\xc8R\xa7\xf8\x14Qi\xaf`\xe9a\xc0\x103\xaaJV\xdc7\x89\x06\xb8\x88\xb1\xfe\xf2\xad\x12\x19\x01\x90\xf2p\x99.\x83\xce\xbd\x84\xdf\x14\xcd\xa9\x0d\x0c\x10+\xe8Wa\xfb\xc6\xaf\x17\x84O\xa5\xd8N\xecO\xdbb\xa4\x1f!\x93\xbek\x04n\x8c\xbc\xdd:\x95\xae\xb1f\x92\x8c\xb8u\xc1\xf4,\xf6N~\x93\xb7\xa5b\xc9\xd3S~\xf5\xe0\x1eP\x1d\xe9M]\xcan\xab\xe9\xc1\xfdj\x97\x1b\x0f7\xf9\xeb\x83\x11jN\x1a\x9ck\xabr1\xac>2w\xc7\xc1Z\xf1\xc2?\xf8s\x87;\xf3\xae\xe1\x9cM\xf9\x960 \xbc+\x11\x17\x8cL\xbb\x81(\xe2\xab?\xf83\xc2\xc8\x14X\xc4\x99}yK@\xf2\xb9\x82V\xf0i\x12\x9a\xf1\xa2J\xc67N\xcb{\x10\x81\x1e\xe0W\x07\xef\x01\xacc\xf5\xdb/(\\xI(/w\x8d3+f\xc4|\xeb\xb9\xce\xfdo=\x07\xc6y\xc7\xa5_=\x0f\x0esD\x1c.\x9d\x91#=z\x0d\xb7\xf6\x9e\x892:\xbf\xa4\x81\xaa\xa5\xef\xde\x91\x8f\xe8\x13u\x96\x07=\xa9K\xcbm5\x839\xf2\x00M\x13\xc8\xdb?\xd8\xa5\xddH\xc4\x94\x97\x13\x07\xd3\xb6\x98\xf3\xe7\n*u\x06\xe3\n\xe4\xbc\x16b\xabsb\x05\x03\x0c\xb1\xb6\xb1\xe8\xc7t\xca\xa0\xefJ\xcf\x04\xf0\x8b\xffW\xe7T\x91un\xf3\xaa\xc9\x89\xae\x7f\xb9\x02\xf0\xb0\xb8\xfe`\xa0\x7f\x81\x9b\xaf\xf4%g-S\xc9`\x9c;[\x03 *\x8a\x15\xfe\xf6o\xaf\xeav\x99>\x13\xf3\xe2\x02_\xc4\xf0X\xb5\xe5\xa4\xca\xdfH\xfdP\xa7/\x1f\xef\x8e\xdfF`5S\xe7V8\x17\xf7 5c\xe3\xb2\x7flg\xe3\xe4\x9c\xdbs\x05\xd6^q\x89')#\xfdQ\xbf\xd8q,\xb5z\xb2n\xbd\x90\x82u\xdb^#Co\x9b\x9bW\xaa\x00\xb0\x8d\xa7\xfe\xf8]\xec\xe8Z\x1b\x99<\xe2\x90G\xc3\xd2\xe0\xd8\xd8J\x8b\xc7g\xf6\xbfz{pf\xfd\x99\xb0H\x93~N\x0d\xeb\xa7\x00n\x19\xc9w\x9e\x0b\xa4K\xa0i\xcc\xf5\x9b\xcf\xba\xd9J)\x9c~\xfc\xde+\xacP=\xe9\x91\xd9\x91\xe9\xfdOL\xef\x16l[3@\xe9!\x93\xd0'\x18J\xc0\xe0\x1cz\x96\xea\x97\x0fh\xdf\xa2\x1f47g\xab\x14\xa8`S%\x0bM2\xc9\x8a\xe8\x0f\xfc\xf4\x7fc:\xe0/\xe6v\xbaK*\xdd\xf9\x06\xb2=~\xddp+\xff'.Mq\xe5\xd0nN\x8f\xdfp\x0b\x0c#\xb3nl[h\x17\xf7\xeb\xe3 U+\xf1\xd7\x8fL::\x8e\x17kp{\xa9KJ\xff\x97/T)\x02\xf3\xa7\xc5\"+\xb0&\xd6\xc4\xe7\x81\xfd\xd9\xdc\xbb\xcc>*f\xf6\xd3EN\xf4\x19V)(\x046\xfd3\x07\xf5\x0e\xeeA1_v/\xb0\xa1\xca\xf7a\x1c\xc3\x1ai\x04\xdf\xfa\xeeU\x96}gb\xac\xda\xb9\x89\xb4\x9e\xd5\xf0\x9d\x87\xaa\x8c2~\x18\x0d\x02\x8c\xd5\x9b\xd5;\x82g\xa02\xd2\xd5\x9e\xec\xcd\xbc\xfd\xde$d\xae\xefu\xdc\xb5Xo\x99{\xc9\x83\xae[ \x06\x93\x04\xdde\xcb]o)'a\xc9\"~\xf0\xcdR\xdf\x9d{\xea\xd6b@\xcb\xb8\x90G\xfdO\x1b\xc8s\xc91\x9bx\xf4\xd7.G\x1b\xd7*\x06g\xe2\xa1s\xfd\xfc\xe9vr\x13#\xe7\xf0\x98\xa5\x1f\x1a^\xe3\xf0\x88\xfd@\x9a\xfd\xbdu\x7f\xf7\xa6\x1aW\xf0E\x7f\xcdv\xb41\xad\xa2w&\x9e\\\x7f\xf7\xb6\xd3\xa3\xd7A\xec,\xda]\xc3\xed\x19N\"\x00w:\xfd\x83-g\xde\xd9\x05T\xd7\xee\x8b\xd3\xc47\x04\xf0j\x97\xd6\x13\xa0\x15\xa0K\xff\xbc\xfb\xc4\xb2\x92\xc2\x13Z\x8a\xaf\xe0\xd9\xee\x04q\xa2\x8dS\xfd\xd3\xf6\x8e\xaf\x9a$\xb8e<\xd8|\xab\xe0GF7\xb8W\xf4\x15\xa7\x7f\xf5J,/s\xa5\x90~\xf2\x9d\x87\xc2\xcf\n\xe0\xa7\xd6\x11Nk\xea\x9f\xed\x9c\x8c\xc4w\xed\xa9O\x1f\xabc\xd8\xba5\x83\x81&\xa3\x85\xbfsR\xb7\x06^\xbdqG\x7f\x1dg\xa4)7\xc5\xecL<\xfa\xc7\xaf\xf7S\xf0a\"h\x1c\x8a\xcb\x08\xe84\xb6\xf9\x83\xeb\xc5:V\xe0b+\xef\xc4\x1cx\x0f^\xcc\x8f\x8cx\x93c\x9f\xf9a\x00\x81\xb0\xc9\xa5\x9dQ\x0eK\x8b\xe2 V#{LRi9O\xa9s\x8a\xe5\xcd\x1e,p\xe6\xd0\xde\x02\xb6\xe0\xc5@\xa0\xe8\xbf\x98m\\\xdf>\x8f::m\x03\xb7\xe6z\x18\x01\x97\x89\xd5\x1f\xfd\xc2\x0d\xafLl\x92L\xb0\xb6\x9e-\xf8\xa0U\x12y\xb5\xec~Z\x8c\xe6\xf7\xb6%uK\\,\xd0$|e\xf2f\xcf\x0bR\xa9\x84x\xdf\xf1\xbf<\xb61\x07N*g\n_\x89\x84L\xbaG\x0e\x80\xec^\xbd\xfe\xf0\x97\xd2\x99\xe3\x94`\xea\xee\xc4\xbe\xb7\x0d \xe7\x87\x86\xa3\x84\xec\xa8~u\xae\xa5\xc9\x98\x83\x17\xb3p\xd6/\xcf5]\x81\x95\xfe\xf3\xfe\xd4\xd82RZ\xcd&\x1c\xfd\xb5\xca\x91f\x1c\x8a\xd6\xe9\xb82\xff\xe9}N\x86\x9d\x80\x10\xd8'O\x1e\xbf<\x98\x01\xe0q\xb0\xff\x83\xafe\xd6c(\x11\xbe\xd2\xed\x0dt\xe0\n\xabO\xbd\xfc\xed\xd92\x1b\x86}mW}\xcb\x86\x1a\xee\xa0X\xc6\xcb\x90\xff\xdb\x93\x1a\xd7\xc7\xbc\x83\x0b25\x8c6a\xbb\xde\x9b\x86\xfb\xc5\xf9\xb7^\x966yH7\xe7\xac\xd3!\x93\x12\xd2`?PO\xff\xcc\xbf\x83\x832\xccslB\x8d\x82\x9f\xda'\x81\xf6\xa7\xfe\x96}\xb5\xaa\x95\x7fhs\xff1\xb8$S\x88Z\x1f\xe9\xf4\x9f\x1e\x15,P\xc8X\xa3\x88a\xff1~\xb6\xfe\xd6\xe6\xa8\xd4\x03\x06T*g\x82\xd8!\x85LMH\x83\xe6\"}\xff\x8bS%\x89\xd9\xb9\xeeGzV\xe5\xaa/\xbe\x84\xc0e\x7f\xdf\x83\x94+\xf6\xf7\xcb\xe0\x8eh9GF\x178\x0b\xcfG\x1aO\xff\xe8a\x99\x0dc^\xf8\xfd6\xe7o\x01\x1d<\x05L\xa08\xeb\xbf\xf7&nSo7[H\x87H\xba\xe3\x16\x86\x83\xbej\xff\xf6\x9b\xae_;%`\xde\xce\xecg\xbcr\xa7>6}\xe3\xb5\xbf\xf9\x14>i\xf5\xa2\xf3\xac\xe1\xcd\xbad\xc9-\x02\xbf\x9a\x883v\xde<:x*\x925W\x9d\x08`)f\x7f\xfb\x08\xb0\x8a\x9e\x89J\xc1\xea\x07\xab\xb9+\xd2\x86\x1b\xbfXR\x8d\x17\x8aN\xfa\x19\xa8\xa5c\xc2\xcd\xd2g}n\xb0?xT\x1c\x8d\xa8\xc4\xfb\xe2\xc7\x84D\xfb\xdb\xcc~\x0c\xeeAi\xbf9Mf\xfa\x12\xab\xea\\\xde\xb5\xcb\xf4\x8cH\xae\xe0(r\x16\xa2\xfcf\xe3\xc1\x99\xa53v\xdcf*\xbbi\xc3\x989\x1e/\x91\xa2\xff\xc1T\xd9\xdd\x1b\x9d\x9e\xb5\xe6\xf8\xfc\xf0X\xa5\xc0\x17\x18\xf7\xef[\x94\xa3\x0d\x13\xbd\x89X\x7f\x1dr\xb4\xd1\xc1er&n\xff\xc3woy\xe7\xee\xca\xe3Jl\xedS3\x03c\x19\x80\xc9X\xe3\x1f^\x88\xf3\x93\x8b\xf4\\\xe9\xcb8\xda\xf4\xe12:\x13\xd7\xfe\xd9;\xcdu|W\xc5+\xbe6~\x93\x91\xa6\xf2~{\x14\xaf\xb8 \xd6q\xb7\x8e$h\x95H>\xa0n\xe2\xfb\xf0\xcf\x9e\xfes_\xfc\x19S\xeax[\x1b\xd6\xe0\x1e\xdbo\x96\xb1ee&\xddh\xe2\x87\xeb\xf5\xe5\x89\x05\x1a.\xe0\x03\xe6\xf6\x97c\x9c\xac\xb0\xeeQ\xb8t\x97~\xc6[\xbf|tN&\x94\x7f8FKP\x91`\xaf\xb8\x8b\xbb\x18\xb6v\xcd\xb1@\xc6X\xe2\xbf\xf7\x0e\x15A\xbd}\xb9\x8f\x9dO\xc4\xf2C\"\x95p\xfa\x87\x0b\xce4\xda3\x1en(s\xc6\xcad\xcf*C\xff\xfe?\x1d3q\x84D\xd7\x16Xw7\xaf\xe4B\x8e7\xde\xb7\x7f\xdf\x92\xc92`\xcdy.U0m\x9c\xf8\xe0\x1d\xfb\xdb[\x7ft?{\xb6\x904Z;P\xc7\xbd\x94\"\xe8\xfa\x8dH l\xff\xfc;\xd7\xc9t\xc8\x81\x81M\x9d\xd9\xeb\xb7\x00\xb4q\xc0\x07d\xf5\x0f\x8eU%\xb6\xcdJ#yk\xc2\x82\x9a\x13\xe2f\xe9\x9d_\x1e\x87\xe5\xd3\x0b\xd8\x81\xe5Z?\x0d+\x18\xbf\xd8\xff\xc3\x85\x1d\xff*\x9eJ\x9f\xe5\xed\xe4E1\x0f\xb6\xfb\xa0\x1al\xb26z\xbd\x890,\xf8\x1db\x82J\x06\xec'\xcd\xccP\xdf\xa1W\x0f\xb8D\x10\xb8\x99\x06\xd8\xf9\xb8\x07_}\xa1\xdb0\xdbJ;\x08TI\xe6)L\xacM\xb9\"\xcd\xf4\x1c\xdeV\xed\xca\x93\xf3v,,\x00\xfd\xcd\xac\xed\xa5\x8f\xdd/'\xc8V\x04\x12\x989p}\xf9v*`\xd7K\"\xcf\x95V\xcfO\xf8hIqee~\x15\xd6\xef\xdd\x8d5\xaf\xd8\xee\xbc13\x9e3\xd4M\xab\xe7\xc1\xb5f\xc1#\x8e\xca\x8f\xe4=H\x1d\xd2\xbd\xea\x91\xc1IN[*\\`'9\xe7\x1e\xadIcL 1\xba\xdd\xaf\xe6\x9bgt\xdb\xba\x802\xc8G}\x96@y\x985\x15\xbe\xab{\xbaWV\xed\x07k\xf19\x92\xa5\x17\xf5&\xbeO\xf8\xde@/\x9e\x9e\xd6\xa7~dt\xbd\xc5\x1f\xaf/P\xac\xb24\x84n\xb4\x1bK\xef\x07\xdf\xed\xea\x977QB \xa3\xd2\xda)\x9dE5\x03{\x06\xfc\xba\xa0+\xe6'\xc3Ax\xde\xa6|\xb1\xa8!`\xc3=ZX\xb22\xa17p#q\xcd\x1c\xeb&\xa8\xbf\x9c'\x84t\x86\x99u\xdb\xbd+*<\xea\xe9\x83\x05\xfdN4\xe0\xd5\xec\xde\x11\xc6\x81\xca\x16\xc1\xeaw/\xfav\xb2\xca\x14\xd2\xd5\xc5\x01\x9c\x10&\x84:S\x00\xdb/\x9f\xc8\xe9{\x13\xd0\xe7g\xdex\xfdI \xd3@\xd0/\xbf\x80\x0b\x11\xfa;\xa4\x0bZ\xc3T\xba\x18\xde\x04+\x08\x89F\xd38\x97WX?{\xe0\xd5\xde3\xf3\xdd\xbd\xf9/\x96g\xab\xcc\x88\x94\xf6\x83\x92\xc5d\xe1\x9cM\x87\x182\x16\xeb\x9dY\x7f\xc7\xa1\xd5\xd4##\xfd\xe2[`\x8f\xd3\xbb!\xf6#z\xba\xd893\xb6\xec\xad\xf1\xb6\xd9X\x17S\xf2\xa7\xa5\xe4\x1e\x81\xbe+S\xce\xd1\xe9Ur\xa3i\x98]g\xe4B\xbd\xa9\xac\x08\x97\xed\xd8:\x12\xec\xa3\xce\x1f\xa8\x96-m\xff\xe6:\xc5K\xc9\x83\x92\xf1sv\xdfu \x18\xba\xc4y\x8f\xb7\xd3\xa1\xb96OC\xca)\x11\xf3\x0e\xdb\x89#w\xaa&q\xb8\"\xecs\x7fs\xdd\xfc\xe3\xaf+?k\xb1~\x03\x00\x18\x00\x1c\xcf\xa6}\x90d\xc8S\x83\xc9U\xa3\x913\xd6\xae\xa6>\xb7\x80y\xe9\xe2!P\xba\x1e\xd1{ \x03\xcb\xac\x0f\x05a\xdfh\xf4\xab\x9d%\xae\xb0\xc7\x95hyD\x92E\xdd\x92\x8e\x8cF<\xd2\x88\x81\xd2\x14C\x7f\x97\x86\x0f\xce/\xc7'M\xe8\x9az\xa2\x10\xbfu\xef:\x00\xfdO\x1b'\xf5f\x9d\xed\xa74\xec\x0c{\xb4W\xe0w\x1d\xba\xbb\x1a\x9af\xbfZk5Z0\x87\x17H\xf2\x1d\xa6\xefw\x92z`\xea\x7f\xb5jp\x94a\x14$e^x\xc0K\xfd\x1d\x8a[\xa8t\x97b'[\x8d\xa5\xc6\xf9\xbc\xb8\xd8\xa7\xd7\xc2x\xd9Q>p?\xbe\x92C\xdb\xde\xcc\n{zP\x9e\xd5\xe0\x0ebJ\xad\x93\xa5\xc7\xa2\xd0\xaa\x06\xc0>(\xfa\xfd>?\x02k\xe1\xfdlu\x8e\xb3\x1cVd\xb2\x83\xd1\xb2!^\xf5\xbe+\xeb\xc4\xfd\xa9\x07\xae\x92\xc8\x074[\x89\xec}@\x94\x08\x8824w\xfc\xfb\xb7'\xe4\xfd\x83\x80>D\x8bW\xa3\xc4\xa1\xd4p\x8b\xaf\xa5\xbcU~\xfc\xb5~\xb3\\\xc0\xb1<\xfa\x8a\x84\x11\x19\x8a\xdf\x9d\x94\xeec?\xef\xce\xd9\xcd\xbb98\xfd\xc4t\xf9rp\x9fZ\x18\x19 \x80d\x13\xbdm\xc1$\x12i]\xfc\xae\x0dg\xff\xc3\xcc\x98\x97-\x9f\xdd\xf2=r\x9a\x10r+\xbd\xcf\xdeL\xaazU~\xe2\xaa.\xd9[\x00s\x89|nG`}g\x08\x19\xc7\xa9\xd1\x84\xf7\x04\xe1\xbcf\xa1W\xab\xce\x97EH<\xabB8xL\x18 _4}\x92I\x03\x7f\xef\x10\xa8K\x04u+x\xceq\xfbl\x1e\x8d\xe8\xa1+'\xf2\x04\x07\x96\xf5\x0f\xbf\xdb\x16L&P\xd8\xe2=\xa9\xd7P\xa3#\xef\xa7[\xf6%\x84\xfe\x88\xc0\x8d\xc2_\x89\x16\x97\xd1\xb5\x89\xc1\xef\xdf\xc4l\n\x95\x98\x9e\x89K\xce\xc5\xcee\x80\xe11{\xc3\xfe\xfc\xd6\xe7\xf1P\x13\xeaU\xf40<\x15\xbbf\x81\x9f\x08\xb4\x11!\xde\x12\xd1\xaaM\x16\x0f<\x96\xd3\x03\xebv{\xd5{\x94\xe2\xfa\x1f[>\x1a\xa7\xb8\x06\xe7z\xb9\xc2\xe6\x9a\xc1{\xf7T\x1d\x94\xacsp{\x114'\xe5_\xca\x92\xb8\x9f\x89\xde\xf3i\xf9\xbd\x89\xf2\xaa\xa9\xbb\x03\xbf\xcd\xb9D\xec{\xb2\x0b\x8b\\\xd71\x03%\x9cTmo\x9fN.\xafN]\xf8\xc5\xb7\x1b\xe7\xe5\xd4\xf9\xbaU|\x07\x9b\xa0@\xb6irqjtWz=\xce\x0d\x98\x16,w\xc7\xb4\xf9\xdbs_y\"\xcfxq\xf6\xdd2G9\xb6H\x91\x07]\xbb\x91\xae\xbb\xaf\xce\xc4]7\xdf\xaf\xf7f\x90\xc4\x96X\xdfY\x81H^\x0f\x14\xa2\xcd\xba:X\xe0\x03.\xf7o{\x94\xe7\x85eUU\x06\x06\x183D\x1f\xb1o\x18\x91fZ\x04\x7f4\xe4k\xcfw\x12>kuN\xb5~\nL?L4eJ\x0f\xb5\xdf.a\xa08\xf9\x81_?\x1fQ\x9e\x1aK\xd6\xd0\xde\xbeE\xb0E\x1f\xe5\xce\x8dK<)\xa5\xa4>r7\xffz(\x1d\xcc\x15=\xfa\xe9\xcc\xf2\x8e\xe6\x8a\x06_\xbd:\x97\xaa\x1a\xec\xc5\xb2\x90\x93g \x80\xdfxx\xcc\x86\x1a#\xf2\xf9iT\x8b\xfe\xbdI\x18\x85\xe5F\xd65x{\x17\x18\x0c\x08\x88\x05\x15xM\x11\\\xc1[\x8e;\x89\xef\x15\xf5\xf8\xce\x15\xba\xaf\xa7\xc1\xd9\xb0K\xe8.\x8f\xd5K<\xab\xc9\x95k\xb1\xf3\x1b.\x1eW\xf2'\xbe\xc7\xbc\xa3\x97\xa3\x9d\xbe\xb5\x888\xc5\x17\xcbq|g2\x0c<\"%\x8e\xdd,\xfc4#\xa6\xc9\xbf\xf7\x9chn\xb7\\\x8d\x97q{M\x99i\xbd\x99\xf0\x110\xe2#`\xd6\xfdn\xa4\xb6\xcf\xed\xe8\x8e\x1b\xdaU\xd2\xcd\x1daQ!\xaf\xd1\xda\x89L'\xf0`\x19\xfb[\xb2\x93\xf1\x8e\xd5\x9b\x16b\x94\x9a\xeeQ\xb1\xe5_\x87o\xea^u\x17\xda\xf8\xf7E\xdf1(\xbbr\x07\xd4\x0f\xce\xde\xafB(\x91\xd7\xe8Z\xdd\xc3:\xe6~\xc2\xc4\xba\xa0@\x80Q\x06fP\x9aA\xe2j}\xa0\x93\xb7a\xaa\xf8\xce\xf4(-\x08-\xe0%>\xb3\xe1W\x03\xa3\x01e,\xa9x\xbc?\xf09\xd8]&\xba\xe2f\x9c\xd3\x8df\xfc Bx'\xba\x05M\xe7;o\x16\x88o\xacgs\xa4\xccjb\xb2?\xc7\xb01\xc7^j3\x0d\x1f\x07b\x16\x161\x84\xbc6\xb6\xb7,\xac\x07\x96\x85$\xbf\xc4\x06\xc3\xbf\xd6Y\xf1U8S\x9f\x185\"\x06\xf7\x93 \x97\x11\x0f?\xf08\x9e\xf6\x0b\xc1\x83\xfd\x00\x1e-?\xb0&\xa7\xe8\x1dG\xa5\x05\x1e^pP\xaf\xab\xc1o\xce\xed\xec\xc1\xda\x08\x06 7\x9f\xd6\x82{.\xda\xd7\xc7]]\xbb.0f\xc7\x13A!&\xa8\xe2\x05F\x9e\xa8\xbc\xf4\x8a\xaf\xe2\xdaH\xe2\xa9@\xe2EoS\xc6\xca`t\xb0o\x19Al\xa6A\xe2\xee\xbd\xac\xc4\xbbQ\xe1\xf7G\x8b\xde\xebW\xf6\xa1\x02\x97\xc6d\xf6\xbc\xb1\x1f\x88\xa8\xcd\x89^\x84\xa2\xeb\x93\x99\xb3\xb8\xfdx\x9f\x08f\xd2\x95\xb0\x9e\x06i\x1dK|y\x08==J\xfa0\xdfL+VH\xfd\xe0\xf4\x0d \x8cM\xcfj\xbc|\x03\x03I\xb8}M#>Y\xc2\xcbCX\xf5\x83\x85\x1bwb\x0b\xef\x92(\x15.\x9e\xd5 \xb4\x0c\xc3\xeb\x1a\x11}\xc7\xb3r\xc4>\x08Q=0|\xee)\xad\xc9)\xad\xdd)\xd3\x9dH@\x80\xe3%a@L3\x0e\x89H\xf0N\x91M{UK\x99\x05\x1f)\xb5uJks\xdc\xed\xef\xe6\x83\xdaw\xff^\xe1%\x08\xab\x9c\xa5\x16\x1eV\x94\x1bgH\x18<\x95\xbcF\xa5G\x1d\x1f,\x15\xa4\xfc\xfd5\\\x97\x9dVO\xedP'M6\x8e\xf3X\xa7\xcax!4O\xc4\xc2\xfdX$]V\xfaPo\xa7\x82\xf4\xa6\x93.\x98\x11\x0e}o\xde\xc4R>\x0d\xb3\x17\xb7\xd8]\xe2\\\xc2\x0b\x93&\n\xb3\xc16\x83\xe79\x0bf\xbc\x81G\xe2\xe3\xdc\x06\xd6\x8b=\xb9xm\xbe\xcd)h\xc1\x05\x19\x10\xd7I\x1f\xed)\x1c\x01\xbeWH\xf4`\xdbX`[<\xdcs\x1c\xeb\xfd Q\x9f\x9b9\x0d\x94f\x18ud\x98L\xb4\xe9v\xd2\x86\xe2\xf9\xb9 \xb7\x05\xd3f>\xb3\xfd\xdeY\xc5\xf3\xae\xe6\x84\x9f\xd5.\xad @\xd5\x00\x8f;>\xe4\xf3\x1bW<\xe1\xf2\xe0\x83\xd5\x82<%\x11*$\xa2\xc8N\xd3\x19\xbb\x01v\xc4y\xd6\x84<\xab\xe3W\xef\xdb_\xbb\xda\xcb\xa7\xd8\xdc\xc7\xd3q\xc2\xe7\xed$\x1f\xf5%\x9b\xd5\xc3~\xc1\x17J\xbc\xa2|\xf9d\xe6^N\xe1\x9c\x1e,\xef\xf8@\x13\xadP\xeaJ\x93\xe56V\xd0\x89e'\x04\xe4S\xb2\x984\xf3\x99\xb5B\x95Vz\xf7\xf9\x1e_ii\xad\xd5\x9b\xe2\xaam\xc6T\xfc\x13\xdco\xb2\x8eO\xeb\x85 \xc7u\x13\x0f\x82\x92\xce\xbb\xe1]1Sg:-\xe2I\xedF\xba\xb6GX\xe7\xa3\xf19g\xda\xfb\x19yqggD`\x97e\xb8 \x8f\xaa\xa1\xcd\xfa\xce!\xc4*\x08\xb1Yz\xd6C\x8c\xeaV\xa4\xf0\xbe\xb9\x8a\xb0Lx\xe9C\x897\x12\xbe\xfb\xde6\xd5\x10\xc6\xefc\xd7\x11Vz\xee\xbc\xda#}3)\xc8#\x8fKW\xfa\xea\xc5\xcd\xf4HU2\xf4\xcd\xb8P\x0f\xd8\xef\xb7\xc3\x90\x02e\xdd\xb1U\xea3\xbd\xec\xa3`T\xad\xacd\xee\xfe\xed\x12\xe4T\x1e\x85\x1e\xde\x1f\x1d$\x96\x94\x10-\xcc\xd6\x11}\x80B\x88\xf0\xd6zzI n8\xf1\xf8\xae\xbb\x13fu\xabf\xadh\x94@U\xc8\xf1\x9c\xd2\x10{\x14bDRBq\xc2\xe8\xb8K\xa7\x80\xdd\x83s\xc0??\xfdk\xd3\xa2\xa5Sj\x14\xbeF\xc6\xa0\x80Y\xb7\xaaRR\x86Q\xc7\xc6-\xe4\xb69~&\xbb\xabx\x93\xfcFD@Jh\xbd ,.\xb2\xed\x94\xb7\xef\xeadu|\xe9{?SC<^\xcb\x8a2\x1f>[H{\xe3\xb7n!\xb6\x8f;j\xaa\xe5~\x8bI\xfe\x95)n\xa6\x87o\x9c\x1f\xc3 }\xf0\xed\xf8\xa6g\x12\x8f\xdb\x95\xb7\x1e\xe8\x03j\x8f\x16\x1e}\xad\xd0%\x0f\xe6l\xaa7\x9f\x0eo=\x94\xee\xb8\x8bV\xe6\x08:\xbe\x0ena\xd9\x10!\x1b\xcd\x984\xbf2\xcb}Q\x9a\x1f\xe3\xe1g\xda\x1d\xf4\xe5fJ\x8e\xafeN~L\xff\x0c\xe5\xab\xf6\xe1\x93\xf0\xe1\xfd\x890\x9a\xb3.\xd8\x98\x90P)\x10\xe6\x86d\xfa3\xf6\xc6\xe6\x8a\x00Z\"\xeaqe\x18\x95\xd5\x013\xdc\xf7\x8c\xbf\xd9\x942\xba\xa3\xe9;<\x92g\x97\x9fQ\xcb\x1e9UpVo\x90\xe1\x89\x1cm\xac\xf9\n\\\x99\x10\xe4\x94\xbe\xbf\xd9\x0d\x96Dj|\x1e\x14D>\x9eE\n\x9d\xc4\\\xf5\xbbt\xc3(2l\x8e\xba\x810\xc7\xdd\xe3.\xc7\xaf\x9b\xc3\xe2%2?\x8c\xe1\x9c\x013\x98V\x89 ~\x18\xbd\x10\xdab\xd92\x96\xc6\x84\xdf\x96\xf3\xc5\xe7'\xe6\xf9U\xf3..4\xe0\xa7\\\x0c\xf8\xb6\xd7\xcawe\xbd\x10)\x85\xd4\xb6\xf8\x0fQ=\xfck\xa0\x19\x9d\xdcj\xfe8\xb5\x12\xae5\x97\n\xa0Y\x84\x9dM\xd3X\x89c\xc7\xff\xac \x1e=U\\\x9d?\x919:\x01?\xaf\xc3\x87\x9bYi2\x11\xc7\xca\xcas\xd9\x8c\x8f\x8d\xd5\xa9\x9d{}\xa6*l\x0d\xefK\x8ecyB\xe3\xf0K3Uu7\x10\xd3\xc2\x8b\xe9\xb68]\xd4*\xe7\x881\x8f\x0f\xa7\xedL@+ijc\x05\x81\xaa\xefO\x0c\xb97\x02\x99\xd0\xec\xd3\x0b\xa3\x91\\t\xc1;\xbc\x89\xe3\x03W\xc6\x11V}\x9f\x04\xbepc\xc7\xe7\xb3\x12\x8a\xeb='\x08s\xa67\x80\xdc\x89<\xca0\xb7\xcf\xde5\x89\x95\xecJ\xfe\xf9.\xc4f\x85\xa2\x94\xa2?Ke\xaf\xf53\x84\xf7\x02\xcc\xfb\xce{\xc7l\x99\x0b\xb5\xde\x8frlx\x8d\xfbD\xecsg\x10k\x192\xcc\xd4\xab\x9b\x96\"'/'\xd8\x0b\x0b\xebK\x9dt\x82$\x92\xe1`_O\xca\xd2k\x18=9B\x1bR\x7fs\xfc\xaa1\xb3\xa9 \xc4re\xab\x87\x99G\x14n\xcc\xb1\xa7P(\x05J\xf2\x0e\x1f\xcc1\x19\xf3J\x9c\x8f\xfa\xfaP\xe6\xe75,\x05I\x91\x1f\xfb\x98\xe5\\\xa2S^\x98\xb5\x86\nB~/\x99\x17V\xbfL4yf\x9f\x01\xa3\"'\xd8\x8du\xa5\xd8\xf7\x1bb\xe18tq*9F\xbb\xd2,qQ7\xf1\x86G\xcac\x1bL\xf8@\xb2D\xee7^\x08\xd4\x89C\x90\xb35nO.\xa50\xd7\x7f\xfb%\xad\xde \x8ceS\xe5\xc3\x8dpA\xf5\xea\xa9\xbf\x1a'x\xd4\x82\xf2\xd4\xb0\x85~\x1b\xf8\x03\xdbs+\x17\xaac2\xf8\xf3\xb8\x8b\x86;\xe2JY\xda\xa9\xf0\xb0wuYo\x83\x0b!\xab\xceY\x13o{\xdeSF\x1c\xc2ud\x88\xe5H4\xcdw\xfcY7Gn\x8e\xc1=\xacKF\x17\xb8\x16\x82G\xe5`n\xfb \x0c9\x1b\xf67\x8b\xbe\xe7\x10i>\x1aS\x07\xd4\x0b}2\xe5\x07#n\xc2\xc4\xb4aY\xb5#\x1c.\x84\xad^\xa5\xd8\x0d\x0b\x17{\xef\xeaa \xac\x1e\xde$9\xf7&\x9c*\xbf\xfa/l\xab\xd9\xa6(,\xa3U\x8a\x92\xeds\x9f>\xf9\x87\xe53\xe2\xf6r\x88`A\xeeG\x0d\x18\x1arltq\xfe\x89,\xaf\xb2?\xfc\x93TC\xdc,\xcbZ=\xde\x0d\x13\xfa m,xARoz8gc\x9e\x9f\x8b\x05\xadO\xf8$\xfc\xdais\xd7\x06n\x89\x08%y\x07X>\x0d\xd8\xc3k\x03\xb1e\x87\x97\xb4\xe7\x89c\x17\"I\x85hx>\xd5~\xdbDa\x8b,%n\xe8W!\x11P\xc5\x03\xe8\xa2\x86}5\x94\x1a\x15Y\xf0\xb6?\xc6\x89\x18F,\xa6ZQ\xf3\xb7\x97\x17DZK\x04>{\xa7\xf435\x10\xae\x88\xcc\xeb\x08|U\xa3J\xfe\x88T\x1f!\x92o^\xd6v\xfe<\xd8\x9b\x8b\x89}\xd3\xd7\xce'g\x0b\xb6.\x11Q!_\xd1u\xc6b\xa7=Z\xc6\xf7,\xe3\xa6\xd2y\x1a\xf4w5\">/\xee\xe6\xa6\\E\xb1e\xb4\x11w\x8dy_xk\x9c\xb3<\xef\xb8\xfb\xc1W\xe2&\x08\x9e\x86~\x06\xfd\x011\x04Q\xf2\xcb\xf7\xc9\x88\n\xae\xf7\xc2<\x8e\x17\x95\xd6\x04\xfe\x92>x>!\xb5\xde\xc7q\xb6\xebO\xdc\x1f\x87\x16\xb3\x1e.\x9f9e@,H\xb0pJ$[8\xd7\xf5\xbdq\xaejN\xab\xf0]\xee\x10\x01\x8e\xdc\x0f\x1a0$\xb4\xb0\x06\x0f:\xa9\x1fn\xbcI\x8e#&\x06=_;AY&\xf8H\x07\x98|\xce[\xe2\x06\xba\xe9\xc77\xcdxDEx\x05\xfc\xb7\x9f\xaa.\x9e\xf6\xc9H\x94\xcb\x18\xf3\x08\x83e\x94\xf2F\x8e\x03\xd8\x87\xe1\xde\x82\xae\x9ak\xef.?\x88\xa7X\xf0\x9a\\\xb8.\xaa\x14_\x0c\x1c\xe5\xebb\xcd\xbd\x19\\\x1bQYr\xe1\xb2\xfe\xa5\xc15\xa3t\xfas?\xc2SV\x95\x06\xe1L\x88\xbe\x10\xce\xdb\xe7\xdd)\x8f\x1cM\xe6D!\x9fe\xd8\x1c\x1e\xb3\xd0\xe2X<\x1e\xb5\xcd\x8c\xe7\xcb[\xfc\x0f\xfa\xbe \x9bn\x87\xaa\xf34\xd9\xce\x1a\x8dn\xb4\xdf\xa7\xab\xe2H\x9agJ\x8fr,\n\xc5\x1d\xc5-\x07\x1b\x93\x0daF\x91\xac\xbdc\xa8\x08cS\x07\x8b}2\x13\xadNYX\xae\xae#\xde\x11N\xf0\xcf\xc9\x0cap\x0f\xcf^U\x96\xef]^\x88\xd1*\xa1Bf\x08B7\xd4\x8d\xbbo\xf3J\x90\xb7\x07I\xdb\x1d\xbb<\x12\x96\xa4\x90\xfdm\xb4u\xe9SK\x08\xdf\xaf\x1bC\x87z\xe38cH\xf4\x9b\x94\xe0YJ\xd6\xa6\xeax\x1e\xcc-\xf9\x8f\xa6\x19\xfb\x07\x89\x865.(/4\xb8\x870o\xec-v\xafU\xd1Z\x91\xe1\xae\x02\x9b\xb6\xc0\xc5s2\xe0\x07\xca\x08\xc2\xa8\x1b{,\x93\x9f{\x86]\xfe\x168\xde@\xeeX\xae\xb1\xc4\xb8{\x0c\x18>\x15\xae7\x923\xbe\xfb\xe6b2\x12\x17\x95\xd7 \xa6\xd3:n\xb6\xaf\x15\x17\x11(s\xf9\xd5\xa2\xb1t\x86e\x82\xbfYj\x18\x91wx|\x12\x8a\x92\x17X{\xa5F!l_\xde\xdc\x0b\xf6\xcf<\x06\xc5\xda2\x1bQ\xf0\x9b\x96\xbbm\xaf\x14H\xe1\x8d\x9a\x98\x13\xaa\x124\x81\xcam\xac\xd4\xe6M\xfb\x8an\xac\x92\x15x\xfc\xf8h{\x87\x8d:\xbc\x9cW\xbb\x942\x8e\x8f\x04\xfe\xfc\xce\xac\xd2\xda}\xbaw\x16s \x8f.\x9a\x11_\x82\x97\x1c\x04!j\xe9\xd3\xe5\x1e\xf3G&\x17\xee\x10}\xf5]\xcf\x01\x97\xf4\xd98\xc2bl\xf1\xbdo\x96\x1b\x9d\x1a\x15\xa2#\xc2\x9c\xbaK\x0f\x8f[G=\xee\xbc\\\xbf\x92\x96}\xa5\x96U4\xcc\x8b9\xb0\xfe*\xe9G\x8fc\x1ez4\xe9\xe4\xaf\x17(\xc5\xdc\xa7\x18\x97\xa5bGYf\xb5<\x06p\x93\x94\x12\x16\x12\xbf\xc5\x8e\x92h]c\x98\xd4\xf8\xa8NK*\x83 \xde=5\x8a\x17(\xa7\xb4\xae2e\xef\x97\xc7\x92\xe9d\xd5\x0c\xc19K\xeb\xb6\x92\xd4B\xe2\xcf\xcf\x86\x92\xf7\xbak\xe3\xd5\xc6\xad\xfe\x15\xbcJ<\x1bE\xbc\xe9}\xc1mEH\xaa6\x110X\x92\x06\xcft\xf2\xd5\xc8\x05\xefU\x00\xbd\x90=\x90md\x9f\xd0W\xf9M\xe1M\xfc\xe8\x9b\xa5hM\x1b\x9e\xdb\x8f\xa9\x86F@(\xf8\x10\xfdX\xe5=*#J\x96/\xa9w\xdfKqh\xa2\xe4`\xf6\x8c\xca\x84\xbd;\xda\xed\x10\xe3\x1e<\xea\xb7t\x84\xfe\x87\xeb\xe1\x85\x0cg\xaf\xad\xcd\x12\x95\x0c\xb5s\x97\x10v\xb1k& \xde\xfd\x8cz\x1c\xbf>\xa3\xcc\x89Z\xdep\x9d\xb3\xbc\xb5\xc7\xcbG\x85Z\xf0\x9e\x0d\xaf\xb1v\x19B\xa0\x9a\xa7\xcf\x8b4\xfa\xb9gh\xf3\x94\xe6\xd8\x1b\xfc\xf3\xe4\xd7\x8b\xd0\xe4h\xa9\xdf\xc3\x07\xd7'\xe4\xec\xa5\xa5#|\x15\xd7\xe5\xf2DU^+\xa6\xa1\x10bMU\xbck(\xab\xe8!\xe6\xfe\xf7\x8e\xce\xea\xaa\x81U\xfb\xc1;\xcf\xc5\x0c\xaf\xba=\xb9'\xec3\x0bb\xb5\x8f\x9e%\xc5\xcc\xd1\xde$\xbe\xa7OH\xc9`\xbax\xf6\xea\xf3u\x9b\xd6g9l\xcb\xa38n\xf3\x9dT\x97\xdd\x9a\x96\xedu\\\x18\x97d\x12\x8c\xc4\xfa!8\x83\xd97\x17\x97|N*@Q\x9c\x001\x8eg}\xa4\xec\xb7\xa2\x04\xac\xf0\xb0suI\xe0\x81\x10H\x93\x926J\xda\x08\xcb:\xf4\x9cz[P\xfb\xd2\xc4g6\xb6{d8\xe7\x95\x86\xbb\x84\x1d>\xf3\x8b(\xe7\xcfEj\x9b\xa7>\xc2\xff\x9b\xa8\xabH\x8f\x9eg\xb6\x0b\xf2\xc0LC33{ff\xa6v\xaf\xfe>I\xde\xef\xfe\xd3\xb4S\x92JE\x92\x8fOA\x8e\x14\x83hq\x8c!xn\x00\xff\x1e\x88\xb2\x8e\xe5L!1\x9f\xbb\xb9\x18\xd4\x07\x8c\xd9\xdb\xe3\x0d\xf9\xf2\x12]\x8aQ\xe9\x98x\xc7f\xe6\xfdU\xb6!7R\xb4>\x14V\x96\xc1*\xac\xfa\x14\x97D\x88\xb6\xd6\xfa\xbeS3\x1f\xb4\xa6WF/\xb5\x90\xa3p\x8c8\x15\xe6\xcbG\x89\xabq\xd1\xc1\xcct\xd2\xd8\xba\xc8\x9bt\x9d\xab\xf5\xaeZC\xbf\x8b\x13\x18tQ\xfa\xd9\x11\xb1\xb8\x8e\x17\xd7\xce\x01\x92\x04b\xd7\xa8\x8a=\"\x8d\x9f\xf2\xd1b\x18\x03\x08>\x85r9\x18\xf5$\x91\xf2c\xce\xa5`dnK\x10\xb6\xdd\xf6)k\xde\x1bp\\5\x0e\xc8m\xbe)C\xb18/\x11s\xc7l\xf3J \x85_\xaa\xa0\xa7\xcbz\xabh0\xf1\x87t\x81\xf6\xf6\x91h\xe8\xb5;\xc7\x82\xc7w\x1b\x834\xe5p\x87N\xbc4\xd0;y\xe6\xf5\x1ba\x84N\xbd\x81hH\xfb\x8e\xf9\xe32\xaav\x99\xfb|\xefDY\x90\x91\x15;\x1a\xf3G\xd4= &2^3\xefn\x8b\x86\xafy\xd1\xf0\x05\x11p \x19\x98G:t\x12zh\xff\x90\xe2\x8a\xd3\xaa\x9ay\xe8\xf4\xfd0{G*\\\xbc\xad\xb7A\xa5\x83F\x87$T\xae=\x96{HX\xbbO]PZ\x99x\xf3\xb6O\x14\xfe\x1dG\x0e7?-\x96\xc2\x15\x88\x88P\x8bjW\x1e\xe3\xe6\xfbAB\x8e\xd8\xee\\?vP\xb7\xa4n\x04\xa4\xaa\xe9`0\x8cM\xa848\x93\xf5Z\xaf\xa7\x1f\xcbxk18\xd5\xfc\x9bb\xda\x82\x10\xca\x9a\xf9\x06BZ{L2\xce\xd6:\x93\xed\xc8v\x9f \xe3j`g\xdc0v\xcc\x9ezZ\xde\xca\x8b\xad\xe0\xbe>\x19NJdRfA\xc4\xedy\xbe\x0f\xcd\xb8rDy]\"Ve\xca(\x99e\xb9\xdf\x17w\xc9\x17Z\xbf\xd9\xc8]\xa8\x086\x94\xee\xc7\xb2\x1c\x83&\x89km\x9a\x81\xac\x1d\xd6R0\xea\xb1u\xee-\xbd\x9c\xfdV=\x99M\xa53K\x07\xa0\x0e\xc1H\xf0\xdd\xc7x-\xc9\xe7\xdb\xf2\x9b\xa1\xe5\xb6\x1bT\xb7\x7fE\xc3\xbe\xa09\x92&\xc5\x9a\xca<\x8c\xd5\xe6\xea\xb9\xc0d\x07}\xce.u\xf7\x86~^V\x92p*E^\xe3\xf6\xa8\x01tD\xaa\x99\xba=\xfa\xcdY\xdf\x197\xdf\xf5rG,#\x11X\x05\xd1Z\x8e#N%\xad~\x7f\x81\xba\x8c\xe8\xed\x93\xb0sY\x0b\xc19~\xb1\x8f:\xf7\xd7\x02\xcc\xfd\x15\xc4\xfdg\n[\xed\xdd>(\xcc\xefU\x84:\xb0\x03\xb6V\"1\xf1\xfd\xe6\xe3\xe7\xd6F\xca\xe4\xcc#\xf5\x03\x03\xb7\xea\xb6\x9b\xfb\x84\x18\x8f*^\x82\xaf\nU\x1e4\xd0\x05\x08\x86@x\x10u\x05\xd6u\x9d\xeate\xbf\x15\x10'\x8e\xbe'Y\xee\xed\xce\xd5\x7f\x9bw\x13Z\x04\xe0\xd7\x0c\xfc\xd1\x8d\x8d\xc6g\xa8\xab\xc2I\x93\xdbw\xb3\xd6\xe5q?\x18\xb6\xe3\x89\xd6\x9f\x91n\x1b\x86U\x861\xed\xa7 q\x0b\xf4\xbb\xd1\x96\x05\xe3q\x1bs\xa8\xbec\x17\x9el|R\xc6\xf9$\xde\xaf\xf1c+\x1ea\xbc\xb4\xdc\xc4\xf5\xe4\xac\xf8\xd1\xf14%y\x9f\\\xfc\x19G\x8fosz\xbe\xda\x19`\xe4\xbc\x84N\xeb\xc7g\x94\xf6T\xc8=#]\x10\xafZ\xf3\x8f\x9e\x8b\x81\xd9\x7f09\xf7~m\x1a\x9d\xe7MI\x1b\xd0\xc2s\xad\xbd\xaf\xab\xef\xa2\x01=n\xdfw\xc5U\xaf\xc0\x8dT\xceR,T\xeb\x81\xb5\xaa7Hu\x11Q\xdc\x00\xf7)\xe2\x94\x9c\xbau4\xd4q\xe84\xacD\xd6_\x14\x1f\x08\xd9\xab\xccN\xcbft\xed!\xfd\xd7>Ge\xa2\xa3W2\x04xa\xd0\xfcR\xb9{t\x87R\xae\xa7\xf43\xf2\x94/c5~ \xda\n\xaf;\xa2\x1b\x1fJI3\x0f\x98rc\xcfE\x81\x00\xe2\x96$z\x8a\xda\xb2o1\xedst\x96\xe4@\xe1\x13\xa7\x17|;\xe7o\xe9\x0d\xa8\xda\x13\xd9\xfc\x86\xe6\xbb\x02V97\x18\x86\xd9\xa6\xe1\xe0\x11,\xb0\xf8\xc4\xc9\x1f\x83`\xa5\xf7\xdeQF\x1bt\xe7\x19K\x12\x82\x08\x0d\xf3\xc8\xd2V`-\xac(\x83\xf8H\x15\xd8!\x91\xa6\xc1\xc7M\xa2\x01\x88\x8f\x80\x0f!u\xa4#\x1cq\xbfy\x85C\x95%\xab\x91\xb0k\xab^Bw\x81iIV\xcf\x01\xd4\xe2\xd4\xdd\xd0G\xe2\x076lK\x0fu\xdc\x9fP;\x87*\x02'l\xef\x8f\xfc\xec\xbb2\xc5i\x0dO\x9ck\x03u\x99\x92V\x89\xa0\xd6\xea\x84\xc3\xa6\xa5\xeb\x91\xc3\xe9K\xf7\x93\xfc\xa4\xb7R\xfe=;\x9fB\x1a\x810\xb6\x8d\xd7\xe0'%aD\xda\x02S\xb3\x1b-\xb7\xae\xd7\xc8\x15\x06_$Sr\xc7\xd1\xceZB]Z\xf7\xec\xf8S \x89\xcc\xe0\x18\xbdI\xb9\x8aMe\xf3c[\xbf\xb1\xfc\xda@)\xb5\xde\n6\x05\xe2H\xf3k\xc4\xe76\xe6e\x7f\xc7&\xbc\xc8\xfb\x92*\xbc`\x86(\xeb\x9b\x15\x1e\xde\x8c\x1c\x92\xc9jfz\x06\xe2\x7f\xf0\xc3\xf0i\xac*\xd7-\x9c*\xe3#\x87\xaa\x03\xe6\xc0H\x88\x83\x12\xbe\x97\"\xfeb0\x84m\xeb\xf1Xw\xde\xd9\x18\xa6Z\xb2\xd86\xa2\xa0\x0fe\xca\x8fu\\\xfe\x1a\xe4\x92\x10\xf2`\xff\xa1\x1c/\xb82*\x01\xa0\xfb\xd6\x06gm\xc6\x9c\xb9\xae[N\xf57\x19\xf4\xf0\xc9\xb9\x8c\xa0G9Y\xe6\xe9=\xf7\xda\xa5\"$\xe9\xc7\xdf\xbb\xc6l#gc\xce|\xf7\x88\x11Rl\x97\x1c\x03\xc6U\xf8\xa46\x84&\xd5\x01\xdd;\x0f\xf3\x17kz\xfd\x02\x83\x96\x9a9\xc4tr\x9ffOZi9\x942\x8e\x8d\xcf\x94)\xe9\xf0\xe1\xdc\xdc\xec\x89h\x00\xc6\x99\xc8\xce\xde\xf0\x8f-5)\xc8\xdbGH\x89=b\x8be\x13q\x0d\x08I\xfd\xe7\x88\xdcV7\x8b\xaa\xc8|\xf6N\xbbg\x84\xc3*\x02O\x18\x06R\xeav\x86\x89\xcdN\x19 \x08\xa7\xd2\x97\xb16}\x94\x92\xcc\x10P\xcb\xccw\xfa\x16\xbdEU\xb7\xc7#\xd0\xb7\xdajK\xb0\x16g\xe6\xa4@C\xa3\xf7\x10\x9e\xd3&\xed\x11^\xf1\x00\xd1/^\x05\xda0\xba\xe0\x13=\xbf\xd1>\xfb\x84\xf3\xb6\x12\xb5,'\x92\xda\xbd\x17\x0d\x9fu\xa1\xd54]f\x05G\xec\xc7\xdc\xeep8A\xe6\x85\xa8\xdb\x98\xf7*'\x03~\xec\x95\xd0\xfbTv\x16D\x13\xda\xbe\x04\x92/q;\xc6\xd7\x81\xd2*\x9b]\xa5u\xa1\x8f\x15\xb09f\x8f\xbe\x9d\xef7\xa1~?\xdc&\x88\x0b.\xa8bT\x91\xa3HBv\x0dR\x1b\x176\x84\x14\x81\xe3\xec\x11\xb79\x7f`\xddSg/\xf3DJ\x82\x15k\x0f\x1bk'1\xcf\x83&\xf3$X\xb1y\x04}\xc9\xb5f\xccb\xa3\xc9\xbb\xf7\xb1\xab\xb6\x98r\x1d\xf6\xe9\xf4\xf8\xb0\x17\x1a\x8c\xce\xbb\x7f\"~\xd0\x063\x8f\xb6\xc9\x1e\xd2\x80\xcd\x8e\x88\xc9n}yG\xe9\xb0\xd8K\xd7\x1b<\x9d>\xad\xea\xac\x11\x81\x02\xaf\xc31\xa6$8\x87\x10\xc5\x857\xf3\xe2\x01\xd8j\xe6O\x91\xdd\xbe\xba)$\x1d\xf8\x18\x1agft\x05~\x9ey\xfa\xb4\xa6\x8bu\x9fZ\x10Fm\xcaUa\xe2\x95\x04M\x9b\xbefw\x17\x9e\xd3\xea\xdd\xe0\x10]\xb5\x08\xd6\xa3\xd9\xadH1\xac \x02:\xc7\xe4Da\xe0\xd5\xd4\xcc\x91\x0e\xf0\xd3\xc9\xda\xd4\x97\x9b\x80Y+\x07\xd8;\xdb\x8az\xf5\x13k\xc2\xdd\xdd\x02s\xdbl\xb8\xcc\"\xe2\xae:\x14\xab\xff\x94\xe8X\xb9c\xde\xa2\xa1\xb6\xa8\xe3\x8d\xbet\x87r4\xaey\x86\x10\x16\xfd\xfe=\xff\xdeU\xf7\x9e\xf1ck\xe3O\xae\xafX\xa8\xdc\xf3cU\xee\xd8\xc5oVR\xe2/\xbd\x04\xca\x9a\xc3x.\x0b\xa7\xf2-\xa5\xed\xa2\xac\x13\x98\xad\xf1\xcc\xc3\xce\xb0\xbed\x0c\xcf\xdd$8\xf8\xf1\x06\x98C4\\dJ\xddCq\xe3\x99\xc7\xdb]u-mlZ\xb8?q\x04F\xf8\xf0\xdd\xbe\xd0]\xf5\xd4\x14$\xb7<\xfa\x927\xc52\xf3.\xa4\xf8\\+\xf1I\xe8q9\x147\xce\x7f\xcef?\xba\xce\xf1\xa5\xa5\x8dF\xf5\xaam\xfe\xa9!\x84\x05\xf9\x14Sp(~(W\xcf]u\x04J\xa0\xc2\xe81\xf9w\x95\x02-\xee\xb9\x0d\x1d\xe7S\x8e\xda\xd9\x85\\\\\xf7\xb4\x05\xb5\xd1Z\x1b\xa6\xe8BD\x0e\xc8\x19\xec\xd9\xec\xa4\x9e\xd0\xc4\x1fy\xfd\x9c8\x90\x07 \xe0\xa7^\x94$\xf2s\xfd\xec\x1d\n6\xe7.\xed\xec\xd7\xf8\x829\x00Z\x0f\x00m\x04y\x83\xf7\xc7\x9e\x82\xdc\xb1d\xd7\xcd\xbbOv:xtFz\x1cb#{\xcaTA\x80\xd9\x8b\x94^\x9f\x03\xbb\x8f\x04\xe1ynyhm\x1a\x1fY\x87\xc2\x89\xce\xd9\n\x82\xc0q\x9c\xcaO\x9c\xcc$\xcd\xe28C\xc7\xa8*\xe7T>\xa9\xbe\xe9\x08\xd4\xc4\x13zE\xaf\x02)\xde\x92$2\xd1\xc0E|\xd7\xc8\xa9\xc5\xc4CH\xd4\xbe{p\xbf\xcf\x92\xf60\x1aM\x18\xa9\xef\xb2\x9a\xd6\xef\xef,\x85\xa2F,\xc8ls\xa6\xe6i\xe1\xcb\xf1\x9f\x88c\xa8Ty\xbb\x10i\xadz\x9ca\xd5\xaf\xc3\xfc\x8b-^0\xa3\xe7\xe9\x17\x14\xe8\x8f\xae\xb7*P7\xd8\xcd\xe1E=[~\xfd\xdfo\x12:'\xc4\x95\xf9\x898\x11jG\xee\x0f\xcb\xd9\x19{kAQ\xf502\x1b\xfd\xe1P\x83\x19*\x10\x89\xf8\xe0\xa8W\xc8\x7f\xdf\xa6\x0dj\xbf\x0eV\x8f\x81\xbf\x18aa4K\xa5\xf9\xb6\xea/N4\xf0\xd9\x94\xfb\xc3b/\x0bG\x08\xbf\xd8[3\xf5\xc4\xdf\xef\x05R%#\x96\xd9o\x1c\xab\xfb\xc5\xbat\xdeZ6D\xc2_O\xbcz\x7f\x18\xd63\xb7\xef\x92\x7f\x83\xf8\x1f.\x9b\xb0\xf8\xf5\xe1q\xd7\xfb\xfb\x9e \xb0\xc0\xd3`\xc1\xcc\x0e\xfe~\x1f\x90s\xbc\xad\x99m\xc6?\xf9\x03\\\xe0\xac\xdeN\x7f\xf8d)\xcf\x10\xc4\x98\x7f\xf1\xad\xda\x94\xf2;\xe3\xfc\xca \x95\xaf\xec3%\xc2\x9f\xda\xef\\SuK?\xa0\xad\xfc[_\xda\xaf\x8f\xb5\xb3\xf7\xffd\x92\x00\xd9+\xdb?\xac<\x04\x83\xce\xf2\xf7M\xd3\xb4\xa9H\xa1\xfc\xe2\xe0\xb5)\xa5o#u\x86_\x9dl\xeb\x0e\x97F\xb7\xfe\xae\x89\x8f\x82v\xfc\xfb~I\xa2&\x96\xf8\x87U\x9f\x18\"\xe1\x01(\xf8\xc5Jy\xa1~\x92\xd6\xe4\x05\x7f\xdfJm\x1as\xb5\xff\xf0\xe5\xc1<\x8aL0C\x06mX\x92\x99\xb3\x1f=\xfa\xe6\xdcx\xa0O\xd1(\xb4\x04\xb0X$'$\xc6\x1c'\x0d\xb0\x87UV\x85\xc5\xb4\x85\xcd\xf9I \xd2\x1f\x10\xff\xae\xd9\xbe xG\x83}u\xbe4f\x831\x9a\xee\x08\x98\xb2\xf2}\xb0\xf1}3\xb45=$&?\x87\x042NA\x0bu\x0ee\xf1\x1d>F\xfb\xd0\xe7^_\x1cI\x161\x9b\xd3=\x96H\xc7^q\xe0}\xb0\xe8}L9Z\xd1\xf42\x03\xcf7\x01\xfb\xb8\xd4\x1co=\xbbW\x08\x0e\x14\xca\x13^\xa4\x10>uH\xf6\xe5\xb2O\xc2tG\xf4\x90[\x18\xd5R\xde\xf3(\xdav\x05%\xa5\xe4[\xca\xfdf\x00\xec\xdaR\xaa?7\x92,7\xb8]K+t\x96n\xbe\xe197\xdc\xd7\x0b\x83\xe8\xfb)g2\xb1b>r\x85BV\xbaK>!\x00\xa0,\x7f^3\x9a\\A\x82\xb6\xbe2\x8a\xe2\x11\xdd\x92 \x8d\xf1K\xb6\x8dh\xbb\xd4\xed\xe0DO\xc9HG\xc5\xb7$\xe8\xd15\xab\x1du\x1b\xc0\xfb\x81_\x8c\xf0\x0d\xcfhL\xb4\x19\xf4r$\xaa\x99\xe0\x01\xcd\xfa~\xcb\x87\xd4n\">\x85\x1a\xd3\x06c7\x1c\xaf\x0d\x10cU\xcc\xda\xa2u\xac\xf89CJ\xf4am.\xa1m\xaaD?\xd2\x86\x97S\x0c\x9a\x17z\xcf(\x7fcfe\xa5_,\x9d\xed\xe6K/OnU$\xf2j\xd5T5\xf2'/\x86\x1c\x92\xe5u\xd1;\x00\x10\xbf\xee\xd5mov\xdd\x0f\x10\xd9iK\x93Q\x9f\x9d\xdaS\xd8\xb7\xf9\x00:\x14?\xb2Y\xb3;H\x03\x84\x0dJ\xa1\x82\xe6b\xc3\xd34\xc4q\x00i\xc47\xad\xe3S}w\x10\x96I\xc5xm\xd2\xa7\x98P\xd4\"\xa5\xde\xa6%uR\x1e\x86Q\x18Vu\x051\xb8+ym)\xa9\xfdb1\x0f6\x8dU##\xc2\xc0\xa2W\x06\x0f ,\xbd\x8ar\xa77\xbc\xee:\x8a\xcc\x9d\x87\x8dY\xc2\x98IS\xaf\x04D#\xca\xee\xb4\x7f\x94\x05P\x98\x9d\x8e\xd9\\\xfc$7\xc8\xab$\xf2\xb5\x1a-\x8b\x94*r84\xc6\x07\xb3i\x01,\x03\xb5o}\xc2/\xc4\xda-\x0fcZ\x0d\xb6oe\x8a;\xa6p\xfc\xf2^\x03\xe5\x1eU3O\xe8\xa3~\xf6\xe3s\xb4\xde\xa7\x94B\xd0\xafr\x10\xbf\xca\xd9\x1e\xd8\x0c\xd4\xd0\xe6$\xc96,\x97\x08\xdfNB\x13\xa4\x89\xd8\xdb\x90i9D\xfe\xc0\x1d!G/ #H:2\x00\xe7bU\xa7Qww\xa2\xd4\xa5a\xc1Y\xb1\x14\x8d\xf9\xab\x17\xb9w\xed\xd8\xe5Hbv\xb2\x1f@T\x87\xbeH[\xfdw\xe6\xec\xae\xb71\xa3\x8f\x9bB\xb6\xdf\x89p\xce=\xc4\x18\xbe(=\xb4(\xc20b\xed\x03q\xc7*\xf8\xa0\x98\xfc\xdcf\xcdy4\xae\xe7\xdf'\xcc\xb0FK41Pd\x18\xc20 \x99\x1c\xfd\x90\x80 \x94\xcd\x94S\x8e\xcf\xc5A9(\xe2\xa0}|\xf4\x0d\x0e\xb8\x12(\xd2/&\xc6w\x1bV\xf4\x90`\x183\xba\xd1\x80\xa0\x89\x1f\x1bw\xd3]\xf7\xc3T\xb5\xd7j#\x0dBv\xc5\xe3;\xc6\xc67\x13V\x00W\xa7x\x8bz\x91\x9a\x17#\x89\x029\x89 \xe6\x06\xd8\xd0\x15\xa1 $?P\x0f\xc8!\x89\x8d\x15\xf5a\xeeHhC\x89\xa7O\xcd\xb2%q\x90-\xde\xf7\xf3qP*\xe4\x86\x8d\xb5\x85\xc5\xf3\xf38y|1\x03(\xcc\xf3\xcdv\x18\xc0\xc1 \x007\x8f~\x127\x99c\xb5\xee\x1f\xf8\xe4`\x99\xb0\xc4\x01$\xdb\xae\x0c\xe0\x16\xfaLsM\xdej\x88\x98\xbd\x97\x88\x05\xb3\x7f\x8f\xaeb\x89w_\xbfT\xde\xcf\xcb-\xdb\xc3\x94>\x08\xf9\x0d\xb4\xa5QB\xc7p\xce6\x7f\xdfbx\x10\x8e$\x9b\x17y\xa3KsHRA\xfb\x13 \xd5q-\xad\xc9\x8d\xe6\x10\xae\x99\x07\xb5\xb5=gy\n\x10\xa0\x9d\x98\xbe\xf6]q\nL\x199\xda\xd0v \x8d\xf0\x82\xc0\x15\x0fW5\x03W\xd5\\ \x96I\x85\xac\x8f\xb9\xf1\xfd1+\xea\xc5\xb0\x97\x8b\xc3\xf8g\xdd\x06\xcbmj'\x1e\xfaQ\xdd\xecq\xf9\x16WI\x849\xdc5\x1a\x1d\x00\x0f\xd6\x9c\x93\xcb\xe2aL\x88\x89I\x12AH\x1c \x04q*x\xa3iNOrW}L\xcd\x84I\xc3{\xba\x9a\x13\xc0x\x10hv\x17\x8f\x14_)\xcf,\x9a>>3o\xa2_\xc6}\x16\xe2g\x9d\x8f\xa5\xae\xee\x87\xfaP]\xff\x9c\\\xb7\xe2\xbb\xe2\x94G\xd5U\x80L \xc0\xcf\xd8\xeap|\xbf=\x0f0g\x1e\xf7\xed\x0c\xa5jXB\xe7\x8d)\xba?}\x10!~)\xb2\x88\x9f\xbc\xa6P\x04gU\xba\x8ck\x16\xaf\xcd\x86\xbb\xad\xdd\x02{&\xb5>_\x90|R\x00\x90L\x10/\xcfN2\xd7\x03\xe76\x83\xef\xb2\xa0\xa8\x8d\\Q,\xde\xd5\xb6}8Z\x15\x81\x13\x075@\x96,(\xe5$r\xd6\xf7\xf5\xa4\x02E\x93\xa4\xb7\xea\xeb$t\xac7h\xcd#%h\x94\xc77$\xb4\xfc\x1e<\xa4\xad@\xdb=tP\x0eO_{\x99\xbe\xa88\xa9\xa0Ax\xd2\x1d\xa4\xdc\x14\xabb%\x90\xd6\xf4\xef\x96\xfb\xecY\xad~\x14q\xaf\xc0\x8d\x8d\"\xe6\x86S\x1e\xf9\x13\xc8 \nXJ\xfd\x94\x89\xa4?W\xd6Y\xb3\x8f\x05`\xc5D =I\x15\x0e\x17\x92\x04\xc7\xfcu\x02\xd2\x14#\x19\x9f\x1c\x81e\xb1}E5\xe0C\xec\xf4\x1b\x88\x8f\xccS/{\x90\x18\xdb\x01\xa9\xe4E(JK5_})\xf3\x8b\xe2\x9f\xd1\xf4\xaf\x88i\xc2\x88\xff\x9e\x04gJxc\xdb\xfd\xaanzs\xb70\x0fA\"D\xd2\xc9\x9c5g\x97_\x18W\x8e\x0c\xa6\xf4q[\x8ca\x98\xc7\xf1\xc3\xcf\xb5q\x8dEj\xc3\x15\x05\xc878\x14\xa0\x9d}\x97\xcd\xd76\x9d\xb2\xbf\xf7Q\xc5rdi\x901%\xf4\x02Uf\x9cc\x19\x05\x90\xbd\xee\xbd9\xe3h;\xfb \xa7(\xf7\xc6\xc3\xc6-3\xc2\xe1\x13=\xc7\x90\x13J\x95\xda\x18\x9b\xa6\xdft\xc7i\xf6\xaaJi\xc7b\x14d\x97\x82R\x83m\xd3\xc5\x84f\xb6\x92\x96\xaa\xbc\x14\xba Db\xe3\xaeLC\xc6\x12\xa8\xfe\xca\xc4\x95\xed=\xc9\xf6=\xad\x91\xfc\x04y\xdd\xfar\xa2\xdd\xd6%\xad\xde\xc7\x01\xd9m\xca\xb1\x11\x96\xd1\xbb\xebb\x9f\x82'\x9f\xce\xdb6\\W<\xff@\x1c`\xfe\x99\xb5\xfcqG\xd3\x15d\x99yj\x8b\xfb\"\xfe$i{\x91\xeb\x8b\xf7\xbdO\xf2\x98]\x8b\xc4J\x9e\xc7<\xb3n\xb2\x1a?\x84j\xe5\xa3\x97\x9fA\x18-WO\xb0\xae+\xcc1\x19\x05\xb9\xfb8` \x10\xc5\x9f\xfd\\?\xd6\xce\x9c\xde0e\x93q\xda-\x01\xd2s\x14=\xd27\xbd\x1b\xd6\x00y\xdf\x02\x98\xf3B\xdci\x08\x93\xe2\x98,{H\x00\x93\xba#&#\x93Z\xa0,;\xbb\xb1z\xff.\x84\xa7Q7O\x17\xd6\x97\xd4\x13\xbb,nHD\xe2\xb3\xa1\xf2g\xd1\xa2\xa1\x00\xb6\x82\x9f\x08+XmyE\x1e\xbe\x06\xfc\x9e\"\xc5\xe3!4$\xda\xf7\x1d\xc1A.\xbcI\x11\x1a)\xb4\xabjn\xa4!\x91\xc8R?\x08m\xf7nB\x0b\x1c\xd9\xf8\x06;\xd8\x8dv\x18\"\xe3\xf99s@L\x1dZ\xf0\x02T\xa4\x8e\xf0A\x99\xea\x9b|\x8f\xc1/\xc6\x89#\xe4@I\xa8\xa3\x91\x0c\x10\xd3'\xee\xce\xfd\xa3\xc9\x9e\x00\x9dGs\x14\x9d\xfb\xabcJ\x7fS\xcf.b\x99Z\x06+\x91\xc5i\xb6x!\x0f\x95jY\xdeN\xa2\xa7oRd\x1f$V\xdd\xb2\x04\x1bG\xce\x84\xaf7\xd5\x14\x08NP\xebC\xbeS\x13\x0b\xee\x0dG\x16\xc5i\\l\xe1\x17\xf2\xd42\\\xa3o\xcds)\xa8\xf1}\x1b\x1bI&\x1fH!\xfa\xe0t\xe7\x9c1\xd2e\xee\xb5\xe5d\n\x11-\x89QMkw/A\xe5\xac\xb1u\\\x91\xca\xdd\x12\x1f\x1a\xc1>\xf8:\xdc\xdb\xcc\xe0W7j~ \x00\xc1J V\x9c\xbb\xe1\x962\xce\x05L\x12RK\xf8b\x08\xdb>\x80\x1b\xfa\xd85 \xc3_\x1cz\xbb5\xbf\xf6|\xb0Q\x10s\x17\xdd+\xa8\xb3v8\x00\xd8\xebTJ\x8c\xed\xbb\xa9\xe4A\xcf\xafZ\x97\xa2\x17L\xf6\x81\n\x04\xc6\xd9 \xd4y\x06o\xd8k\x8cE)\nV\x0e\x99\xab\x8a\x8e\x85w\xed}sX\xf0\xcb\x13\x11\xcc\xda\xb2\xef9\x1d\xb6\x0e\x90[R\xee\xf8\xc9\x07\x94\xe1\xda\x02 \xaf9\xca\x05\xb8\xb3\xb8\xfaU^[0\xdb\xa9\x8a\xd6\xcc \xd4=~\xcf\xb0T\xf4\xb2V\x11\x01\xcb\x12kL]\xbf\x0b\xe1\x12dM\xe6\x98\xde\xd8O\xbe\x84A\xa8\xfaG\xad\xb7\xa4DP\x17\xf8\x86\xdf\x97\x06y\x1e\xc4o\xa6jJ\xf8$\xb4\xf8K\xca$\xc4\x86~\x9a\x03\x00O\xceTg\xdd\xa9\x00\xe97\xea7 \xecz\xa7\xda\xb3\xf7\xf6]\xc3\xf1JE\xb5z?\x04\x93\x93\x19\x9d\xd1\xd4\xe9}j\x0e\xfe\x82\x1d\xde\x8bcy\x19\xb7bO\x86Gt$\x9e\xa5\xf5\xb3P\xc0\x84\xc0\xdbr+\xa9\xb4\xb8\xfe\x0d\xb4f\x84L\x859<\xb2m\x8c\xba\xb4\xd5\xc0\xbd=\xc6w?Fo[\x17\x0dO\x07 D0\x00Qg=\x84\x14\x10D\xdd\\\x9a\xa2\xf7B\x11\xed\x95\xf1S\x8b\x1e\x88\xc0p\x93\xbe\xc3\xe2lRDTr)\x11\xbew\x93\xc6\xb3\x0b\x8dk\x91\xe7\xc4\x96\xbcP\xdf\x9e\x08\xbe\xd03\x92\xa4\xbbB\xc0\x1a\xc0\xe5.|h\x16\x12\x92R\xf9b\xe6\xb0m\xbaEP\xec@@\xa2\x9c\x1bfUNja\xcf\x9f\xedlS\xc6\xb7l\x16\xbb\xa6\x8f0\xaeA\x9e\xdb,\x9c\x88D4\xac\xed\x91\xb5\x16\x0d\n-AY\xd0\xb6\xe5fv56\x88O\x06\x15\xf8g\xac\xb5/\xc2\x87~1Q\xa0\xfbBx[j\x1c\x13`b\xe0\xf5\xb8\x15rH\x04\x91\xb07W\x8d\x9f\x9f:~\x05\xdf\xf8\x8b\xdd\x01\x9cE\xf33\x07\xdf\x05\xf6\xbeq\xad\xf8)U\xef\xd9\xdc(\xfe\xbd\x19\x06I\xf7\x92Kj\xe5T\x96w\xd7j\xdb\xb2Fz\x12\xf8\xf6\x15\xe0\xc3\xa8^h\xed\x0c\xa9\xd3\xd8+^\x02ym\x12\x96-\xd6\xb1\xa7\x06%RT\xf3'\x04\xc9j\xaf&@\xa6d\xc8Y\xc3p\xae?\xdb\xf7\xa1%@\x1d\x0e\x18\"\xe7\xba\xb9{W\xdb\x8a%\xfa.\xd2\xf7\xfeL\xe3j\xee\xf9\xa89\x86\x7f\x80\xe0\x83D\xbdpO\xd6r\xd4\x0cr\x85n0\x06\xe8\xc5!\xb9ZN\x94\x12,\xc8K/!e\x98\xbd-{$\x97&\xf4 !\xd9\x9a\xa2\xa5\xd9\\\x97\xc5\x99\xda\xe6\x10\x97\xdd\x94S\xfcy\xc7U\\s\x96\xe4hA_\x8f\x90Q\x81\"W\xc8\x81\x8fUb\x8aq\xb57.\xde\xc9^?r-`\xf9\xca\xc5\xd7U\xabn6\xac\xee\xd4\xcc\xa2~?5\x07\xce`I:\xabUPa\x98\\=\xabyy\x04\xb3-\xb6\xb72Im\xa0|y\x80\xd4\xc9P\xb0\xc2\x05D\x9b\x88C\xea\xcf:\x81\x0dS]\xdc\xa9m\x11i\x81~rL.0\x12\x13\xfd0\x02 \x98t\x9f\x89o\x18\xaa \x9cc\x9a\x0b6\x98x\x06\xa1\xb6\xa9Q\xa1\xac\x15\x82\xe4\xc6\x82\x0e\xb1\x94\xc6\x9cY\x16\x9cB\xa16\xc3R\xf57\xc7t%\x0d\x82\x156\xf9\xce\xf9\xa6\x8em\xce\x1f\xa66$U\xbc\xac)\xba\xb3&E\xbd\xed\x88\x93\xad\xd5n\x99\x136\xb8\x04\xd7\xb8\x8e\x88\xe7u2\xd4\x8cO\xe6\x96\x02\x7f\xb6\x15<\xc7\x1a\x08\xc9\x15\xf9X\xe0`\xbav\x00\x81\x9f\x15\x92\xddxvP\x14e\xbbq5\xd7\x94M\xa8\xf0Y\xc3\x9e\xd5\xc2\xcc{\x01>B\xb1p\x0d]\xd4\xfb\x00\x0cEb\xf5Z\xed\xeb\xae\x81\xfc4T\xfdh\xed\xd7\xbb\x82\xdc\xcdd\x95O\xf75\xfe,nZ\x00\xe6|\xefb\xce\x94\xd5\x16\x88\x88\xb68\xfb\xa7i\xb5\xb3\x8cP&& \xda\xeb\xc6uma\xb5\xfbl\x88(\x04\x05R\xf3\xa4ru\xa3\xba6)\xe3N\xb5\x16\xe2.@\x1a\xa0 !O\xeaAy\x17S\xb7!|\xf1h\x1b\xf0\x8a\xf3\x02?.\xac\x9a\xe2%:\x154?\x16)\x9e+\xe8+\x9ed}x \x95:G\x1e\xec\x1b\x14/\xd5`\x89{\xde\xb1\x99\n\xb1\xb7G~\x8d\xdceg\x94\xc6\xc5l\xef\xd6\xec2_\xfd(\xba\xa9\x86[\x01\x13\xb7G\xdb\xf2\x8c\x9f\x96a\x85\\\xf1\xf3\x8dg\xb7\x1bV\x8ab\xcb\xc2\xaa\xc3 [\xb3}\xa5\xd4\xf7S\x8a\x9a7~\xdf\x1a\xe4\xf0;t\xb7q\xb7lJ\x1e\xa1v\xab\x87J>hO\x0fa\x8dB\xc5\x12\xf9\x08fJqO\xb0\"p\xec\xce\x85\x01 nUD*h\xb6X\"a\x95R\xbb\xef\xbc\x1a5L\x17\xd2d\xa6\xcd\x10\xa3\xe1\x05N\xdf\xef\xb6\xcf\xe7E\xb1\x8d\x06\x97\xf9K\xfa`z\xb8\x0c`\x0b\xab\xb3-H\xc8\xa3\x07s\xbe\xf9\x85\xd5w\x0b\x1fW\x17\xf2aQ\xc1\x04\xc2\x8b\\\xd6q\xb7\xbd\x1f4\xbeWU\x1d\xa8Tn[[\xc9\xd1\x1f\xd4\xb1c\x88\xc0\xac\xb9\xec^\xc1\x83\x12\xd5\xdb\xc3\x0f\x95\xd0\x85\xba\xf9*P\x04\xf6\xf6\x05\x14\xd9|\xa3\xdb\xa9\x86\xae\xda\xef\x06:\x01a\x8b5\x92\x92\xc8\x83\x1e\xaa,\xd3'\x14\xdf\xca\xd8\xfb\x86\xbd\x12:\xdf\\\x9f\x82\xb4\xa5\x8b\xd4\xf3\xdc\xbd>\x98\xbc\xef\xdf\xae\x0d\xf3\xbe\xacp6\xdf\xa6\xfd\xbb\xa7SB\x9ab\x9d\xea\x10\xa0\n\xa7~U\x81i\x9a\xb5\xbf\xc3\x99@\x8a\x87..\x8f7F{\xdf\xf1R!\x9b~\x14\x7f\x93\xc6\xc3\xfd\xe0\x0d.\x18\xbc\xb1=F\x9f2\x9e?\xd6\xf8N7\xae\xac\xed7O=\xaf\xbc\xc0j\"U\x99\xd3\x9cL\x8a# \x92\n%\x0c\xce\xdc<$\xd8 \xac\xc0\xd43\xb1 \xb5[[\xd7\xef\xee_zv\xad\x13\xe8\xbby5~\xc4\x91B\x95ry\x83uS-\xe9\xfd\xa8\xa4\x968\xd6`-\xd1\xbeO\xa8\xf5\xbd\xce\xde\x0dG\x1c\xcb\xd4b[\xb7\x9b\xbe\xc6\xe2\xfa9\x8f\xf9:a\x06@\xf4)\x08,\xed\x88\xe5\x93\xd7M\xbc\xa3\xaaY\xd3\xb9\xbc\x0f\xa70\xa4\xae\xf5\x94\xa7\x87\x85\xa2\xd0+(\x07\xe3\x0f\xb19@Z0\xc8\xb1\x9bm\x9b\\-\x13\xa1\xac4\xae\xb6\xb4b\xbb\xee\x12H\xce9B8\x9bcp=\x90\x9d\xe6\x07\xec\xf1\xd1G\x85\x8e\xca\x92\xcd\xcf\xa7m\xf9\"\x12\x12\xb8\xa0\x00\xf3\x90\xd7uTu)\xfd\xf1e\xba\xa3\xbd\x8d\xc4\xab\xd4s>\x13\xe8\xde9\xbb\xe4\xab\xb3\xf5\xb8\xe4\xee,\xab\xcd\xb1^~\xad\xf7\xebh\x1b6\x87\x99\x145m\x98\xd1\x05!\x89\x99\x1c\xc7%P\xa6\xb8\x88\xecI\x98\xdf\x1fqT\x95i\x1d\xb6]\xbf\xf2\x92\xd7\x8e\xca\xa9\xb6\xa4\xf4\xeeV\xef\x8b%\xb8\xe3\xf3\xd4-\xa6\x0d9\xf8\x8c\x89\xf0\xa2\xb6\x95\xa2\x93\xab\xfd\xce\xb1\x9b\x8d\xefe\x85\x1e\xa2\xffT\xf3\x83?\xc0\x98\x16\xbd\xf7\x1b\xdb ,z\xdb\xbd5x\x1co\x8d\xa4+\xee\xd6\xae\xc7Me\x8dZ\x16\xa5)\xfa\xb3\x7ft;\xe8\x8f\xa1\x0e~2\xac\x0e\xb3@\x1b\xc9\xfas\xed\xc5-J\xf0W\x87\\s#\xd4`\xd4\xd6>\x1f\x07X\x85\xa5\xd0=\xd0\xf8\xa3\x8c\xab1\xa5\x05\x82\xe2)\xd4G{\xe2\xe3'\x14\xacY\xac^@\x18\x05\x9a\x89\x9f!\xec\xa3\x13\xf3\x86\x08\xc8<\xabZ\x8aZ6\x07\xf2\xb9\x7f\xec\x0f^s,\xcdl\x8b\xdc\xeb2\xb6uv\xbd(\xb6,\xed\xc7\xd0\xbc\xd8%\xe6n\x17)\x91\xa6\xa1l\xcd|\xb5\x02\xd9\xcb|?\xdeZ\xa1\x01\xf0\x93\xfb\x84Y[06\x17\xb4\xfe\xcd;\x10'\x15d\xcbi\xd2<\xa4\xd0\x1f\xb9]\x9a\x86\xea\x8b=\xd2\x90\xef\xfb\xd9u\xad;\xf3\xd7U,i\x81\xfa\xc1;\xec\xa8\xb52\xfb\xe6\xc1ZD\xd6\x8d\xde\xe3T\xcbJ\x85\x1a\xc6\xd2>RJ\xcf\x11Z`\xf2!\xdf\xa0\xbc\xcd\x87\xd2&\xc9e%\xbc\xd1\xb0\x85\xe7\xe3\x92\xe9\xc7\xfb\xcc\xb8<27*\x8f\xf0\xa8\xb2Z\x96VW\xfa\x8a\x01\xe97N\xa0\x01\x01\x1dv\xbe\x95c2\xcc\xd8\xbc\x03D\x94Vj [\xe1\xfa\x82;P\xe9\xdcW\xa3cf\x10,\xdb\x1c\x0b\xe1\xb3D=\xa8\xae\x81\x14\x05\xe5\xaa\x9d\x00\xb9/@\x85g\x9bo\xef$*\xc2\xc1\x1d\xda\x92\x84\xb2\xdf\x94\xbc\xc8M^\xfb\x8a\xed/\xfd\x12M\xc1\x9b\x9e\xf7\xbb\x11\x90\xb3\xb7`fS\xd3\xe8\xa8\x0c\x08?7f\xb6t\xd0\xd7\xec\xdc?{\xd7\x87\x11h^\x85\xcf\x1d\xe8:\x92 \x98\x1ca:\x17u\x8c\x04\x03\xbf\xcb\xce\xd6h\xc8\x16\xef}\x98(8\x06\xbe\x19\xac\x8c\xba\xfe\xf8lSu\xa3\xe9\xe0iy\xab\x87\xf4\x05\xe53\xce\xf1\x06}?b\xa4+\xb8w\x87RD\x13Z\x96\x96\x12\x1d\x04\xca\xd6\x87\x02\x89^\x98\xb6x\xf4\x19\x168\xcf/i8\xe8\x8a\xe1\xb4H\xb0q\xed\x18\xa9\xc8'\xf7\x8e\x83z\xddQ\x8cx19\xa8>\x94\"\xc3\x1a\xd5\xa7\x1d9\x18\x8b\xd8\xc4\xf5f=#\xa8\xe3\x13\xfaf\x80\xe51\xba\xea8\xee\x1fqh \x9fW\x95\xbe\xdb$r\x06\xe6\x8c\x97Y\xcc~\xf1\xf2\xaa\xdb9\x9b:F:7\x03\xe4\xcdij\xce\x8e\x01\xeey\xd7d\xba\\\x90\x98\xfa:FC\xf8\x8e\xab\xb2$t~\xc3d\xb3\xb7\xb5\xc1\xcdG\xe8\xa4\x0c\x88<\xc5Z\xd0\x03\xe1WZ\xbc\xa2_\xa8\xd0\xc0\xfb\x0es)\xc4\xc04nQ\x82\x9f\xa8;\x9a\xb9\xb6\x9dL\xa06{xQ\x06\xdc;\x1f\xc5\xea\xc1\x14\x0f\xdc\x85\xba\x8a\xb9\x14\xc5\xeav[B\x15Jp>Fe\x83N\x07\x8c\xf4\x85\xf6\xd0:Z\x96O\x12f\xe6\xc8\xff\xe8\x07eL\xfcC\xdf\"9%\n\x93~\xa9p\n0A\xe4\xdd\xae#\x04\xb7\x06ih\xb3\xf4epQ\xc0\xc5\x9d!M@\\\x81\xd7\xb1\xc4/\x99\xc0\xb2\xed\xc3\x13\xae\x9al\x0f&1\xdd\x98C\xbc \xbd9\xb6-\x89\x10\x01,\x9d\xdd\xefB\x80\x83\xb2\xbc\x07\x03G\xf6g\x1bWA?\xcb]\xbdwA\xc5\xe0O\x07\xaax\xa4\xb5!+F0\xf9\x90B\xef\xd6$\x00\xdd\x06\xf7\xe5\x94\x9b\xba\x88\xf8\x94\x95k\xc1\xe5<'\xc3\xe7\xdd\xc65\xc1`\x08\x17\xa3\xef\xd9]Z\xc4!h\xc5\xd8p\xbe\xac~\x10GW\xe4&\xf3\xa5\xd5\xfa=\xee\xe9W\xd74o\xa2=\x0f\xaag\xe6a;P\xc7ciZ\x14\xd4\xc09\xa9%\xd51\x83\xc2G\xd0\x94W\xb3u\xb20\x85\x1c\x19\x1a&\x03\xe1j\xb7\x95\x8c\xb1\xc8\x1c\x8d\xc0\xebl\x99IslL\xd6\x84m1:\xf6\xd2\xbd~ir\xe1\xc7~\xd8!\xb0\xda\x1dZ\x97^\x7f\xca\xac\xf1&\xfcD/X2\xcd\xc0P\n\xa9~\x00Xo\xe1\x0d:\xf2m\xaaN\xcd\xea\xd8\xc6\x14\xc1\x1c\xf5\xfa>\xbd\x1b \xe9\xf3\xa9\xaf\xea\x1c\x93C&\xddC\xc8Q7\x9f\xed\xcb}[\x04\x03S\xeb\x91\xdaP%\x0f\x83@\xdf\xfa\x04\xde\x7fu\xcf\xe2N\xe4\xfapt\xfe\x9c\xf9W\x05\x0d\xeb\x80\x80\xa9\xac}\x87\xf0\xe9/\x0e\xc5\x0b\xedDGzTY\"\x01-I3\x85\xb2vb\xfa.\xf5^\xacc\xfa\xd2:0\xa47w\x87\xac\x10Q\xbbR\xef6Ao\xc7\x90\x88gd4\xb6n3D&\x08\xc2\xa0\xda\xdf\xeen\xe9[\xbb:Z\x05\x90e3+\xd0d\xf0~\xbf\x97\xe2\xf80\x93\x96]\xac\x1a\x99\xe3\xaa)\xb8$\xa6\xa9\xc8\x87~H\xfb\xb5\xf6\x85+\xc1/EwI[\x02H\x165\x8ekLaeF\x9b\x02y\xc0\xbd\xcf\xf9\x01\x8ctiY\xbd\x81\xb9\xa1\x15\x8b\x9e;\x8e\xae\xdf\xc8\x9dA\xdd\xd0\x89\xdc\xfc\x91\x81>\x15\xb9lD\xbb\xb9\xb1=Ez^\x0c\x99\x05\x13\x8b\xd8\xcc\xe9x\x07\x16\xb9l@\xcf;\x15\xbb\nKP6\x19W\x13K4\x8b\x83 \xebm\xde\xc9\xc5\x0caU\xec\xb2r\x9d\xb6\x07\x94Ps\xcf\xb9E#\x06\x9d}W\n?%{\xf9\xe4\xe4M\x87\xf0\xe9\x82e\x99\xcd\xf7+\x08\xab\xd7>_\xc8\xc5\xe6\x8bR\x87p\x0c\x02\x17\xff0\x11\x9d\x13\xfe\xaa\xac\xca\x02\xb1 \xa7Z\x01^\xf0\x9e\xc6\x15\xe1\xa7\n_\xad9\xeb\x0e\xd6o \xc7\xfd\xc9y\x08\x8f\xc0\xdf(]\xd1\xcb\x97\xc4\xd6g\xd1\xe0\xe39\xdd\xa5\x97\x89\x1f\x96Bn\xbc\xd0\xd4\x15{\x98\xfbyl\x1a\xcb\xac\xa4!V\xe4\xf9+\x15\xb2\x13\x0c^\x11?\x97\x87\x84m\x01|\xbd\x9f\x9c\xaf\xdd|\xe44\x7f\x1co1)\xc1\x95,EO\xeb?\n\x0c\xfd\xbd\x1f\x9e\xa9\xb6\xedg|\xe3{mqU\xaf\xa5\x9f\x10\xf7\xfc\x7f\xdcK\x17\xd9\xb9c\xccUp\xbf\x1d\x0e>\x11\x16\xf3\xef\xfd\xf6\xb3\xc0\xfb\xe5\xb5\xc6\xcer\x16\xf0\x00\xe1\xdf\xf3\xe1\x84\x9a\xf0\xa9,\xf1\xf3\xe9\x9a\xe6\xf7\xfd\xb1\xb7\x17\x97~\xe7.\xb3\xb7\xb5\xc4\x9a\xe2\xbfw\xd7b\x94\xd0Q\\\xe4#*\xc4\xbf\xf1\\\x16\xaa\xfc\x97\x7f\xcfU\xb5\xc37\xab\xff~;\x9f3\x08\xfep\x00\xa1\xda~\xf4O%vn\xcde\x84\xc6\xfc\xbd?\x1f\x1e\x18\xcaX9@ \xc1\xc4\x94\\9\xb9_\xee\xac\xcd\xab$\x82\xcb\x0d\xcc\xc7\x99:\\G\xf5\x97\xb3z\xa0\xcb\xf7\xc4\xbe\x88S\xef\x87*\x16Z\xf1B\xc9?\xdd\xbcW\xdfhv\xfaJ\xbf\xdc\x95\xab\xbc%\x85X\xe5>'Qr\xdbi\xce\xbfu\xcf\xed\xd0\x11\xc8\xe2\x1b\x97\xc8M\x9a{0\x12a\x17\xff\xfd\xb6\x0f_/\x1c\xea\xd6\xc5\xefg \x1e\x13\x90V\xef\x0f\x8b\xe0\xc1\xd0\x17\x03\x02\xe9\x11\xfe\xf8A<9x\xb7\x05B\x10A\xc4t^(\xda\x7f\xef\xda\xe3\xde\xe7Y\x06\x98\xd0 vZ_\xd4#F.e+\xf8'\x83F\xcaU{-g\xb3\xd2\x90\x93\x8d\xd8\xc5D\xf5O~YBA\x80\xa8\xc8*\x1a\xba\xbe,\x7f\x9cl[uhZ\xe2\xc6\x0c\x08\xbb\xde?\x1c\x84\xe9\x98F\xb1\x94\xee$(\xad/j\x08c\x96\xe2\xff\xe4\xbf\xc5*\xbd\x86\xb3\x1f_\"\xd0\x92\x82\x9e\x96\x7f\xfb:\x87\xad\x93\xbc\xb0\xa3/\xee\xfd\xcb\xdb\xec\x85\xeaG_ .\xc1\xc7M\xc9\x9f2b\xa3\x7fs\x19\xca\xf3C$r\x12K\xc6\x16r\x93\xe6\x18\xd8\xfb\xb6\xff\xec\xa9\xed:w4\x98\xcdL\x83M4\xac\xf2\xff\xf1\x19\x15lfz\xb3\x95Z\xe2[\xff\xe9\x88\x84!\x8f\xf5T__\\\xf5\x9d\xd4&^\xff\xd9\xa5\x1c\x1b7\xe7\x8e\x81\xebi\xbe>8xGh\xff\xd9e\x7f\x8d\n\x0b\x9c\x81\xe2? 7t\xbfvc]9\xa7\xf1L5{\x9b`>\xec\xbe\xac\x7f\xfa\xc9\x8a=\xfaY\xffR\xcb\x8b\xc7\x89F\xb0\xe4\xcb\xc3X\xe6\x9f\xfd\xf8\x8e\x1a\x0f\xf2Dj\x96h\xa8\x85Kg\xcf\x1f\xd6C\x97`]\xbe\xe0b\xe4*f\x84\xd6-\xfc\xe52\x19\x86<\x84\xca(\x95\xbdK\xf0\xca\x0d/\xb3\xd7\xfb\xe39\x95\xcbA\xae\xee\xfej\x13\xa5\xf8\xc0\xa9V\xe4\xbf{]j\x8b5\xef\x9cC\x14zR\xd4\xd11l\xbf\xcf\x97\xa7\xec~\xcf\xd8u\xd6n\xf82F\xe4\xf6\x7f\\\x87\xd0\xe2\x9c_\xfe\x8c?\xa8\x07 V:\xb9\x8d\xf8\xef\xef.|K'\xbc|\xb8Jz\xfe8\xe5<\xd7\x8e\xa8\x8c\x0c\xfb@\xc1$\xb4\x19\xb8_\xec\xc7\xe4}.\xc8\xc5\xb4d1E\xa1\xd41W}\x9b\xfe\xa1\xdef\xfe\xfb\x9f\x0d\xa5p-\xc0\xbc\x06\n\\\xe8n\xf5\xc9\xf2\x91\xbf\xb5\x05\x8b \xaf\x91 o\x1f\xb6b\xdf\xd7\xfe\xcf\x17 fo\x90\xae\x81\xa4\x10Z\x7fjr\x81\x85\x00C\x94~\xc7\x92\xa6uBw\x81\xd8\xbbK4\xcc*\xd4\x02\xf8\x8f\x0bn\xed\xae2;/\xf1P\xf6&\x0f\xff\xd9\xa5\x06E\xc8\xad^c\x18\x97\x1a\xe4\xa8\xdd\xc0_\xd2'\xf8\xdb\x87\xbc\x03\xf5O^\x06q\xccM\xd2\x95\xc1V\xfe\xc7=\xea0q\x05\x07\xa8G\x08\xe3\xa3\xfeoO\xddr\x7f\xf95\x84Q\x0f\x16\xb8dE@\xce\xa9\xfe\xd6\x9f\xf5\xb6\xfd\xfag\x00\xc7\x1e\"\\\xbe\xd57\xbfX\x1fw\x15\x91Rh\xb2ys=\x89\x92\xe6\x06\xfaO_\xf5\x05\xbbGG\x86a\x08\xdd\xad=\xb0]\x0f\x98\xff\xf9z\xfc\xbd\xc8\x08\xab]\x92\xe8\xf7\xd6\xe7c:\x1b\xad_=f\x9b\x0b\x11&\xa3\x82\xde\xf03\xbe\xf9\xd8\xdf\x05\xff\xe3\x07\xec\xea\xfb\xcb\x9f!l{\x93p\xa5\x18\x14\x0b\x7fX\x9d\x8b\x1d\xddE4\x89\xc8ig\x863\xff\xe9D\xc9r\xc5\xa9\xfc-\x87w\xf6\xa7f\xa0\x98\x83\xb0\x8a\x7f\xd8\x9b\x84\xe4\xdc1q\xfdmT\xb1\xf09\xbf\xe0\x7f\xf6\x90\xed\xac\xbf@\xccL\xe7\x8b37\xf0\xd1\x07\xffx+\x056'j\xc9\xad}\xe8\xfc\xd9\xabhb\xa7\xbf\x98\xa8\x0d\xec\xbe4$\xf4\xd0\x10mr\x93u\xaf\xa2\xd6\xfc\xce7,\\{\x19\xb8Y\x0c\x05E\x7f\xa2\xc8\xed\xfe\x8d\xb1\xb2\xe3#\x85\xeef\x9aD\xdct\xfe\xc8I\x85\xfa\x0f\xa3$\xa7H\xb62\x1fi \xc8\xbd\xf2d\x03t{[U\x9d_y\xaf\xbe\xbb=a:\xe6\xc7\xf5~r \x80\x89\xfb\x7f\xba1\x0b\x16\xf6\x8a\x9d\xd2L\xc7l]\x9fC\x19\xe0\xdf\xfe8\xf0\xa3\x00\x9d\xa3\x17A\xe4\xf46[\xa6\xf2\x7f\x18\xa7\xc3A\x99/\xa1:\xe7\xc7\x8d~\xe4\xd9\x17\x0fi\\\xf9;G3N t\x814#\x82\x87U\xef\xe8\xb8\\\xce\xb8\xe1\x8d.\xfe\xd3G(\x1a\xb7/\xeaW7\xe8ep6\x86\x12\xf0\x80\x0d~\x89\xff\xfd\xbfK\x0fQ\xe0p\x1d!\\\xae\xd55\xffxC!et\x17\xf3\xd6\x02\xa6\xb7\x990\x15\xff\x9b\xcb\x10\x06\xc1\xa5\xceF\xb9\x04\xf1\xd3y\xe3\x8c)V\xf2O'\xcd\xfaz%!rb\x88)\xc5[\xb0\x0f,\xfe\xf2\x83\x0f\xd3\xdaL\xda\xc3\x8b\x19\x16\xb4>\xabFN[\x88\xd7\x9f\x0foNr\xf9\xb0\x12\xb9\x9aXiB\xd0~\xd9\x18p\xb4\xbf\xdf\xe8\x80]\xaf\x01H\xb9K\xb8\x1b\xa5\xea\x87?;\x0e\x11\xc3f\xdaPZBh\xf5\x7f\xf4\xe1x\x9cZ\xfc\xc3\xa7%\xc1\\\xd5\xc1\xd6\x9aJ\xf0\x94\xaf\xf6\xa8\xce\x1f\x06,h\xc2s |\xdd\xcd\xc4\xe1o\x0e\xb1\xd1\xc9\xfft$\x08_\xea\xc0}i\x0b\xb6V\x9fA&\xc8\xba\xff\xc6\xfa\xda\xd5o\x8b\xb7\xfd\xa7\x9e\x8d-v#\xb4?\xdb\x14c%\x0cfm\x0e\xa5\x05\x86\xd6\x98\x93\xed\xbao\xfe\xc9\xe3dPx\"-0S\xae\xe2\xa4\x00#X\xe9\x0f\xe7\xb6ul\xea/\x9b2\x94c\xae8j7\xf3\x8f\\u\xf3\xbf\xb1\x1e\xb9~\xfb\xd1 V\xef\x15\xae\xd0b\xca\xf0\x9f\xfe\x86\xf5\x90\xc3 \xd5\xb2\xd8\x13\x0feH]\xfby\xfe\x8bg^\x8c<\xe47\x1fEu\xe5\x10\xf9\xba(\xb6\xf9\xab=\xb4Ah\x97\x81\xf8zcX\xad?\xf9\xe9`\x0f\xc2\xf8OO\xceT\x8fs\x96A\xde\x08)\x05U\xf7N\x1d\xfd\xce}\x9b\"a7\xae \xba\x06=I\xd5\xd7DY\xf7?\xff\x11\xf6\x01\xf3\xf2a\xe7\xda\xe8\xa4\x7f\xf4\x0b?\xc1\xaf\x9e6\xc7\xdb\x02O\xde\x82\x9f\\\xae]\x0c\xf1_\xad\xa0\x06L_\x0cZYJ\x8b\xaf-.'\x1ba\xa6\xb6\xbf\x1c\xbf\xd1F\xbd\xb3\x17km\xcc\xc5\x0f\xfb]p\xe6_\xbd\xd4\\\x85$\x04\xdark\xa2\xa1\x0ci\xe2hf\xf4\x17S\xfb\xd8-\xd0\x11\xda\x16_\x13\x0d\xb9\x98F\xfb(\xf8?\xde_3\xcc\xc7/\xcc\x9d\xe1\x8f=0\x15\xbf\x07\x01\xf1\xd7/\xe0\xe6\xcc[7\x03w\x0b\xb4\xa4\"\xe4\xc7T\x96\x7f\xf9\x9b>Y\x07\xb3\x9c-\xf4\xab@KR\xed\x15\xeb6\xfbo\xadr\xbf\\\xe4\xbb\x85\xc1>r\x93lE\x7f|\xcbj\xaak\x16w`\xa6\x03\x05Z\xb2J\x0f\xdb/\xf4\xfb/N\xf3\x1f\xa0(\xd7X\xd9\"\xa7\xfd\xaa\x8fd\xff\x17G\x88M\x95\x1b\xcf\xd1\xc60\x1c9D\xae\x1e\x1e\x89\xff\xe4i\xee=\x92!\x12HK\xc1\xc9\x86\xea\xb7\xba\xf6W\xeb%/&\xf8\xcb\xc8\xa5a\xbe8\xf8\x80\x16\xc53\xfc\xd6ob&{?\xe3S\x81\x96X\x98tL\xc1\xf5[C\xec\xc4\xdb`\xbb\xc5\x07\x8a\xc9M|\xfd\xeeP\x82\xff\xdaG\xb55\xfb\xeb\xacu(-$\xb7\xd6\x9cl\xcc\xccr\xfdg\xdf\xf0\xc3i/?\xaa\xf8\xe9u\x82\x17, e\x0f\xcb?\xfe\xd0\x922\"\xaa\x81\xddJw\xd6\x96\xec\xdd&o\xff8GWnJX\xfc\xc9\xdal\xeb\xcc%x\xaa@\xfd\xd5\xff\xb4miUT\xcb\x1c.\xd1s\xbe\xa0\x83\xc2\x08\x88\x0b\xbf\xf6\xd9 \xd2\x9b\x8f\xa2~z\x88\xe0\x15+\xfd\x08\xa3\xeb\xff\xab\x7f\nQ\xaf\xf4h[\xcf%x\xe8'l\x9c\xcf\xbf\xf5\x8b\xe2\xa1XGp\x86\xf1\xfeS\xbf\xec\x0cg\xf5\xe8\x9f\xef\x9b\xca\xdeS\x10\xeeFdp\xb5\xfax3\xc1\x7f\xba\xf6OL\x92^}\x14\xf7\xd3\x03~t]\x9b%\xd4\xfd\xf2\x1f\x87z,\xab&\xda\x85\xdb^{\xb21;d!+\x7f1\x90\xcb\x1bqb%\x91\xd8\x18\xf7g]\x92#\x00MR\x87\xbf>\xe4\xee9gs\xea-\xd8A\xdc\x19\x9a\x12p^\xd1\x98O\xf9\xaf~\x84\xa5vV\xdf9v \x89\xc8\x84\xa7\xfd\xcaPF\xec\x94\x15\xa9\xbfu\xd9@\xab\"\xdc\x1e\xe9\x12\xfe\xd4\x84m\xc2\xc7\xd8\xf9g\xcb\xed\xbef\xe88g\xcaG\x12\x0b\xbd\x801A\xfe\xf3Omp\xe7\xc9\xd9\xb9\x03\xf2.\xe3?\x9f\x19\xfe|F\x0f4\x154.H\xeb}\xf6H\xb3\xc2\x9f\xac\xfc\\N\xb0\x11)\xb5\xfc\xd3i\xa8\x9f\xa0\xb7\xaaUd\x04\xdbfK_\x97\xbf0\xd6d\xea\x7fz\x0d\xa5\x9b~xZ\\\xf2u\xcb\x7f\xe6ta\xf2\xdd\xc6\x7fve\xc6\xd9 \xaa\x17$t\xa6\x9exx\x82\x89\xf2_-\xa4\x0d\xd1\xbc ;k@\xe1f\xe8?s\xea\x92\xac4\xde\x9f\xbd\xf7W\xf8K\x94%\xa6K\xd9\xfc\xe3\x94\x90\x1a\xb3\x88\xf8O\x9e\x06Gn\xb9\x98BvQ\xf7\xd2\x9f\xcb\xc97\xf5\xc5\xd8\x99\xfa\xc7\xbb\xael\xfb\x87\xc6\x8aT}\xf3\x8fwXH\xa14\x9e;\xca3[\xfc\xadW\x0d8\xaa\xcc\xd9\xde\x88\x9e\xa3\xa4\xc4\xb4\xa9[\xef_~\x9a\xbf\x8d\x06K\x8b\xf6\x13\xc7=w\xd4@\xc6n\xa4s\xf9\xe5\xd0\x93\x97\x8c\xf1\x89\xa8\x07\x11\xb1\x8d\x99\xd9+\x96#\x11\xb2\xe0\x8f\xa7\xdeU\x0d\\\x0b4Bu\x06\xb8QJ0X\x139\xf8\xab\x0fGg\xdd\xa8Y\x02\xce\x8dmk\xf63\xac^X\xf1\xd9\xefy#\xe4\xab\xfa-j\x02Y$\xb1\x90\xbd\x193'\xe17\xe7\x8dp\xc1\x80\x0f\xc6\xf5\x91\x8b\x7f\xf8\xcb\xddo\xb1\xc0\xe4\x9c\x8a>\xbf\x18\xf1\xadX\xa8\x02e\x8co\xcb_\xeeMp@\xa4\xfd\xcc\xc3'S\x10a\x18&\x1a\x87Qp\xc2\xdfym1\xe9\x18\xe4J\x88\x85\xf4\x95\x19\xb2\x94\xfe\xd5\x03c\xc9\xdf\xe9$\x12d\xe2 \x85\x83+/'?\x82\xa4t\xef\xbf\xbc52v\xeb\xd8[\x1e\xe7c\xa30\xd0\xac\xc9\x05\x1b\xff\xd5B\xda\x10\xdc\xe3f\xafy,q\xaf\x80\xa1\xc6Q\x88\xc5\x7fk\xd5J\\217z\x04\x05\xe7\x87]1\x01\x8e\xc1P\xef\xa3\x0e\x9d\x17\xa2\x1c\xff<\xf1c\xec\x18'\x16\xea\xeb\x8d\xe2\xc5\xe6\x90\xf3\x17\xef\x92\x98 \xd1\xef\xb1\xcf?gP\xd2\x101\x16\x0f\x82_\x9e`O\xa9\x12\x16\xfcD\"\xcd6:\xe68\x83\x16^,\xff\x88|\x10\xfe\xf1 \xfa\x0f\xe2\x92\xdeF\x98\xce\xbe\x19n\xceq\xc1\xea\xe5\x8dgu\xff|\xbb\xbav\xac\xd6\xde\x8b\xcb\x19\xc7?\xb0\xf8e\x08\xfc\x17W>z\x94\x85\xf1\xb6\xfb\xcagv\xee\xca+t\x01\xe4\xf5\x99^c\x8e\xa7\x88\xf4B\xfc\xadw?\x07\xed\xe6]\xf5\xf2\x0f-\x1bkK\x10po\x98\x15\xbb\x10\xde\xbf\xfd?\xdd\x10D\x84t\xe6\x1f\x1d+}\x8aG\xd9Q\xfcwn\x14\xc3\xca\xeb\x8b\x01\xf6<,\x9d3\x15\xe9 \x8b\xb50\x91_\xb6?\xd9\xfd8\xd87\xd7\xaa\x91\xeb\x88g|\xff\xc8v\x87]I\x00\xb9\xfbO\xb6+\x93\x85\xe1\xaf\xd0\xb7eQ/\xbf\x85/\xa6\xc2\x7f~Yj\xc1\x9c^C\xbfC\xe4\xe3\xce\xdc\xf7\x1e\x96\x83{\xfea\xf37/\x0d\x18\x92\x9bFJW\x9cr\xd8\xbc\x83=1\x9b\x17\xc2?\xce\xf7\xd4xY\xfd\x01e\xf4\xda\x99\xf7\x15\xe4\xd4\x1b\xbe\x8c\xf6\xff\xf1P\xbdb\xac6\xde\x93\xdf\x05\xa7<\xc9\xb9a\xff\xc5\xc3\xcdQ\xafD\xa7\xbe+q\x90\x92\xab\x89v\xa6\xae\xf1\xc4\x1b\xfe\xd8\xa5\x99\x07\x99G-\xc2\xb2j\xe3\xb7\xddY\x149\x18\xfa\x9f\xedm\x97|+\x9f\x83\xdc\x18\xe7l{\xc0\xddg\x9aRs\xe5\xfd\xeb 4\x98'k\x92k\x81Yt\xf2\xca\xc7\xf2\x13gn\xce\xfd\xab\xd1\x1d\x18\x16\xea\xd7\xc8M\xe2#\xe8I\x02\xcd\x1aR\xb0\xe8\x9f\x0f\xe9\x81a\x82\xe6\x87\xda7\xc59[\xa3\xc6\xc4\xe9_|[\x95,\xf1\xfdT\n\x17\x00\x7f\x91\x96\xfd5\x98\x08\x13\xe7\xf63\xfe\xc6!v\xcdY\xf7uW\xee\xb5\x9ak\xa1\xd2\xa7\xd1\x87\x1dl\xd0\x872\xdcp\xfc\xfd6d]2\x86\x87\x1b\xa9C\x8cV\x179\x8f\xea\xee\x86\x82E\xebg\xce\xdb6\xdb\xea;\xc5.'\xc2=\x16=\x95Qp8\xe4\xfc\xde\xeflQn)\xee\xa1o\x10\x038p\xc3\x91n\"3|\xc1\xe9X\xf7\xaf\x07\x12Z\xb0\xf5\xe73\xec\n\xcem\x1b\xa9+\xce\x08E\xe4\x07\xa0x\xf3Q\xad\xee\xb7w\xc1\xe8\xb6\x19\xc6\xc5\xaa7\xe8\x0b\xc1\x11\xdf7\xea\xd5T\x02\x95\x92\xd0'\xea\xf3\xdb\xeb\xe9^2\x86\x86\x17\xe9\xa5L.\xe2\xd4\x9f\xe90\xe9\xbfZ!\x14\xa5\xfa-*Jk\xa6\xb8WS\xe7\xa2\x02\x03\xd3\x80\xfe\xb7\xd6\x1e\x9d6}\xbd5\xa1\xdf\xb7H\xb2gV0)_\xfe\xee\xd3\x82!.\xd8\xfbC\x8b\xa2~[\x81\x99\x04\x17\x14\xf9Oy\xb1)\x96\xfd\xf6\xe5\xdc\x1c/z&J\x15F\xdcb\xb6\x8d\xc6\x9fV\xd7f\x8a\xaf\x8dh\xe1@\xed\xf7\xee\xc4\x8bC\x96\xfc\xa6\xc6\xd8V\xe2\xa1x\x1e\xac^L\x8f\x19\x96\x02\xed?6\xb8\xb5\x03k\xaf\x9a\x9d\x85\xa1'\x1e\x9a\xe7\xc1\xba\xc5\xdc\xd8\xf7\x1d\xfezHQ\xe40\xe0\xc5\xb3\xac\x92\x9ar\xda\xddj\x14\xbf\xfe\xd9\xd1\xb0u\xa5\xc5&\x84\xe5tW5\x19m\xec\xe6\xbb\x80\x16l\xfc| \xf17vj\xa7z`f\xa8\xbe{\xb2l\x96\x9ar\xdc5\xa0\x95\xb2>\x0c\xca$\xa1\x18\xfd\xdd;\xb2\x1c\xa6\x00\xfe\xa8\x86n`\xc5\xcdp\n\xda[\xd2 \xf9\xf0\x91\x0b\xff\xed\xfd\x87N\xf8\x9c,\x8cp\xb9\xc4C\xd2\xbcQ\xbf\x15o\x13}\xea'\x1ey\x9b\x9e\x17\x0cI\xed\xd2\xa6\x1d.\x17 \xa2\xfav\xdf\xc6A\x9b\xe27\x07.\x0b\xca\xcc\xcf\"\x13\xd6\x96t\xaf \xa6\xd103X!\x00\x7f}4\xf4\xfe\x98d\xfc\xda\x9a\xf3\x13\xac\x97\x9e\xe91\xc6\x80\x82\xf5\x17\xa7\xf6\xbdi9\x7fL`Q\xc1\xab\xc0H\xea\x98\x16P\x96\xfdw\x9fJ\x90\x8c\xf6\x1eY\xb3}\xd9\x99\x93\xa6\x0eEX a\xdd'\xfd\xb77\xd6\xc5\x0c\x986\xcck\x87)\x9b\xf9\x9c\xad\x1f\xb7\xd2\xe2\xfd\xf2&\x0fL\xcdh=^-,^\x19\xf0\xe0\x03\xed\xfc\xf8\x80\x91\x05\xbfw\x82\xc4#\xa0\x93\x93\xee\x0c\xf5\xe3d\x83\xe6\\\x8c\xf9X\xf9_\xecV '\xbdX\x13({\xff\x8c\x846g\xa7\xe1'5\xe8u\xf3\xfc\x9e\x9b\xe6m\x96P&\x02\xb5\xcf:\xed\x823\xb6<\x90I\x0d\xf9H\xd2_|V\x02\x89\xd1+\x8c\x92\xbft\xe4\xb4\xbb6\x0c\xa9\x97?B\xc1\x96\xcd\xcf\x9a{7U(V\xa6vi\xcc\x8e\x96\x8b$S}\x07\x80\xf1\xa1\xe07\xa7e~\x01\x8c \xb5\x86\xa1\x11=\xeb\xaa\x07;&\xd6\xfc\xbfX\x97\x80_<~\x8e\xcd\xdd\xbf\xb2\xa1I?I\xc7\xbe\xb9\xcf\xbf\xd8\xc1\xdaP\xeb\x81\x13\x15\x04w\x80;tS\x80OW\xc8\xcb_?\xa6J\xab\xfc\xdeEC\xc6\xc0\x1cs\xe8\xc8OJ2V\xc1\xfe\xb7\xe6\x8c\x88\xbfM\x1c\xcc\x9a\xc3\xba\x81\x1d6\x8aD\xe4e2\xd1\x8dzs\x93\xf6\xdb\xbf\xd1\x95\x00/~\xce\xce\x87m\xeb\x98\x0e\x97A\x02\xfd\x16\xef\x83\x11\x1f\xeb\xea\xf7?n\xe3\xd6\xb8\xb8\xd3+\xb6H\xd3Z\xe2\xe7\x99P\xbfE\xf0`\xc5G\xba\xb8\xdf\xde]\xd7~j\x17s\x0f\xb8\x07hZ\xff\xfbL\xa4\xdf\"S0\xe3\xa3]\x7f\xf1`\xdaW\xeb\xe2L\xaf\xd8\xa7\x1f9\xc5Z\xbb\xecq\xd7\x80L\xa9_\x85\xf8\xd7\xd3\x14MS\xde\xe2\xc7C\x90\n,uN\xf1\xa0\x98\xbc)\xb2\x7f\xb9\n\x0e\x14\xed\xe6p5r)1\x8c\xa8\xc8id'\xbfE!a\xf3\xc7\x85\xff|c\x8c\x13\xb9\x10=\xef\xe8\x13m\xbb\xf1\xc2\xe8\x16\x86\x9a(e\x7fk\x92\xfd9\xcfm.q5&\xd6 \xa1\x83\xc0\x83BG\xa7\xfav{S\x16\xfc\xf9WGLa\xf1\xbdS\xe3\x94\xc2 \xe4\xcd\xc4\x93\x03\x7f\xd5\x02\xb8`\xf7&\xfc\xa7\x93n\xef\xc3]y/~\xd1\xb6q8Zs\x15\xef\xc5o\xca\x82\xf9O\xc6x\xa2\x10?\xf9\xa1\x1b\x18p\xaf\x94\xde2\xac.\x9c\x1aT\x10\xb7\xf4o\xfc\x0e\xc5\x05\x1d\xd4\x98-C\xd1\xf9FN\x0bK\xe4\x88|8P\xc8\x9b\xad\x13\xb6\xdf\x1e\xd7{\x93\x03\xa8f\x8c&\x9b\xe8K\x91\x05\xab\xf4t\x16\xf9\x05\xc2\x7fk]ka\xc78\xf0=\xd2\x17\x8a>)\xd7\xa6Z`_\xdc\xf90\xa1\xfb{.I\"R\x90\xdb\xdd^2\xff39-\xac7\x83V\xa3\x8c\x85\xc9\xfe?\x1b\xd1\x8a7\x7fBpN|gM9>\xbc\xbcba(Fn\x80\x7fc\xd8q\xe1Q\x8aH}\xb9\xcc|I\xcf\x1dRaQ\x00\xa4C+M\x7fdZ\xb2\xfe\xfa\xe7\n&\xc6\xda\xea;Xd\x1f\xb4\xe2\xa0\x9a\xd1~\x8b*\xc6\xde\xcc\xf9/\x1f\xec\xfbt@\xb3v\x84\xa2\"-\x88\x18\x88\xb6\x97\xdf\x12\xfa(\x00cg\xdao\x0f\x0d\xeb\xffX\xfa\x92\xe6uyh\xe9\xef\xf2nY\xa0\x12\x14\xab\xde{\xab\x12& \xc8\x14\x19w\x80\x8caFD\xfd\xf4\xb7\xf8\xfd\x9f\xfd\xa9\xe4D\xba\xfbt\xbb9\xdd\xa01\xf7\x8c\xf2\x993\xe8\xd6\xc9\xaa|\xf48v\x97\x8f\xb9\xc2%\xc2\xc2\xf0\xb7+U\xbbDh\xdeXR\xb4\xb1\x82\x1fjT+\x9e{q\xd3\x1b\x0c\xef\x08\x9e\xf5\x7f\xde\x7f\"\x8fLJ)\xff0\x863\xf4\x16W\x9c\xa2\xeb\xf0\n\xe9x\xba,\xdc\xe3?Nw\xf1\xa3\xdf\xf8\xfa\xd1\xb6u94Q\xaex\x88\xf3\xd3\xb7\xbc\x00d\x94\xa7\xff\xb4V\xe64V\xd0#\x1a\x07\xb2\xd7\xdeS#\xdb\x1a\xdf\xf3z\x1d\np.\xe3\xff\xb0\xbd\xa4/\xbc\x8a~K\x95c7\xf8\xce-\xce\xe9\x0c\x89\xa5\xb5\x0b\xa3\xd3\x8e\x8cz\xc7\xcbR9\xddN&\xbe\x05^\xe5+\x19\xd5C\x0e\x9e\x80L\xbft\"X\xed\xb8Gm\xa4\x8e\x1e\xbdE:=\xc0\xc3\x92\xcf\xf3x\xaa\x05A\x0c\x05G\xba\x88>\xa6K<#\xbb~c\xcb7<_L\xc4r\xad\xf2\xc9x\x82\x95\xd5.\xbd\xf4R<\xa7%\xb1\x9e\xa4%\x04\x16\xedy\xe2\x12o\\Fq\xc2\xfc\xf8\xe2J)\x93\xd2\xad\x12\xcby\xd7\x0d\xcfZ\xd1\xf3\x9b\x8d\xed!F\x93{_*k\x1a\x9f\xc3\xc2\xe2\xdb\xb21\xef\xbc\xfe\xb7\x17\xfd.\x90[\xaa\x02\xe9\xe8z\xd6\xe0\x99\xbc\xef>\x98\xb0\x00\n\xb0B\xf4\x0f\xc3\xea\xea\xa8\xc0\n\x02\xa7\x08\x92\xca\x87\xd7\xf6L\x16\xa8\x00\xe3\xf7_~\x90\x1e\xa2tQj\xac\xf8\x8asn\x1ccp&w\xb4\x1d\xa9\xd8L\xa1\xe9\xb6\xd0\xd8}\x8c\x1e\xc5\xbb\xd7`\xbd\x03c\x8e\xf8\xfc\xeb2\xcdO\xabS\xcdfp\xdc\xa4\xcb\x9f\x97\x99Hr\x82X\xd0e\xca\xeb\xdb\xa8s\x81S\xf9bw\x08.\xdb\xddB,\xf8\x91\xfd]\xd8\xcb\"AL\x0fG\x92\x0eg\xd1\x9bs>VHKoW\xc9F:0\xb2Q\xff\xcbv\x16\x07i\x1c\xa36\n\x14O\x0f\xdcQ\xdc\xd4\xb0\x0c29\xf8\xdb\xe73\x9d\xe1\xafl\x8b.r\xbf\x83\xd2\x82\xef\xd4\x9a\xc3\xcb.\xbd\x0c1\x9b\xfb7\xa3\xe8\xb8\x8av\xcda\xcbK=_\x9aLg\xad\xc8\xf4~\x82w\x08\x83L\xfc\x87-\x1f\xe3\xd9\xb9\xe5\xdd\xd9\xcd\x942h\xb5\xb6\xd4\xfc`\xde\xe5L\xfc\x95f\xae\xfe\xed\xb2\xc2z\\^\xb6\xa6v\xdb\xc8%^\xbdj\xbfJ\x9c\x8e\xd8\xf2\x10\xc0!\xfc\x9dwN\xb4\xa4\x8e?\xd2u\xfe\x96\xb1/\xb6q\xadxg\xe2\x8d\x1d\x908x\xcf\xe0vP\xb2}\xd6\x93\xbbI#>\xe6\xf8\xd3=\x9a\x9env\x9fF=I\x9f\x00^\xa9\x15\xa6\xc2\xbf\xbe\xfbq1\x0b\x91\xc1VHt\xb1\xf6\xab\xc8w\xda\xa7\xb9f\xa4\xd9J1\x97w\x9e>u\xf9\xe7\x14\x8c\xe6u\xb1\xe2xm\x13]\x0dw\xf2\xaco\xc3\x94!\xadO\\\x11\x9c4\x91\xdfgJ\xaf^\xdb\xfe\x0c\x96\xa5<\xe2\xde\xf2\xee\xc3\xd3\xeb.\x1fW\x80\x98\xd7\xae\xf1\xb7\xc2\xb4N{}\x15\x1b\x92\x8dW\xcf\x97\xc8Gj%\xfd\xe6cvQ\x8e\x8a\x0d\x99H\xa5N\xfbu\xb1u|\xe9\xd7\xaa=\xb7\xaa\xef\x9c+\xbe\x17u/O\x8c\x0cP\xeb#\x8c\xb2 \xe6\xdb\xf3\xf37\x9f\xd0{3r\xc9 \xd9\xd4\xed\xfd'\xf5W\xf6J\xe6\x18|6\xb5@O\x10\xf1\xc4\xc7z\x87\x8cRf\xfa\xe9\x91\xdd6\xaf}F\x89\xe2\x89#\\\x05?-UA\xac\x0e\x7f~0\x89\xc4\xdb6\x91G\xcb\x8a\xe50\xa5\xf7\xa5R0:\xd5\xdcI;\xbeD\xa6baA\x95\x8cN\xce\xf9Tb\x01\x15UGg\xad\x15\xe9{\xb2\xe1Z\xf9\xc6\xbfH=o\xb7\xf7\xc7\xb3_\xd8\xeb\xcd;\xd4\xf7\\\x9b\xd2Y\x03{\xdd\xad\\+\x1f\x19/\xa5\x16\xfa\x1c4\x0c\xbfk\xd3\xe4\xe8\xddv\x144\xda\xc57\xc7k\x9f1{{\xd0\xb2\xa5\xa1\xb0\xbdK\x0c\xf0\x05\x96\xb5\xbc\x12_\xf2\x8c\xd2\xce\xe0|\x81w\xb2\x86\x95\xa4+\x9e\xb3\x1e\x82|\xb3Yt\xdb\x02K$\x1a\xe1\xb1\x17\xaf+46\xbbx\xf8\xfa\xc9\x0d\x14\xfc\x0e\xbc\xaa\x95\xa7\x85\xcb\x0d\xbcY94G\xbe\xd0\xe5nj;\x0e~3\xa9\xa7\xbc\xeb{~E\xeed\xff\xddB\xca\x7f\xb2\\\xbal\xd7\xddG\xf8\xd0\xe8a|\xb5\xbc\x8aA\xf4>[N]\xf9\xc4x+4x\xa4\xd9G\xf0w\xa9\xd2\x17\xb8\x00\xe9*\xb8\xad8I\xfb\xfb\xe4\xdbK\x99\x84[S\xfa\xb9\xday-\xc9'\xa4HeGR\xba\x88t\x98\x14.\x8b[2\x0c\xac\x00\xaf\xb4\xfb\xdc\x0e\xfd*\xad\xa2\x8b\xb9P{\x0b\xe2\xa5\x8c\xe3\x18M\xf1\x94i\xb13\xcd\xafc\xfd`\xa4\xeb\x86r\xe8\x00\x0b`\xb9{.\xa8\x01\xf6\x0d%\xb4\xd1\xa2X\x0c\xfcN\xf3\x9di\xe8\x85I\x1bY\xa8\x01\xd4W\xb3\x8c\xbd.i\x90O\x7f\x17\xbd\xe8\xf4\xc7\xc1=)\x98\x0f\xb2\xaaU\xa7\x8e\x0b0\x0bt\x1b\x8e@\xedQ\xa8\xec\x98m\xac\x02\x1d\x97Z\x07\x963\x1aS \x8c\xc1w\x1f\x177\xbd\x08~\xf9a\xef\x87\xd2\xa0=I\x10h-6\x1a\x88o\x9c\x87Y\xa4\xe7YvH\x13\x84\x8a`5\xdfh\xae\xd2?\xe6\x1f\x91\xd6C/\xeb\xcf\x8fL\x89\xbc\xd6\x8cV\xc53\xf1\xfa\xf2\x17AfK \xa8\xb7R\xdcu\xa6uV\x15\xc0\x02\xbe\xae\xd8\xabb%\xf2z3\x16UOQ'\xce\x82\x0d\xd0ldo\xaa\x82B\x8c\xbd\x19\x86\xf0 \x94\x07\x1d\x89\xb9\xe3r9R\x0dG\xc7t\x15\xfc\x03\x10\x19\xc4nY\xa6dNK\xd2\xc3Qd\xcb\x93\xa0-4~\x94:\xf9\xe0H\x14}\xd3\xfb\x9b\xd9\n\x8b^\xe0vAo\x19{\xfdsq=\x00\xd9\xeaD\x1b\xad\x17\xe9\x9b\x80\xfdL\xef\x88W\xc1\x7f\x94a\xca\\\x1f/\x14b\xc3S#\x01\x8a1\xa4:Y\xebE|\xbc\x9a\xc1w<\x9a\x1f\x02\xe6\x83\x9f\xe3\xa5r\xc1-\x04\x17\xd9\xa7/w\xd4\x04\xd4\xc6r\xb8\x95\xb5\xdb\xde\xef\x95\xe1-\x95\xa8\xeb}\xb3\x8b\x80\xc6/\x1fVz\x97\xdf\xc0\xea\xb1\xd7!?C\xf6W\x8d$e\xb7|\xf1\xe0\x88g\x95\xce]\xc7\x9d\xf6\x97^\xbb\x05\x90?\x0e\x8c\xf15\x7f\xfc\x9c$\xef\x93\x7f\x98\xed\xca\xcd\x18\x12\x92\x7f\xa5\xbcbH\x13\xe3\x86\xe4 \xa9\xf0p|\xd9/\xda\xdf\xeb\x00\x10\xedL\xc3\xfd\xfb\xe4\xd3\xfc\x8c\x1a\xb6\xbc\x05\x0f\x83D\x828\x0eW\xbe$\x0f\x9f\xfbI1ue\x16\x86\xe7|T\xb7\xd8\xfa\xf2\xb8\x0f\xeeU\xab\x90\xe89\xbczX *[E\xc0\xd4*\xc3{\x1d\x16\xf4>\x87\x17(\xf1\xe8lm\xf8\x16H\x95\x8f\x08(4\xe9\x02\x84\xba\xfb\xf9\x02\xbaHE\x87\x9e\x9a\xd7\xfb/\x18\xf2\x96Y-\xe0\xbb\xd9\x14\xbb\xa1\xe9\xd4\xf5\xe8\x9b\xc3\xab/E\xa1:o\xd9\xbb\xfa\xda3\x8f\xbc\x82\xd8054\xc2\x98w\x1a\x17\\\x94y\xfdts\xd6j4\xf1\xf4b\xcb\xebB\x8f\x03Wf\x82\x01\xf6\x1e\xfa;~.%\xbfl\x94\x8f\xe4\xc1\x90[eAH\x9f\x06\xf7T\x7f2T\x1c\x0e\xa7\x9ci~\xd5h\xe8I\xd9nJ\x8f\xce\xb4\xc7\xbd\x82\x13:\xb4T\xc3\xbe\xaf\xb3\xf3\xaf\xd2\x85\xb6\x93\x9e\x82i\xab\xfd\xa2\x7f\xc9x\xbeF0\xde\xcc\x1e\xbe\n>\xd4E\xaa\xef\xfc\x9d\xa6\xe59\xac\xd7R\x12z\x1d\xc4oT\x82\x98\xd9F|\x1e\x9b\xe3\x06C\xf8\x03\xc8\xa0#A\x9ax\xc5c\xe0T\xad\xe2\x18\x7fT\xd5\x85\x1bWv\x82\x92\xad\xd5x\xe7S\xc4ov\x0f\xcd\xac\x9d\x1e\x07\xc4\xb5Xo\x0e\xc6\x90<\x92$-\xa2\x8e\xd9\xa2\xfa\xb2\xa7%+\xb7\xde\x91Og7\xd5\x81\x183\xcf\xcc'\xd9t\xda\xcfn\x03\xadj\x15w\xb6~\x9f\x1fc\xa4\xc2\xf9\xd86\xe1W\xc16\xac3\xf7\x18\x893\x9b\xc7\x8e\xdc\x1e\xe3Z\xf5\x8eb\xb3\xfb\xfe\x1b7b\xf6\xed\x92\xc8\xde\xcf\xac\xc6R5j\xe5\x82{(\xf2\xba\xb7\x02K\xc0f\xa0\xee\xdf.\xba\xf8\xa9\xad\x1d\x97;?p\xb4;Z\x19#E\xfe\xa1\x9e\xa8\xc7\xc11\x83\xef\xd2Y\x1f\xad\xe5\xa3p\xc4\xd3\xd7\xd3\x06R\xfc\xcekf\x860\x14\x10+\xfa\x1bcV!\xd1\xa72\xcf$e\x93}\xfa9\xd8\xc0\xd6E\xfa\x99\xef\x0eyi\xc3\xa9>2\nWI\xec\xdb;\x03\xd86a-\x8e\x8d\x19I\xedf\x850\xbd\xea^\x05\x92\x1ds\xbf\x1ds\xba\x0bW!\xc8\xb7\xfb[k\xc7\x80Q\x9a\x92\x9cn\xf6\xbd\xfe:~\xe4\x8a@Lk1\x95\xfa\xe1\xe1\xf5:0\xeb\x90(\xfa/p\xca6\x89\x8c\xc4p\x80\x1cR3\x90Z\xc1\xb0\x11\x13\x99\xaf\xe8H\xe7\xb1FF\x9d\xce\x10\xe5\xd0\xff>Gu\xcb\x98\x0f\xc6\x87\x01,\x15\xd6\x0en~\x08\xde\x87\xdb\x89\xb5\x94\xb9\x94\x04%,\x1f\xcb\xfd\x19\x1di\x1d\xb7\xa2Q>V\x98\x80z\xdd\xf8\xcd\xf4\x9b\xb0V\xb0:Z\x8a\x87\xb5\xf3lrud\xe8\"#\x16p\x02r\xb0\x98y\xa4\x1ch\xf2sD\x07\xb6\x99t\xe45\x7f\x83\xc7&\xfcb\xdc\x95\xa51\x10\xf7\x18\xa4o\x05f\x92\xf1\x0d\";\xe4S\x96\xc5\xf0c\xf0z\x92\xb8\x87M\xbd\xd4q\x8a\xe2\xed9\xea\xdf<\xff\x18Xn\x1f\x11Q\xbd\x11\xf1\xf3\x8b\xaf\xd4\xe7\xf4S\xae\xbd\x01>\x02,\x00\x8d\x8e\xc9\x18#\x80.\xa5[\x81\xb9,,\xa2\xc0\xac\xa3/WR(\xc6\xfa\xa0\x08\xca\xac(~%\xda?\xe3\xca\xe4\xc2\xd7\x18\xd51&s$\x9d\xe5#E\x0ft\xbcR\x86\xd4]V\xb5:!\xc9\\\x80G.j\xac\xb5\xb4Lt\xb5o\xec\xbd\x9e\xdc-G\x1dP\xd2\xd7\x9b\x89x\xfd\x9bZ\x7f=\xfd\xf1\xcc\x1b\x82C\xf0\xfb\xc6)*\x1e\xf6\x86\xf3\xcef\x85<\xad\xc6&\x1a>\xf0\xbe\xb5'\xb6\xfdI=\xbcejN\x96n\xbeO_\x17\x8f\x81\xbc\xdf\xa5'\x97\"\x12\x7f\xf5\xfd\"\xfdd\x9e\xd7\xae\x0d[\n@\xea!\xd7\x99\x99>p\xa1\xd9A&\x13\xd9x\x0dY\x8e6\x1c\xfb\xe4\x18ro\xa4\xa0\x8au\x9a\xedxj\xb1\xf9\xf2\xdf\x8b\xf8\xf8\xa63\xda\x98\xe6\xbc\x0d\xb7\xf1\xc2\x9e\xde|\x13\xeasw.\xed\x151\x89\xfb+~O\xca2`\xb3\xb4\x9e\x14>\xd5bBQ7q\x0c\xe2\xca+k\xbd7V\x7f_\n\xf6E\xdd\xe6\xf8*y\x00\x95z\xdb\xaeO1RU\",U\xa4\x1c&r^\x9e\xa2.\xf7 )\xce\x19x'$\x17\x9b\xd8m\x92\x1b\\\xd8{q\xe2+\xc3x\x1ec\xbc\x9e\x0d\xa8\x05\xfc\xefZ<\x94\x0bS\x18\xbcMg\xa7\x11\xcf2M\xbc?LR\x1d4vd\xe5_Fz\x94/ \xf6\x90\xb2(\xfbx\x97\xd4E\x91\x98l\xb0-o\xc6)\xed\x0f\x86#\x07\xf8\xdb\xa5\xcf\xf7\xd2\x13l\x1c,\x14\x8b\xc4\xc7\xe1N\xe6*+t\x10\x08\xd7\xcb\xfa\xb4\xb5\xf85r\xf6m\x1e\xcc\x1b;\x9asB\xa4$}1vHf\xfd\xfb:\xd9\x9f`\xeb\x16\xa2\xe0\xe0\xb2\xa66%\xeex\xa8\xbf2\x9d\\\x92\x18ET\xf2\x07\x03T&\xc7\xd8J\x19)\xcc\xf7\xd0\x97\xce\x82\xae[d_\x9a\xf7\x02\x99\x18\xbd\xdb\x0bd\xac\xae\x84\x10B\x84]\x8f\x97g\x8a\xcb\xb2\xfc\x9f\xff\xf9\x7f\xec\xff\xfe\x7fvy\x97\xff\xfb\x7f\x01\x00\x00\xff\xffPK\x07\x08\xff9\xbd(rt\x00\x00\x12\xa0\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00r\x9b\xb2P\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00#\x00 \x00img/supervised_user_circle-24px.svgUT\x05\x00\x01\xa9\xe1\xc2^D\x92\xdd\x8e\x9c0\x0c\x85_\xc5\xca\xbd\x0f\x8e\xf3G\xaae/z\xd5\x9b>D\xc5N\x87\x91\xe6gU\x10\xacx\xfa* 3+\xa1\xe4\xe3\x9c\xc4\x18\xdbo\xf3z\xa6\xaf\xdb\xf5>\x0ffZ\x96\xcf\x1f]\xb7m\x1b6\x87\xc7\xbfs\xa7\"\xd2\xcd\xeb\xd9\xd0v\xf9X\xa6\xc1\xa874\x9d.\xe7ii\xbc^N\xdb\xcf\xc7\xd7`\x84\x84\xd4\x93z\xf3\xfe\xf6\xf9g\x99\xe8c0\xbf\xadE\xce\xa4#\x07\x04%a+\xe4\xe1\xfb\xb2[\x99\x0bR\xc5\xf6\xf0!\xf0\x81\xdc\xd0\xca~s\x88\x96\"\x9c\x1f-$\x91\x90Ev\xe8c\xdd\xdbR4I\xfc\x14\xf9\xe5p\xbbR\x85b\xf3\xcbf\x88\xad\xeeS\xfd\xbe\xb9\xdf\xb8H\xa1\x1f-Jl\x85+q%6\xaaK\xf9b\x8d\xde\xde\xf9\xe5\xcc\x15\xab\xc3/g,\x198[\x82\x84\xef\xa3\x95\xf6\x9bP\x86u\xabC\n#+<#\x05\xf6p\xac\x88\x1c`={\xe4\xd8\xeeZX%\x87\x98\xd8\"f\xaan!\x84V\x04\x85\xf4\xe5G\xa0Zt\x8f\xbe\x9c\xcc\xa4\x10= \xf6\xfb\xb3722\xb4\x94\x07\xa1\xd5\x03)3\xc4\xaf\xec!\xa9&\xed\x95\x14\xd9\xb3\xc2\xba\xd2\xbf\x06G#\x14Y\xe129\xf4\x9e,l\xcd/\x159\x95\x08\x91\x02$s\x82\x0f\x95v\xd3\x1d\xf3\xf1\xf7r\xbd\x0e\xe6\xfe\xb8\x9fL\x9d\x15!\x99\xd4\xaf\xea\x7fI=T\xc6\xee\xfd\x7f\x00\x00\x00\xff\xffPK\x07\x08uq\x02\xd2d\x01\x00\x00\x9e\x02\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00r\x9b\xb2P\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0e\x00 \x00style/main.cssUT\x05\x00\x01\xa9\xe1\xc2^\xbcX[o\xdc\xbc\x11}\xf7\xaf`\xd7\xf8\x00;\x10\x15]V\xbb\x8e\xf2\xd6\x14A\x0b\xd4yH\xd0\x87>R\xd2h\xc5\x9a\"\x05\x92\xf2\xaeS\xe4\xbf\x17\xbc\xe8\xba\xbb\xb6\x9b\x87/\x88\x01\x8b\xd7\x993g\xce\x0c\xfd\x01\xfd\xf7\x06\xa1\x96\xc8\x03\xe59\x8a>\xdf \xd4\x91\xaa\xa2\xfc\xe0\xbf\xf0\x11\x8a'\xaaq-\xb8\xc6\xaa\x15B7v\x92pM \xa3DAe\x97\xb5\xe2'\x16\xeat\xb6\xee \xc9\x8b* \x83\xf9a\x1aN\x1a+\xfa\x130\xa9\xfe\xd3+\x9d#.\xb8]Q\x88\x93\x99\xb0[\x0b!+\x90\xb8\x10'3c\x0f\xaeIK\xd9K\x8e0\xe9:\x06X\xbd(\x0dm\x80\xfe\xca(\x7fz$\xe5\x0f\xfb\xfdUp\x1d\xa0\xcd\x0f8\x08@\xff\xfa\xc7&@\xdfE!\xb4\x08n\x10Bh\xf3w`\xcf\xa0iI\xd07\xe8a\x13 E\xb8\xc2\n$\xad\xc7{\x8cm9\x8a%\xb4f\x88Q\x0e\xb8\x01zht\x8e\xe2pkF\x7f\xdd\xdc\x84\x9d\xa4-\x91/\x16\xc2R0!st\xbb\x83m\n\x0ff>df\x83\x9d\xfc\xf8\x01\x91l\x1f\xd75\xfa\xf0qZ+\x0f\xc5]\xbc\xcb\x02\x14\xc7i\x80\x92,\xbb\xb7\xdb*\"\x9f\x86]\xb7\xdb$\xf9\xdbnw\xb6m\xb7\x0b\xd0\xd6\xec\x8c\x92{g\x8b\x85\xb4\x15\\\xa8\x8e\x94`\xf7\xcf<\x89\xc2\x87\xcc;\xb3\xc0\xf1\xc7\xd7G\xc1\x05\xfe\x0e\x87\x9e\x11\x19\xa0G\xe0L\x04\xe8QpR\x8a\x00}\x11\\ FT\x806\xff\xa4\x05H\xa2\xa9\xe0fVl<\x98_D/)H\xf4\x0d\x8e\x9b\x00M\xf7\xff\x85\xb6\x9d\x90\x9apm\xcd+D\xe5`\xaa\xa8\xea\x18y\xc9Q\xcd\xc0\x85\x95\xc1 WTBi\xce\xce\x91\x14G3L\x18=pL5\xb4*G%p\x0d\xd2\xd2\x83\x94O\x07)z^Y\x1c\xc8\x12\xbf\x00EadP\\`\x95\xa6\x01J\xf7\x01\xda\xc6v\xc6\xf0\x8d\xd6/\xb8\x14\\\x03\xd79\xb2\x06\xe3\x02\xf4\x11\x80[ko[B\xf9\xfb\xcc-\x05\xeb[~\xf1\xdc\xc9\xea#\xadt\x93\xa38\x8a\xfe0\x9f-\xe5\x13\x9b\xa2\xe8\xb9q\x97R^\x0bCv{\xf1+\xa7]\x81\xe6\xccT\x9f\xc7\xb8\x10Z\x8b6GI\x98H\xcf\\\xe5\xcc\xff\x7f]\xec\x84\xa2>J\xc0\x88\xa6\xcf6e-\xf3\xacQ9bP\xeb3\x17\x13\x7fk\x13\xafi\x99\x84\x0bV\x1e\xfd\x8em\x14\xad\x0f\x9e\xfcd\xa05Hl\xa2\xe6T*L\xbb\xd3\xb8\\K\xc2U-d\x9b\xa3\xbe\xeb@\x96D\xc1\x8a\x0fq\x1c\x05h\xb7\x0fP\x92\xfa\xe4i\xe2PS\xcd\\\xd6\\\xbeu\x12\xc5polFq\x98d\x03\x9cMr\xb6s\x00b\x90\x85\xd4\xfe{\xd3\xcc\xab\xce\xcdUi\xb8y\x85\xda.\x8b\x9c\x14\x94DV\xd6 /\xa0\x92T\xb4W\xe6\xb0a\xdfjb\xeb\xeep\xa39\x8a\xbb\x13R\x82\xd1\xca\xa5X\x14 \xff?\x8c\x13\x97]\x96\"\x07)\x8e9\x8a\xc7o\xd5H\xca\x9f\xfc\xc8XQ\x10N#w|KN\xd8g\xc2vJ\x84a\xe4\xc1\xaf\x1a\x81\xf6n\xce\xdd\xb5\xc5\xa1!\x95\xb97r\xe6\x98\x15\x91_\xb9\xb66\xda;E\xbd\xa9)\xb0J\x81\x9e\xd5\xba)+\xfc\xbdse\xb9\xad\xcb\xba\xac\xeb\xac\xfa\xedK\xdf\x82x(ws\x89^\x9a\xcaH\x01\xcc\x1a|9\xeb\xdeL[/\xa4C\x16n\x935\xbeQwr%\xfe\x8a\xa0\xbc\"A\xe7\xd9zfz\xce\x85\xbe\xcb\x19Q\x1a\x97\x0de\xd5\xfd\x9c\x91\x03\xf8\xaf0\xed\xfe\x12\x1c\xaa#N\xb4f\xcc\x89\x93l\xe5Y\x84\xe2l& >#\xa5\xb1\xd6\x1eJ\xdbCHK/\x7f\xfe\x14\xd2k1\x87+\xdd\xcd\x036\x8613\xc459\xd6\x82R\xe4\xe0\x14c\xbc8 3\xcf\x8d\xfd\xa8\x0d\xa1u\xc1 \x1f\x83\x93O\x8f\x0b\xb6^R\xc0\xab\x15\xff\x15\xbe\x8a^\x9b~e\xe2X\xd9KeN1`\x98\xefcC5X\x89\xb1\x8b\x8e\x92tv\xdf3\xc8\x9a\x19\x967\xb4\xaa\x80\x8f\xf8M\x13\xc0\x18\xed\x14U\xcb\xd0\x84\n\x18\x94:\xcfI\xadA\xfaf\xc8Sf\xb3\xf9\xbc`0)\x94`\xbd\xb6vy\xe4?9\xf7\x07\xe0=\x1a\xd2}\x0d\xd9\xa9E\xe7\xc1\x1f\x13\xd8\x0e\xe1\x81\xd5\x82\x1anbx\x06\xae\xd5\xe0\xbb\x895\xefz=\xa7\x9e\xd2/l\x06\xce\n-[\x1b\x8d7\xeb\x80\xbdk\xf7\xd4\xe0\x92\xae\x03\" /\xe7s\xa6E\xbe4qi\xec<\x8aC!\x89\xd3\"\xad\xe7q\xf5\xbe\xafIaKLG$\xf8\x16,4\xde\xbc\xaf\xe2{\xe9\xb0\xc3\x86\x1e9\x1aH\xf2\x8e\xe6),z\xad}r\x0dF;~\x9e\xd1v\xe8\x95\xd7\"\xbb\xedNh\xd7\x9d\x9c(dQ\x80\xcc\xcf\xa7\xd4\xeaB|\x1f\x98\xa4\xe9N(\x1dV\xcc\xd5\xf7\xe1\x1d\xe2\x1b\x9d\xa5\xdb\xde\xa5\xdb\xa8\x95\x9euWSi\x06\xf9BO\xc7\x9c\xa9\xa0\x14\xae[\x9e1\xcb#\x136\x84\xd5#\xbf|\x19\x8d>\xaf\xcb\xe84R\x10E\x8d2\x13V\xdee\xd1\x1f\x08\xdb\xbb\xee\x17g\xd6=c\xeb3\xe3\xf9\x8a\xbc1y\xec\xfa\x94\xa9\xf9\xb0\xbf2\xa2\xe1\xdfw8\xb6g\xaec\xb1\xefN(6\x01\x89.\x86\xe3\xde\xbd\x03\"\x1b\x8d\xdd\xb4l\x1d\x93_7\xa1\xa8kl \xe1\xd2qN\x84l\x9ffE\xe6i*\x84\xc9\xe4Q\xa0G\xb6Rn\xdfa\x9a\x14\xee1\xb9\xd0\x818Y\xcaH\xe2c\xbe\x92\xf7g\x90\xe6\xe5\xc7\x86\xa2\xa0E7\x7f>\xf5\x1a\xaa\xe5k\xae\xdcg\xfbj\xf1\x9aY\xbd\xabfJo\xfa.\xec\xcc\xbf\x9cgoe\xcf\xaa\x14\x8f\xcd\xf4\xcc\xd1\xb1\x97\xb2C^%\xf1\xacGZ\xb57\x17\xa6\xcc\xb1\xcb\x89\xeb\xcd\xed\xd5\xaf2\x86\x9f-\x1a\x81o\x80T\xd7\x80\x7fo\x87s\x16\x8f\xdf\x81\xde\x13\xf2O\xc6\xfd\x1d-\xd5\x12z-\xba\xb7p7K\xae\x82n3F\x8e\x7f\xe2\xb8\xd8j\xfd/\x00\x00\xff\xffPK\x07\x08L\xbb\xd3^\xd3\x05\x00\x00^\x12\x00\x00PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\x97l\xd2P\xfa=\x01\xd5a\x04\x00\x009\x1b\x00\x00\x16\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xb4\x81\x00\x00\x00\x00html/dashboard.go.htmlUT\x05\x00\x01\xeem\xeb^PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\x88\xbd\xbbP\xd6L6ob\x02\x00\x00\xb2\x06\x00\x00\x12\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xb4\x81\xae\x04\x00\x00html/error.go.htmlUT\x05\x00\x01P\xfb\xce^PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\x88\xbd\xbbP`}f\xf1\xde\x00\x00\x00c\x01\x00\x00\x13\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xb4\x81Y\x07\x00\x00html/header.go.htmlUT\x05\x00\x01P\xfb\xce^PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00r\x9b\xb2P\x83\xba\x83\xe4\xf5\x00\x00\x00|\x01\x00\x00\x1b\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xb4\x81\x81\x08\x00\x00img/account_circle-24px.svgUT\x05\x00\x01\xa9\xe1\xc2^PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00r\x9b\xb2P\xfc\xc6x\x8f\xb7\x00\x00\x00\xf9\x00\x00\x00\x12\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xb4\x81\xc8 \x00\x00img/error-24px.svgUT\x05\x00\x01\xa9\xe1\xc2^PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00r\x9b\xb2PK\xfe\x8b#M\x03\x00\x00d\x08\x00\x00\x10\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xb4\x81\xc8\n\x00\x00img/pomerium.svgUT\x05\x00\x01\xa9\xe1\xc2^PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\xb1r\xd0P\xff9\xbd(rt\x00\x00\x12\xa0\x00\x00\x1a\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xb4\x81\\\x0e\x00\x00img/pomerium_circle_96.svgUT\x05\x00\x01n\xd5\xe8^PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00r\x9b\xb2Puq\x02\xd2d\x01\x00\x00\x9e\x02\x00\x00#\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xb4\x81\x1f\x83\x00\x00img/supervised_user_circle-24px.svgUT\x05\x00\x01\xa9\xe1\xc2^PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00r\x9b\xb2PL\xbb\xd3^\xd3\x05\x00\x00^\x12\x00\x00\x0e\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xb4\x81\xdd\x84\x00\x00style/main.cssUT\x05\x00\x01\xa9\xe1\xc2^PK\x05\x06\x00\x00\x00\x00 \x00 \x00\xb2\x02\x00\x00\xf5\x8a\x00\x00\x00\x00" fs.RegisterWithNamespace("web", data) } - \ No newline at end of file diff --git a/internal/frontend/templates.go b/internal/frontend/templates.go index 62b862403..589e7a2a9 100644 --- a/internal/frontend/templates.go +++ b/internal/frontend/templates.go @@ -74,7 +74,10 @@ func NewTemplates() (*template.Template, error) { if err != nil { return fmt.Errorf("internal/frontend: error reading %s: %w", filePath, err) } - t.Parse(string(buf)) + _, err = t.Parse(string(buf)) + if err != nil { + return fmt.Errorf("internal/frontend: error parsing template %s: %w", filePath, err) + } } return nil }) diff --git a/internal/grpc/authorize/authorize.pb.go b/internal/grpc/authorize/authorize.pb.go index c7cf7e9c2..ed04ed9b0 100644 --- a/internal/grpc/authorize/authorize.pb.go +++ b/internal/grpc/authorize/authorize.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.23.0 +// protoc-gen-go v1.24.0 // protoc v3.12.1 // source: authorize.proto diff --git a/internal/grpc/cache/cache.go b/internal/grpc/cache/cache.go deleted file mode 100644 index 6f55a1db4..000000000 --- a/internal/grpc/cache/cache.go +++ /dev/null @@ -1,14 +0,0 @@ -// Package cache defines a Cacher interfaces that can be implemented by any -// key value store system. -package cache - -import ( - "context" -) - -// Cacher specifies an interface for remote clients connecting to the cache service. -type Cacher interface { - Get(ctx context.Context, key string) (value []byte, err error) - Set(ctx context.Context, key string, value []byte) error - Close() error -} diff --git a/internal/grpc/cache/cache.pb.go b/internal/grpc/cache/cache.pb.go deleted file mode 100644 index 9c396e558..000000000 --- a/internal/grpc/cache/cache.pb.go +++ /dev/null @@ -1,470 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.23.0 -// protoc v3.12.1 -// source: cache.proto - -package cache - -import ( - context "context" - proto "github.com/golang/protobuf/proto" - grpc "google.golang.org/grpc" - codes "google.golang.org/grpc/codes" - status "google.golang.org/grpc/status" - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - reflect "reflect" - sync "sync" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -// This is a compile-time assertion that a sufficiently up-to-date version -// of the legacy proto package is being used. -const _ = proto.ProtoPackageIsVersion4 - -type GetRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` -} - -func (x *GetRequest) Reset() { - *x = GetRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_cache_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *GetRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetRequest) ProtoMessage() {} - -func (x *GetRequest) ProtoReflect() protoreflect.Message { - mi := &file_cache_proto_msgTypes[0] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetRequest.ProtoReflect.Descriptor instead. -func (*GetRequest) Descriptor() ([]byte, []int) { - return file_cache_proto_rawDescGZIP(), []int{0} -} - -func (x *GetRequest) GetKey() string { - if x != nil { - return x.Key - } - return "" -} - -type GetReply struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Exists bool `protobuf:"varint,1,opt,name=exists,proto3" json:"exists,omitempty"` - Value []byte `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` -} - -func (x *GetReply) Reset() { - *x = GetReply{} - if protoimpl.UnsafeEnabled { - mi := &file_cache_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *GetReply) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetReply) ProtoMessage() {} - -func (x *GetReply) ProtoReflect() protoreflect.Message { - mi := &file_cache_proto_msgTypes[1] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetReply.ProtoReflect.Descriptor instead. -func (*GetReply) Descriptor() ([]byte, []int) { - return file_cache_proto_rawDescGZIP(), []int{1} -} - -func (x *GetReply) GetExists() bool { - if x != nil { - return x.Exists - } - return false -} - -func (x *GetReply) GetValue() []byte { - if x != nil { - return x.Value - } - return nil -} - -type SetRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` - Value []byte `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` -} - -func (x *SetRequest) Reset() { - *x = SetRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_cache_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *SetRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*SetRequest) ProtoMessage() {} - -func (x *SetRequest) ProtoReflect() protoreflect.Message { - mi := &file_cache_proto_msgTypes[2] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use SetRequest.ProtoReflect.Descriptor instead. -func (*SetRequest) Descriptor() ([]byte, []int) { - return file_cache_proto_rawDescGZIP(), []int{2} -} - -func (x *SetRequest) GetKey() string { - if x != nil { - return x.Key - } - return "" -} - -func (x *SetRequest) GetValue() []byte { - if x != nil { - return x.Value - } - return nil -} - -type SetReply struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields -} - -func (x *SetReply) Reset() { - *x = SetReply{} - if protoimpl.UnsafeEnabled { - mi := &file_cache_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *SetReply) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*SetReply) ProtoMessage() {} - -func (x *SetReply) ProtoReflect() protoreflect.Message { - mi := &file_cache_proto_msgTypes[3] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use SetReply.ProtoReflect.Descriptor instead. -func (*SetReply) Descriptor() ([]byte, []int) { - return file_cache_proto_rawDescGZIP(), []int{3} -} - -var File_cache_proto protoreflect.FileDescriptor - -var file_cache_proto_rawDesc = []byte{ - 0x0a, 0x0b, 0x63, 0x61, 0x63, 0x68, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x05, 0x63, - 0x61, 0x63, 0x68, 0x65, 0x22, 0x1e, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x03, 0x6b, 0x65, 0x79, 0x22, 0x38, 0x0a, 0x08, 0x47, 0x65, 0x74, 0x52, 0x65, 0x70, 0x6c, 0x79, - 0x12, 0x16, 0x0a, 0x06, 0x65, 0x78, 0x69, 0x73, 0x74, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, - 0x52, 0x06, 0x65, 0x78, 0x69, 0x73, 0x74, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, - 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x34, - 0x0a, 0x0a, 0x53, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, - 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, - 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, - 0x61, 0x6c, 0x75, 0x65, 0x22, 0x0a, 0x0a, 0x08, 0x53, 0x65, 0x74, 0x52, 0x65, 0x70, 0x6c, 0x79, - 0x32, 0x61, 0x0a, 0x05, 0x43, 0x61, 0x63, 0x68, 0x65, 0x12, 0x2b, 0x0a, 0x03, 0x47, 0x65, 0x74, - 0x12, 0x11, 0x2e, 0x63, 0x61, 0x63, 0x68, 0x65, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x0f, 0x2e, 0x63, 0x61, 0x63, 0x68, 0x65, 0x2e, 0x47, 0x65, 0x74, 0x52, - 0x65, 0x70, 0x6c, 0x79, 0x22, 0x00, 0x12, 0x2b, 0x0a, 0x03, 0x53, 0x65, 0x74, 0x12, 0x11, 0x2e, - 0x63, 0x61, 0x63, 0x68, 0x65, 0x2e, 0x53, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x0f, 0x2e, 0x63, 0x61, 0x63, 0x68, 0x65, 0x2e, 0x53, 0x65, 0x74, 0x52, 0x65, 0x70, 0x6c, - 0x79, 0x22, 0x00, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, -} - -var ( - file_cache_proto_rawDescOnce sync.Once - file_cache_proto_rawDescData = file_cache_proto_rawDesc -) - -func file_cache_proto_rawDescGZIP() []byte { - file_cache_proto_rawDescOnce.Do(func() { - file_cache_proto_rawDescData = protoimpl.X.CompressGZIP(file_cache_proto_rawDescData) - }) - return file_cache_proto_rawDescData -} - -var file_cache_proto_msgTypes = make([]protoimpl.MessageInfo, 4) -var file_cache_proto_goTypes = []interface{}{ - (*GetRequest)(nil), // 0: cache.GetRequest - (*GetReply)(nil), // 1: cache.GetReply - (*SetRequest)(nil), // 2: cache.SetRequest - (*SetReply)(nil), // 3: cache.SetReply -} -var file_cache_proto_depIdxs = []int32{ - 0, // 0: cache.Cache.Get:input_type -> cache.GetRequest - 2, // 1: cache.Cache.Set:input_type -> cache.SetRequest - 1, // 2: cache.Cache.Get:output_type -> cache.GetReply - 3, // 3: cache.Cache.Set:output_type -> cache.SetReply - 2, // [2:4] is the sub-list for method output_type - 0, // [0:2] is the sub-list for method input_type - 0, // [0:0] is the sub-list for extension type_name - 0, // [0:0] is the sub-list for extension extendee - 0, // [0:0] is the sub-list for field type_name -} - -func init() { file_cache_proto_init() } -func file_cache_proto_init() { - if File_cache_proto != nil { - return - } - if !protoimpl.UnsafeEnabled { - file_cache_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*GetRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_cache_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*GetReply); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_cache_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*SetRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_cache_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*SetReply); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: file_cache_proto_rawDesc, - NumEnums: 0, - NumMessages: 4, - NumExtensions: 0, - NumServices: 1, - }, - GoTypes: file_cache_proto_goTypes, - DependencyIndexes: file_cache_proto_depIdxs, - MessageInfos: file_cache_proto_msgTypes, - }.Build() - File_cache_proto = out.File - file_cache_proto_rawDesc = nil - file_cache_proto_goTypes = nil - file_cache_proto_depIdxs = nil -} - -// Reference imports to suppress errors if they are not otherwise used. -var _ context.Context -var _ grpc.ClientConnInterface - -// This is a compile-time assertion to ensure that this generated file -// is compatible with the grpc package it is being compiled against. -const _ = grpc.SupportPackageIsVersion6 - -// CacheClient is the client API for Cache service. -// -// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://godoc.org/google.golang.org/grpc#ClientConn.NewStream. -type CacheClient interface { - Get(ctx context.Context, in *GetRequest, opts ...grpc.CallOption) (*GetReply, error) - Set(ctx context.Context, in *SetRequest, opts ...grpc.CallOption) (*SetReply, error) -} - -type cacheClient struct { - cc grpc.ClientConnInterface -} - -func NewCacheClient(cc grpc.ClientConnInterface) CacheClient { - return &cacheClient{cc} -} - -func (c *cacheClient) Get(ctx context.Context, in *GetRequest, opts ...grpc.CallOption) (*GetReply, error) { - out := new(GetReply) - err := c.cc.Invoke(ctx, "/cache.Cache/Get", in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *cacheClient) Set(ctx context.Context, in *SetRequest, opts ...grpc.CallOption) (*SetReply, error) { - out := new(SetReply) - err := c.cc.Invoke(ctx, "/cache.Cache/Set", in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -// CacheServer is the server API for Cache service. -type CacheServer interface { - Get(context.Context, *GetRequest) (*GetReply, error) - Set(context.Context, *SetRequest) (*SetReply, error) -} - -// UnimplementedCacheServer can be embedded to have forward compatible implementations. -type UnimplementedCacheServer struct { -} - -func (*UnimplementedCacheServer) Get(context.Context, *GetRequest) (*GetReply, error) { - return nil, status.Errorf(codes.Unimplemented, "method Get not implemented") -} -func (*UnimplementedCacheServer) Set(context.Context, *SetRequest) (*SetReply, error) { - return nil, status.Errorf(codes.Unimplemented, "method Set not implemented") -} - -func RegisterCacheServer(s *grpc.Server, srv CacheServer) { - s.RegisterService(&_Cache_serviceDesc, srv) -} - -func _Cache_Get_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(GetRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(CacheServer).Get(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: "/cache.Cache/Get", - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(CacheServer).Get(ctx, req.(*GetRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _Cache_Set_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(SetRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(CacheServer).Set(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: "/cache.Cache/Set", - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(CacheServer).Set(ctx, req.(*SetRequest)) - } - return interceptor(ctx, in, info, handler) -} - -var _Cache_serviceDesc = grpc.ServiceDesc{ - ServiceName: "cache.Cache", - HandlerType: (*CacheServer)(nil), - Methods: []grpc.MethodDesc{ - { - MethodName: "Get", - Handler: _Cache_Get_Handler, - }, - { - MethodName: "Set", - Handler: _Cache_Set_Handler, - }, - }, - Streams: []grpc.StreamDesc{}, - Metadata: "cache.proto", -} diff --git a/internal/grpc/cache/cache.proto b/internal/grpc/cache/cache.proto deleted file mode 100644 index c5ebb3670..000000000 --- a/internal/grpc/cache/cache.proto +++ /dev/null @@ -1,20 +0,0 @@ -syntax = "proto3"; - -package cache; - -service Cache { - rpc Get(GetRequest) returns (GetReply) {} - rpc Set(SetRequest) returns (SetReply) {} -} - -message GetRequest { string key = 1; } -message GetReply { - bool exists = 1; - bytes value = 2; -} - -message SetRequest { - string key = 1; - bytes value = 2; -} -message SetReply {} diff --git a/internal/grpc/cache/client/cache_client.go b/internal/grpc/cache/client/cache_client.go deleted file mode 100644 index d1765a65c..000000000 --- a/internal/grpc/cache/client/cache_client.go +++ /dev/null @@ -1,57 +0,0 @@ -// Package client implements a gRPC client for the cache service. -package client - -import ( - "context" - "errors" - - "github.com/pomerium/pomerium/internal/grpc/cache" - "github.com/pomerium/pomerium/internal/telemetry/trace" - - "google.golang.org/grpc" -) - -var errKeyNotFound = errors.New("cache/client: key not found") - -// Client represents a gRPC cache service client. -type Client struct { - conn *grpc.ClientConn - client cache.CacheClient -} - -// New returns a new gRPC cache service client. -func New(conn *grpc.ClientConn) (p *Client) { - return &Client{conn: conn, client: cache.NewCacheClient(conn)} -} - -// Get retrieves a value from the cache service. -func (a *Client) Get(ctx context.Context, key string) (value []byte, err error) { - ctx, span := trace.StartSpan(ctx, "grpc.cache.client.Get") - defer span.End() - - response, err := a.client.Get(ctx, &cache.GetRequest{Key: key}) - if err != nil { - return nil, err - } - if !response.GetExists() { - return nil, errKeyNotFound - } - return response.GetValue(), nil -} - -// Set stores a key value pair in the cache service. -func (a *Client) Set(ctx context.Context, key string, value []byte) error { - ctx, span := trace.StartSpan(ctx, "grpc.cache.client.Set") - defer span.End() - - _, err := a.client.Set(ctx, &cache.SetRequest{Key: key, Value: value}) - if err != nil { - return err - } - return nil -} - -// Close tears down the ClientConn and all underlying connections. -func (a *Client) Close() error { - return a.conn.Close() -} diff --git a/internal/grpc/cache/mock/mock_cacher.go b/internal/grpc/cache/mock/mock_cacher.go deleted file mode 100644 index 08ca944ed..000000000 --- a/internal/grpc/cache/mock/mock_cacher.go +++ /dev/null @@ -1,77 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: github.com/pomerium/pomerium/internal/grpc/cache (interfaces: Cacher) - -// Package mock_cache is a generated GoMock package. -package mock_cache - -import ( - context "context" - gomock "github.com/golang/mock/gomock" - reflect "reflect" -) - -// MockCacher is a mock of Cacher interface -type MockCacher struct { - ctrl *gomock.Controller - recorder *MockCacherMockRecorder -} - -// MockCacherMockRecorder is the mock recorder for MockCacher -type MockCacherMockRecorder struct { - mock *MockCacher -} - -// NewMockCacher creates a new mock instance -func NewMockCacher(ctrl *gomock.Controller) *MockCacher { - mock := &MockCacher{ctrl: ctrl} - mock.recorder = &MockCacherMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use -func (m *MockCacher) EXPECT() *MockCacherMockRecorder { - return m.recorder -} - -// Close mocks base method -func (m *MockCacher) Close() error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Close") - ret0, _ := ret[0].(error) - return ret0 -} - -// Close indicates an expected call of Close -func (mr *MockCacherMockRecorder) Close() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockCacher)(nil).Close)) -} - -// Get mocks base method -func (m *MockCacher) Get(arg0 context.Context, arg1 string) ([]byte, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Get", arg0, arg1) - ret0, _ := ret[0].([]byte) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// Get indicates an expected call of Get -func (mr *MockCacherMockRecorder) Get(arg0, arg1 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockCacher)(nil).Get), arg0, arg1) -} - -// Set mocks base method -func (m *MockCacher) Set(arg0 context.Context, arg1 string, arg2 []byte) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Set", arg0, arg1, arg2) - ret0, _ := ret[0].(error) - return ret0 -} - -// Set indicates an expected call of Set -func (mr *MockCacherMockRecorder) Set(arg0, arg1, arg2 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Set", reflect.TypeOf((*MockCacher)(nil).Set), arg0, arg1, arg2) -} diff --git a/internal/grpc/databroker/databroker.pb.go b/internal/grpc/databroker/databroker.pb.go new file mode 100644 index 000000000..71c549261 --- /dev/null +++ b/internal/grpc/databroker/databroker.pb.go @@ -0,0 +1,1361 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.24.0 +// protoc v3.12.1 +// source: databroker.proto + +package databroker + +import ( + context "context" + proto "github.com/golang/protobuf/proto" + any "github.com/golang/protobuf/ptypes/any" + empty "github.com/golang/protobuf/ptypes/empty" + timestamp "github.com/golang/protobuf/ptypes/timestamp" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// This is a compile-time assertion that a sufficiently up-to-date version +// of the legacy proto package is being used. +const _ = proto.ProtoPackageIsVersion4 + +type Record struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` + Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"` + Id string `protobuf:"bytes,3,opt,name=id,proto3" json:"id,omitempty"` + Data *any.Any `protobuf:"bytes,4,opt,name=data,proto3" json:"data,omitempty"` + CreatedAt *timestamp.Timestamp `protobuf:"bytes,5,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + ModifiedAt *timestamp.Timestamp `protobuf:"bytes,6,opt,name=modified_at,json=modifiedAt,proto3" json:"modified_at,omitempty"` + DeletedAt *timestamp.Timestamp `protobuf:"bytes,7,opt,name=deleted_at,json=deletedAt,proto3" json:"deleted_at,omitempty"` +} + +func (x *Record) Reset() { + *x = Record{} + if protoimpl.UnsafeEnabled { + mi := &file_databroker_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Record) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Record) ProtoMessage() {} + +func (x *Record) ProtoReflect() protoreflect.Message { + mi := &file_databroker_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Record.ProtoReflect.Descriptor instead. +func (*Record) Descriptor() ([]byte, []int) { + return file_databroker_proto_rawDescGZIP(), []int{0} +} + +func (x *Record) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *Record) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *Record) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Record) GetData() *any.Any { + if x != nil { + return x.Data + } + return nil +} + +func (x *Record) GetCreatedAt() *timestamp.Timestamp { + if x != nil { + return x.CreatedAt + } + return nil +} + +func (x *Record) GetModifiedAt() *timestamp.Timestamp { + if x != nil { + return x.ModifiedAt + } + return nil +} + +func (x *Record) GetDeletedAt() *timestamp.Timestamp { + if x != nil { + return x.DeletedAt + } + return nil +} + +type DeleteRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` + Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"` +} + +func (x *DeleteRequest) Reset() { + *x = DeleteRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_databroker_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *DeleteRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteRequest) ProtoMessage() {} + +func (x *DeleteRequest) ProtoReflect() protoreflect.Message { + mi := &file_databroker_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteRequest.ProtoReflect.Descriptor instead. +func (*DeleteRequest) Descriptor() ([]byte, []int) { + return file_databroker_proto_rawDescGZIP(), []int{1} +} + +func (x *DeleteRequest) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *DeleteRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +type GetRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` + Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"` +} + +func (x *GetRequest) Reset() { + *x = GetRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_databroker_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetRequest) ProtoMessage() {} + +func (x *GetRequest) ProtoReflect() protoreflect.Message { + mi := &file_databroker_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetRequest.ProtoReflect.Descriptor instead. +func (*GetRequest) Descriptor() ([]byte, []int) { + return file_databroker_proto_rawDescGZIP(), []int{2} +} + +func (x *GetRequest) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *GetRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +type GetResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Record *Record `protobuf:"bytes,1,opt,name=record,proto3" json:"record,omitempty"` +} + +func (x *GetResponse) Reset() { + *x = GetResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_databroker_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetResponse) ProtoMessage() {} + +func (x *GetResponse) ProtoReflect() protoreflect.Message { + mi := &file_databroker_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetResponse.ProtoReflect.Descriptor instead. +func (*GetResponse) Descriptor() ([]byte, []int) { + return file_databroker_proto_rawDescGZIP(), []int{3} +} + +func (x *GetResponse) GetRecord() *Record { + if x != nil { + return x.Record + } + return nil +} + +type GetAllRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` +} + +func (x *GetAllRequest) Reset() { + *x = GetAllRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_databroker_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetAllRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetAllRequest) ProtoMessage() {} + +func (x *GetAllRequest) ProtoReflect() protoreflect.Message { + mi := &file_databroker_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetAllRequest.ProtoReflect.Descriptor instead. +func (*GetAllRequest) Descriptor() ([]byte, []int) { + return file_databroker_proto_rawDescGZIP(), []int{4} +} + +func (x *GetAllRequest) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +type GetAllResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Records []*Record `protobuf:"bytes,1,rep,name=records,proto3" json:"records,omitempty"` + ServerVersion string `protobuf:"bytes,2,opt,name=server_version,json=serverVersion,proto3" json:"server_version,omitempty"` + RecordVersion string `protobuf:"bytes,3,opt,name=record_version,json=recordVersion,proto3" json:"record_version,omitempty"` +} + +func (x *GetAllResponse) Reset() { + *x = GetAllResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_databroker_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetAllResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetAllResponse) ProtoMessage() {} + +func (x *GetAllResponse) ProtoReflect() protoreflect.Message { + mi := &file_databroker_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetAllResponse.ProtoReflect.Descriptor instead. +func (*GetAllResponse) Descriptor() ([]byte, []int) { + return file_databroker_proto_rawDescGZIP(), []int{5} +} + +func (x *GetAllResponse) GetRecords() []*Record { + if x != nil { + return x.Records + } + return nil +} + +func (x *GetAllResponse) GetServerVersion() string { + if x != nil { + return x.ServerVersion + } + return "" +} + +func (x *GetAllResponse) GetRecordVersion() string { + if x != nil { + return x.RecordVersion + } + return "" +} + +type SetRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` + Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"` + Data *any.Any `protobuf:"bytes,3,opt,name=data,proto3" json:"data,omitempty"` +} + +func (x *SetRequest) Reset() { + *x = SetRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_databroker_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SetRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetRequest) ProtoMessage() {} + +func (x *SetRequest) ProtoReflect() protoreflect.Message { + mi := &file_databroker_proto_msgTypes[6] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetRequest.ProtoReflect.Descriptor instead. +func (*SetRequest) Descriptor() ([]byte, []int) { + return file_databroker_proto_rawDescGZIP(), []int{6} +} + +func (x *SetRequest) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *SetRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *SetRequest) GetData() *any.Any { + if x != nil { + return x.Data + } + return nil +} + +type SetResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Record *Record `protobuf:"bytes,1,opt,name=record,proto3" json:"record,omitempty"` + ServerVersion string `protobuf:"bytes,2,opt,name=server_version,json=serverVersion,proto3" json:"server_version,omitempty"` +} + +func (x *SetResponse) Reset() { + *x = SetResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_databroker_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SetResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetResponse) ProtoMessage() {} + +func (x *SetResponse) ProtoReflect() protoreflect.Message { + mi := &file_databroker_proto_msgTypes[7] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetResponse.ProtoReflect.Descriptor instead. +func (*SetResponse) Descriptor() ([]byte, []int) { + return file_databroker_proto_rawDescGZIP(), []int{7} +} + +func (x *SetResponse) GetRecord() *Record { + if x != nil { + return x.Record + } + return nil +} + +func (x *SetResponse) GetServerVersion() string { + if x != nil { + return x.ServerVersion + } + return "" +} + +type SyncRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ServerVersion string `protobuf:"bytes,1,opt,name=server_version,json=serverVersion,proto3" json:"server_version,omitempty"` + RecordVersion string `protobuf:"bytes,2,opt,name=record_version,json=recordVersion,proto3" json:"record_version,omitempty"` + Type string `protobuf:"bytes,3,opt,name=type,proto3" json:"type,omitempty"` +} + +func (x *SyncRequest) Reset() { + *x = SyncRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_databroker_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SyncRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SyncRequest) ProtoMessage() {} + +func (x *SyncRequest) ProtoReflect() protoreflect.Message { + mi := &file_databroker_proto_msgTypes[8] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SyncRequest.ProtoReflect.Descriptor instead. +func (*SyncRequest) Descriptor() ([]byte, []int) { + return file_databroker_proto_rawDescGZIP(), []int{8} +} + +func (x *SyncRequest) GetServerVersion() string { + if x != nil { + return x.ServerVersion + } + return "" +} + +func (x *SyncRequest) GetRecordVersion() string { + if x != nil { + return x.RecordVersion + } + return "" +} + +func (x *SyncRequest) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +type SyncResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ServerVersion string `protobuf:"bytes,1,opt,name=server_version,json=serverVersion,proto3" json:"server_version,omitempty"` + Records []*Record `protobuf:"bytes,2,rep,name=records,proto3" json:"records,omitempty"` +} + +func (x *SyncResponse) Reset() { + *x = SyncResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_databroker_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SyncResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SyncResponse) ProtoMessage() {} + +func (x *SyncResponse) ProtoReflect() protoreflect.Message { + mi := &file_databroker_proto_msgTypes[9] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SyncResponse.ProtoReflect.Descriptor instead. +func (*SyncResponse) Descriptor() ([]byte, []int) { + return file_databroker_proto_rawDescGZIP(), []int{9} +} + +func (x *SyncResponse) GetServerVersion() string { + if x != nil { + return x.ServerVersion + } + return "" +} + +func (x *SyncResponse) GetRecords() []*Record { + if x != nil { + return x.Records + } + return nil +} + +type GetTypesResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Types []string `protobuf:"bytes,1,rep,name=types,proto3" json:"types,omitempty"` +} + +func (x *GetTypesResponse) Reset() { + *x = GetTypesResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_databroker_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetTypesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetTypesResponse) ProtoMessage() {} + +func (x *GetTypesResponse) ProtoReflect() protoreflect.Message { + mi := &file_databroker_proto_msgTypes[10] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetTypesResponse.ProtoReflect.Descriptor instead. +func (*GetTypesResponse) Descriptor() ([]byte, []int) { + return file_databroker_proto_rawDescGZIP(), []int{10} +} + +func (x *GetTypesResponse) GetTypes() []string { + if x != nil { + return x.Types + } + return nil +} + +var File_databroker_proto protoreflect.FileDescriptor + +var file_databroker_proto_rawDesc = []byte{ + 0x0a, 0x10, 0x64, 0x61, 0x74, 0x61, 0x62, 0x72, 0x6f, 0x6b, 0x65, 0x72, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x12, 0x0a, 0x64, 0x61, 0x74, 0x61, 0x62, 0x72, 0x6f, 0x6b, 0x65, 0x72, 0x1a, 0x19, + 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, + 0x61, 0x6e, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1b, 0x67, 0x6f, 0x6f, 0x67, 0x6c, + 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x65, 0x6d, 0x70, 0x74, 0x79, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, + 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xa3, 0x02, 0x0a, 0x06, 0x52, 0x65, 0x63, 0x6f, + 0x72, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, + 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, + 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, + 0x12, 0x28, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, + 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, + 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, + 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, + 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, + 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, + 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x3b, 0x0a, 0x0b, 0x6d, 0x6f, 0x64, 0x69, 0x66, 0x69, 0x65, + 0x64, 0x5f, 0x61, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, + 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, + 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0a, 0x6d, 0x6f, 0x64, 0x69, 0x66, 0x69, 0x65, 0x64, + 0x41, 0x74, 0x12, 0x39, 0x0a, 0x0a, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, + 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, + 0x6d, 0x70, 0x52, 0x09, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x41, 0x74, 0x22, 0x33, 0x0a, + 0x0d, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, + 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, + 0x70, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, + 0x69, 0x64, 0x22, 0x30, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, + 0x74, 0x79, 0x70, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x02, 0x69, 0x64, 0x22, 0x39, 0x0a, 0x0b, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x2a, 0x0a, 0x06, 0x72, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x64, 0x61, 0x74, 0x61, 0x62, 0x72, 0x6f, 0x6b, 0x65, 0x72, + 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x52, 0x06, 0x72, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x22, + 0x23, 0x0a, 0x0d, 0x47, 0x65, 0x74, 0x41, 0x6c, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, + 0x74, 0x79, 0x70, 0x65, 0x22, 0x8c, 0x01, 0x0a, 0x0e, 0x47, 0x65, 0x74, 0x41, 0x6c, 0x6c, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2c, 0x0a, 0x07, 0x72, 0x65, 0x63, 0x6f, 0x72, + 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x64, 0x61, 0x74, 0x61, 0x62, + 0x72, 0x6f, 0x6b, 0x65, 0x72, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x52, 0x07, 0x72, 0x65, + 0x63, 0x6f, 0x72, 0x64, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, + 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x73, + 0x65, 0x72, 0x76, 0x65, 0x72, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x25, 0x0a, 0x0e, + 0x72, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x72, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x56, 0x65, 0x72, 0x73, + 0x69, 0x6f, 0x6e, 0x22, 0x5a, 0x0a, 0x0a, 0x53, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x28, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x22, + 0x60, 0x0a, 0x0b, 0x53, 0x65, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2a, + 0x0a, 0x06, 0x72, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, + 0x2e, 0x64, 0x61, 0x74, 0x61, 0x62, 0x72, 0x6f, 0x6b, 0x65, 0x72, 0x2e, 0x52, 0x65, 0x63, 0x6f, + 0x72, 0x64, 0x52, 0x06, 0x72, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x73, 0x65, + 0x72, 0x76, 0x65, 0x72, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0d, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, + 0x6e, 0x22, 0x6f, 0x0a, 0x0b, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x25, 0x0a, 0x0e, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, + 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, + 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x25, 0x0a, 0x0e, 0x72, 0x65, 0x63, 0x6f, 0x72, + 0x64, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0d, 0x72, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x12, + 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, + 0x70, 0x65, 0x22, 0x63, 0x0a, 0x0c, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x25, 0x0a, 0x0e, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x76, 0x65, 0x72, + 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x73, 0x65, 0x72, 0x76, + 0x65, 0x72, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x2c, 0x0a, 0x07, 0x72, 0x65, 0x63, + 0x6f, 0x72, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x64, 0x61, 0x74, + 0x61, 0x62, 0x72, 0x6f, 0x6b, 0x65, 0x72, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x52, 0x07, + 0x72, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x22, 0x28, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x54, 0x79, + 0x70, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x74, + 0x79, 0x70, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x74, 0x79, 0x70, 0x65, + 0x73, 0x32, 0xc5, 0x03, 0x0a, 0x11, 0x44, 0x61, 0x74, 0x61, 0x42, 0x72, 0x6f, 0x6b, 0x65, 0x72, + 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x3b, 0x0a, 0x06, 0x44, 0x65, 0x6c, 0x65, 0x74, + 0x65, 0x12, 0x19, 0x2e, 0x64, 0x61, 0x74, 0x61, 0x62, 0x72, 0x6f, 0x6b, 0x65, 0x72, 0x2e, 0x44, + 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, + 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, + 0x6d, 0x70, 0x74, 0x79, 0x12, 0x36, 0x0a, 0x03, 0x47, 0x65, 0x74, 0x12, 0x16, 0x2e, 0x64, 0x61, + 0x74, 0x61, 0x62, 0x72, 0x6f, 0x6b, 0x65, 0x72, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x17, 0x2e, 0x64, 0x61, 0x74, 0x61, 0x62, 0x72, 0x6f, 0x6b, 0x65, 0x72, + 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3f, 0x0a, 0x06, + 0x47, 0x65, 0x74, 0x41, 0x6c, 0x6c, 0x12, 0x19, 0x2e, 0x64, 0x61, 0x74, 0x61, 0x62, 0x72, 0x6f, + 0x6b, 0x65, 0x72, 0x2e, 0x47, 0x65, 0x74, 0x41, 0x6c, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x1a, 0x2e, 0x64, 0x61, 0x74, 0x61, 0x62, 0x72, 0x6f, 0x6b, 0x65, 0x72, 0x2e, 0x47, + 0x65, 0x74, 0x41, 0x6c, 0x6c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x36, 0x0a, + 0x03, 0x53, 0x65, 0x74, 0x12, 0x16, 0x2e, 0x64, 0x61, 0x74, 0x61, 0x62, 0x72, 0x6f, 0x6b, 0x65, + 0x72, 0x2e, 0x53, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x17, 0x2e, 0x64, + 0x61, 0x74, 0x61, 0x62, 0x72, 0x6f, 0x6b, 0x65, 0x72, 0x2e, 0x53, 0x65, 0x74, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x04, 0x53, 0x79, 0x6e, 0x63, 0x12, 0x17, 0x2e, + 0x64, 0x61, 0x74, 0x61, 0x62, 0x72, 0x6f, 0x6b, 0x65, 0x72, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x64, 0x61, 0x74, 0x61, 0x62, 0x72, 0x6f, + 0x6b, 0x65, 0x72, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x30, 0x01, 0x12, 0x40, 0x0a, 0x08, 0x47, 0x65, 0x74, 0x54, 0x79, 0x70, 0x65, 0x73, 0x12, 0x16, + 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, + 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1c, 0x2e, 0x64, 0x61, 0x74, 0x61, 0x62, 0x72, 0x6f, + 0x6b, 0x65, 0x72, 0x2e, 0x47, 0x65, 0x74, 0x54, 0x79, 0x70, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x09, 0x53, 0x79, 0x6e, 0x63, 0x54, 0x79, 0x70, 0x65, + 0x73, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1c, 0x2e, 0x64, 0x61, 0x74, 0x61, + 0x62, 0x72, 0x6f, 0x6b, 0x65, 0x72, 0x2e, 0x47, 0x65, 0x74, 0x54, 0x79, 0x70, 0x65, 0x73, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x30, 0x01, 0x42, 0x37, 0x5a, 0x35, 0x67, 0x69, 0x74, + 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x6f, 0x6d, 0x65, 0x72, 0x69, 0x75, 0x6d, + 0x2f, 0x70, 0x6f, 0x6d, 0x65, 0x72, 0x69, 0x75, 0x6d, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, + 0x61, 0x6c, 0x2f, 0x67, 0x72, 0x70, 0x63, 0x2f, 0x64, 0x61, 0x74, 0x61, 0x62, 0x72, 0x6f, 0x6b, + 0x65, 0x72, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_databroker_proto_rawDescOnce sync.Once + file_databroker_proto_rawDescData = file_databroker_proto_rawDesc +) + +func file_databroker_proto_rawDescGZIP() []byte { + file_databroker_proto_rawDescOnce.Do(func() { + file_databroker_proto_rawDescData = protoimpl.X.CompressGZIP(file_databroker_proto_rawDescData) + }) + return file_databroker_proto_rawDescData +} + +var file_databroker_proto_msgTypes = make([]protoimpl.MessageInfo, 11) +var file_databroker_proto_goTypes = []interface{}{ + (*Record)(nil), // 0: databroker.Record + (*DeleteRequest)(nil), // 1: databroker.DeleteRequest + (*GetRequest)(nil), // 2: databroker.GetRequest + (*GetResponse)(nil), // 3: databroker.GetResponse + (*GetAllRequest)(nil), // 4: databroker.GetAllRequest + (*GetAllResponse)(nil), // 5: databroker.GetAllResponse + (*SetRequest)(nil), // 6: databroker.SetRequest + (*SetResponse)(nil), // 7: databroker.SetResponse + (*SyncRequest)(nil), // 8: databroker.SyncRequest + (*SyncResponse)(nil), // 9: databroker.SyncResponse + (*GetTypesResponse)(nil), // 10: databroker.GetTypesResponse + (*any.Any)(nil), // 11: google.protobuf.Any + (*timestamp.Timestamp)(nil), // 12: google.protobuf.Timestamp + (*empty.Empty)(nil), // 13: google.protobuf.Empty +} +var file_databroker_proto_depIdxs = []int32{ + 11, // 0: databroker.Record.data:type_name -> google.protobuf.Any + 12, // 1: databroker.Record.created_at:type_name -> google.protobuf.Timestamp + 12, // 2: databroker.Record.modified_at:type_name -> google.protobuf.Timestamp + 12, // 3: databroker.Record.deleted_at:type_name -> google.protobuf.Timestamp + 0, // 4: databroker.GetResponse.record:type_name -> databroker.Record + 0, // 5: databroker.GetAllResponse.records:type_name -> databroker.Record + 11, // 6: databroker.SetRequest.data:type_name -> google.protobuf.Any + 0, // 7: databroker.SetResponse.record:type_name -> databroker.Record + 0, // 8: databroker.SyncResponse.records:type_name -> databroker.Record + 1, // 9: databroker.DataBrokerService.Delete:input_type -> databroker.DeleteRequest + 2, // 10: databroker.DataBrokerService.Get:input_type -> databroker.GetRequest + 4, // 11: databroker.DataBrokerService.GetAll:input_type -> databroker.GetAllRequest + 6, // 12: databroker.DataBrokerService.Set:input_type -> databroker.SetRequest + 8, // 13: databroker.DataBrokerService.Sync:input_type -> databroker.SyncRequest + 13, // 14: databroker.DataBrokerService.GetTypes:input_type -> google.protobuf.Empty + 13, // 15: databroker.DataBrokerService.SyncTypes:input_type -> google.protobuf.Empty + 13, // 16: databroker.DataBrokerService.Delete:output_type -> google.protobuf.Empty + 3, // 17: databroker.DataBrokerService.Get:output_type -> databroker.GetResponse + 5, // 18: databroker.DataBrokerService.GetAll:output_type -> databroker.GetAllResponse + 7, // 19: databroker.DataBrokerService.Set:output_type -> databroker.SetResponse + 9, // 20: databroker.DataBrokerService.Sync:output_type -> databroker.SyncResponse + 10, // 21: databroker.DataBrokerService.GetTypes:output_type -> databroker.GetTypesResponse + 10, // 22: databroker.DataBrokerService.SyncTypes:output_type -> databroker.GetTypesResponse + 16, // [16:23] is the sub-list for method output_type + 9, // [9:16] is the sub-list for method input_type + 9, // [9:9] is the sub-list for extension type_name + 9, // [9:9] is the sub-list for extension extendee + 0, // [0:9] is the sub-list for field type_name +} + +func init() { file_databroker_proto_init() } +func file_databroker_proto_init() { + if File_databroker_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_databroker_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Record); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_databroker_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*DeleteRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_databroker_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_databroker_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_databroker_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetAllRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_databroker_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetAllResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_databroker_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*SetRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_databroker_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*SetResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_databroker_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*SyncRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_databroker_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*SyncResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_databroker_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetTypesResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_databroker_proto_rawDesc, + NumEnums: 0, + NumMessages: 11, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_databroker_proto_goTypes, + DependencyIndexes: file_databroker_proto_depIdxs, + MessageInfos: file_databroker_proto_msgTypes, + }.Build() + File_databroker_proto = out.File + file_databroker_proto_rawDesc = nil + file_databroker_proto_goTypes = nil + file_databroker_proto_depIdxs = nil +} + +// Reference imports to suppress errors if they are not otherwise used. +var _ context.Context +var _ grpc.ClientConnInterface + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +const _ = grpc.SupportPackageIsVersion6 + +// DataBrokerServiceClient is the client API for DataBrokerService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://godoc.org/google.golang.org/grpc#ClientConn.NewStream. +type DataBrokerServiceClient interface { + Delete(ctx context.Context, in *DeleteRequest, opts ...grpc.CallOption) (*empty.Empty, error) + Get(ctx context.Context, in *GetRequest, opts ...grpc.CallOption) (*GetResponse, error) + GetAll(ctx context.Context, in *GetAllRequest, opts ...grpc.CallOption) (*GetAllResponse, error) + Set(ctx context.Context, in *SetRequest, opts ...grpc.CallOption) (*SetResponse, error) + Sync(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (DataBrokerService_SyncClient, error) + GetTypes(ctx context.Context, in *empty.Empty, opts ...grpc.CallOption) (*GetTypesResponse, error) + SyncTypes(ctx context.Context, in *empty.Empty, opts ...grpc.CallOption) (DataBrokerService_SyncTypesClient, error) +} + +type dataBrokerServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewDataBrokerServiceClient(cc grpc.ClientConnInterface) DataBrokerServiceClient { + return &dataBrokerServiceClient{cc} +} + +func (c *dataBrokerServiceClient) Delete(ctx context.Context, in *DeleteRequest, opts ...grpc.CallOption) (*empty.Empty, error) { + out := new(empty.Empty) + err := c.cc.Invoke(ctx, "/databroker.DataBrokerService/Delete", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *dataBrokerServiceClient) Get(ctx context.Context, in *GetRequest, opts ...grpc.CallOption) (*GetResponse, error) { + out := new(GetResponse) + err := c.cc.Invoke(ctx, "/databroker.DataBrokerService/Get", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *dataBrokerServiceClient) GetAll(ctx context.Context, in *GetAllRequest, opts ...grpc.CallOption) (*GetAllResponse, error) { + out := new(GetAllResponse) + err := c.cc.Invoke(ctx, "/databroker.DataBrokerService/GetAll", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *dataBrokerServiceClient) Set(ctx context.Context, in *SetRequest, opts ...grpc.CallOption) (*SetResponse, error) { + out := new(SetResponse) + err := c.cc.Invoke(ctx, "/databroker.DataBrokerService/Set", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *dataBrokerServiceClient) Sync(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (DataBrokerService_SyncClient, error) { + stream, err := c.cc.NewStream(ctx, &_DataBrokerService_serviceDesc.Streams[0], "/databroker.DataBrokerService/Sync", opts...) + if err != nil { + return nil, err + } + x := &dataBrokerServiceSyncClient{stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +type DataBrokerService_SyncClient interface { + Recv() (*SyncResponse, error) + grpc.ClientStream +} + +type dataBrokerServiceSyncClient struct { + grpc.ClientStream +} + +func (x *dataBrokerServiceSyncClient) Recv() (*SyncResponse, error) { + m := new(SyncResponse) + if err := x.ClientStream.RecvMsg(m); err != nil { + return nil, err + } + return m, nil +} + +func (c *dataBrokerServiceClient) GetTypes(ctx context.Context, in *empty.Empty, opts ...grpc.CallOption) (*GetTypesResponse, error) { + out := new(GetTypesResponse) + err := c.cc.Invoke(ctx, "/databroker.DataBrokerService/GetTypes", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *dataBrokerServiceClient) SyncTypes(ctx context.Context, in *empty.Empty, opts ...grpc.CallOption) (DataBrokerService_SyncTypesClient, error) { + stream, err := c.cc.NewStream(ctx, &_DataBrokerService_serviceDesc.Streams[1], "/databroker.DataBrokerService/SyncTypes", opts...) + if err != nil { + return nil, err + } + x := &dataBrokerServiceSyncTypesClient{stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +type DataBrokerService_SyncTypesClient interface { + Recv() (*GetTypesResponse, error) + grpc.ClientStream +} + +type dataBrokerServiceSyncTypesClient struct { + grpc.ClientStream +} + +func (x *dataBrokerServiceSyncTypesClient) Recv() (*GetTypesResponse, error) { + m := new(GetTypesResponse) + if err := x.ClientStream.RecvMsg(m); err != nil { + return nil, err + } + return m, nil +} + +// DataBrokerServiceServer is the server API for DataBrokerService service. +type DataBrokerServiceServer interface { + Delete(context.Context, *DeleteRequest) (*empty.Empty, error) + Get(context.Context, *GetRequest) (*GetResponse, error) + GetAll(context.Context, *GetAllRequest) (*GetAllResponse, error) + Set(context.Context, *SetRequest) (*SetResponse, error) + Sync(*SyncRequest, DataBrokerService_SyncServer) error + GetTypes(context.Context, *empty.Empty) (*GetTypesResponse, error) + SyncTypes(*empty.Empty, DataBrokerService_SyncTypesServer) error +} + +// UnimplementedDataBrokerServiceServer can be embedded to have forward compatible implementations. +type UnimplementedDataBrokerServiceServer struct { +} + +func (*UnimplementedDataBrokerServiceServer) Delete(context.Context, *DeleteRequest) (*empty.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method Delete not implemented") +} +func (*UnimplementedDataBrokerServiceServer) Get(context.Context, *GetRequest) (*GetResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Get not implemented") +} +func (*UnimplementedDataBrokerServiceServer) GetAll(context.Context, *GetAllRequest) (*GetAllResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetAll not implemented") +} +func (*UnimplementedDataBrokerServiceServer) Set(context.Context, *SetRequest) (*SetResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Set not implemented") +} +func (*UnimplementedDataBrokerServiceServer) Sync(*SyncRequest, DataBrokerService_SyncServer) error { + return status.Errorf(codes.Unimplemented, "method Sync not implemented") +} +func (*UnimplementedDataBrokerServiceServer) GetTypes(context.Context, *empty.Empty) (*GetTypesResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetTypes not implemented") +} +func (*UnimplementedDataBrokerServiceServer) SyncTypes(*empty.Empty, DataBrokerService_SyncTypesServer) error { + return status.Errorf(codes.Unimplemented, "method SyncTypes not implemented") +} + +func RegisterDataBrokerServiceServer(s *grpc.Server, srv DataBrokerServiceServer) { + s.RegisterService(&_DataBrokerService_serviceDesc, srv) +} + +func _DataBrokerService_Delete_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DeleteRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DataBrokerServiceServer).Delete(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/databroker.DataBrokerService/Delete", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DataBrokerServiceServer).Delete(ctx, req.(*DeleteRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DataBrokerService_Get_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DataBrokerServiceServer).Get(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/databroker.DataBrokerService/Get", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DataBrokerServiceServer).Get(ctx, req.(*GetRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DataBrokerService_GetAll_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetAllRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DataBrokerServiceServer).GetAll(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/databroker.DataBrokerService/GetAll", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DataBrokerServiceServer).GetAll(ctx, req.(*GetAllRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DataBrokerService_Set_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SetRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DataBrokerServiceServer).Set(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/databroker.DataBrokerService/Set", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DataBrokerServiceServer).Set(ctx, req.(*SetRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DataBrokerService_Sync_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(SyncRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(DataBrokerServiceServer).Sync(m, &dataBrokerServiceSyncServer{stream}) +} + +type DataBrokerService_SyncServer interface { + Send(*SyncResponse) error + grpc.ServerStream +} + +type dataBrokerServiceSyncServer struct { + grpc.ServerStream +} + +func (x *dataBrokerServiceSyncServer) Send(m *SyncResponse) error { + return x.ServerStream.SendMsg(m) +} + +func _DataBrokerService_GetTypes_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(empty.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DataBrokerServiceServer).GetTypes(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/databroker.DataBrokerService/GetTypes", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DataBrokerServiceServer).GetTypes(ctx, req.(*empty.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _DataBrokerService_SyncTypes_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(empty.Empty) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(DataBrokerServiceServer).SyncTypes(m, &dataBrokerServiceSyncTypesServer{stream}) +} + +type DataBrokerService_SyncTypesServer interface { + Send(*GetTypesResponse) error + grpc.ServerStream +} + +type dataBrokerServiceSyncTypesServer struct { + grpc.ServerStream +} + +func (x *dataBrokerServiceSyncTypesServer) Send(m *GetTypesResponse) error { + return x.ServerStream.SendMsg(m) +} + +var _DataBrokerService_serviceDesc = grpc.ServiceDesc{ + ServiceName: "databroker.DataBrokerService", + HandlerType: (*DataBrokerServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Delete", + Handler: _DataBrokerService_Delete_Handler, + }, + { + MethodName: "Get", + Handler: _DataBrokerService_Get_Handler, + }, + { + MethodName: "GetAll", + Handler: _DataBrokerService_GetAll_Handler, + }, + { + MethodName: "Set", + Handler: _DataBrokerService_Set_Handler, + }, + { + MethodName: "GetTypes", + Handler: _DataBrokerService_GetTypes_Handler, + }, + }, + Streams: []grpc.StreamDesc{ + { + StreamName: "Sync", + Handler: _DataBrokerService_Sync_Handler, + ServerStreams: true, + }, + { + StreamName: "SyncTypes", + Handler: _DataBrokerService_SyncTypes_Handler, + ServerStreams: true, + }, + }, + Metadata: "databroker.proto", +} diff --git a/internal/grpc/databroker/databroker.proto b/internal/grpc/databroker/databroker.proto new file mode 100644 index 000000000..8f14854e3 --- /dev/null +++ b/internal/grpc/databroker/databroker.proto @@ -0,0 +1,69 @@ +syntax = "proto3"; + +package databroker; +option go_package = "github.com/pomerium/pomerium/internal/grpc/databroker"; + +import "google/protobuf/any.proto"; +import "google/protobuf/empty.proto"; +import "google/protobuf/timestamp.proto"; + +message Record { + string version = 1; + string type = 2; + string id = 3; + google.protobuf.Any data = 4; + google.protobuf.Timestamp created_at = 5; + google.protobuf.Timestamp modified_at = 6; + google.protobuf.Timestamp deleted_at = 7; +} + +message DeleteRequest { + string type = 1; + string id = 2; +} + +message GetRequest { + string type = 1; + string id = 2; +} +message GetResponse { Record record = 1; } + +message GetAllRequest { string type = 1; } +message GetAllResponse { + repeated Record records = 1; + string server_version = 2; + string record_version = 3; +} + +message SetRequest { + string type = 1; + string id = 2; + google.protobuf.Any data = 3; +} +message SetResponse { + Record record = 1; + string server_version = 2; +} + +message SyncRequest { + string server_version = 1; + string record_version = 2; + string type = 3; +} +message SyncResponse { + string server_version = 1; + repeated Record records = 2; +} + +message GetTypesResponse { repeated string types = 1; } + +service DataBrokerService { + rpc Delete(DeleteRequest) returns (google.protobuf.Empty); + rpc Get(GetRequest) returns (GetResponse); + rpc GetAll(GetAllRequest) returns (GetAllResponse); + rpc Set(SetRequest) returns (SetResponse); + rpc Sync(SyncRequest) returns (stream SyncResponse); + + rpc GetTypes(google.protobuf.Empty) returns (GetTypesResponse); + rpc SyncTypes(google.protobuf.Empty) returns (stream GetTypesResponse); +} diff --git a/internal/grpc/directory/directory.go b/internal/grpc/directory/directory.go new file mode 100644 index 000000000..4e1f01896 --- /dev/null +++ b/internal/grpc/directory/directory.go @@ -0,0 +1,30 @@ +// Package directory contains protobuf types for directory users. +package directory + +import ( + context "context" + + "github.com/golang/protobuf/ptypes" + + "github.com/pomerium/pomerium/internal/grpc/databroker" +) + +// Get gets a directory user from the databroker. +func Get(ctx context.Context, client databroker.DataBrokerServiceClient, userID string) (*User, error) { + any, _ := ptypes.MarshalAny(new(User)) + + res, err := client.Get(ctx, &databroker.GetRequest{ + Type: any.GetTypeUrl(), + Id: userID, + }) + if err != nil { + return nil, err + } + + var u User + err = ptypes.UnmarshalAny(res.GetRecord().GetData(), &u) + if err != nil { + return nil, err + } + return &u, nil +} diff --git a/internal/grpc/directory/directory.pb.go b/internal/grpc/directory/directory.pb.go new file mode 100644 index 000000000..351e59f41 --- /dev/null +++ b/internal/grpc/directory/directory.pb.go @@ -0,0 +1,168 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.24.0 +// protoc v3.12.1 +// source: directory.proto + +package directory + +import ( + proto "github.com/golang/protobuf/proto" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// This is a compile-time assertion that a sufficiently up-to-date version +// of the legacy proto package is being used. +const _ = proto.ProtoPackageIsVersion4 + +type User struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` + Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"` + Groups []string `protobuf:"bytes,3,rep,name=groups,proto3" json:"groups,omitempty"` +} + +func (x *User) Reset() { + *x = User{} + if protoimpl.UnsafeEnabled { + mi := &file_directory_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *User) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*User) ProtoMessage() {} + +func (x *User) ProtoReflect() protoreflect.Message { + mi := &file_directory_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use User.ProtoReflect.Descriptor instead. +func (*User) Descriptor() ([]byte, []int) { + return file_directory_proto_rawDescGZIP(), []int{0} +} + +func (x *User) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *User) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *User) GetGroups() []string { + if x != nil { + return x.Groups + } + return nil +} + +var File_directory_proto protoreflect.FileDescriptor + +var file_directory_proto_rawDesc = []byte{ + 0x0a, 0x0f, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x12, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x22, 0x48, 0x0a, 0x04, + 0x55, 0x73, 0x65, 0x72, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x0e, + 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x16, + 0x0a, 0x06, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x06, + 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x42, 0x36, 0x5a, 0x34, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, + 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x6f, 0x6d, 0x65, 0x72, 0x69, 0x75, 0x6d, 0x2f, 0x70, 0x6f, + 0x6d, 0x65, 0x72, 0x69, 0x75, 0x6d, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, + 0x67, 0x72, 0x70, 0x63, 0x2f, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x62, 0x06, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_directory_proto_rawDescOnce sync.Once + file_directory_proto_rawDescData = file_directory_proto_rawDesc +) + +func file_directory_proto_rawDescGZIP() []byte { + file_directory_proto_rawDescOnce.Do(func() { + file_directory_proto_rawDescData = protoimpl.X.CompressGZIP(file_directory_proto_rawDescData) + }) + return file_directory_proto_rawDescData +} + +var file_directory_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_directory_proto_goTypes = []interface{}{ + (*User)(nil), // 0: directory.User +} +var file_directory_proto_depIdxs = []int32{ + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_directory_proto_init() } +func file_directory_proto_init() { + if File_directory_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_directory_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*User); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_directory_proto_rawDesc, + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_directory_proto_goTypes, + DependencyIndexes: file_directory_proto_depIdxs, + MessageInfos: file_directory_proto_msgTypes, + }.Build() + File_directory_proto = out.File + file_directory_proto_rawDesc = nil + file_directory_proto_goTypes = nil + file_directory_proto_depIdxs = nil +} diff --git a/internal/grpc/directory/directory.proto b/internal/grpc/directory/directory.proto new file mode 100644 index 000000000..2a7d7c4b6 --- /dev/null +++ b/internal/grpc/directory/directory.proto @@ -0,0 +1,10 @@ +syntax = "proto3"; + +package directory; +option go_package = "github.com/pomerium/pomerium/internal/grpc/directory"; + +message User { + string version = 1; + string id = 2; + repeated string groups = 3; +} diff --git a/internal/grpc/grpc.go b/internal/grpc/grpc.go new file mode 100644 index 000000000..75f7237ca --- /dev/null +++ b/internal/grpc/grpc.go @@ -0,0 +1,6 @@ +package grpc + +//go:generate ../../scripts/protoc -I ./session/ --go_out=plugins=grpc:$GOPATH/src ./session/session.proto +//go:generate ../../scripts/protoc -I ./user/ --go_out=plugins=grpc:$GOPATH/src ./user/user.proto +//go:generate ../../scripts/protoc -I ./databroker/ --go_out=plugins=grpc:$GOPATH/src ./databroker/databroker.proto +//go:generate ../../scripts/protoc -I ./directory/ --go_out=plugins=grpc:$GOPATH/src ./directory/directory.proto diff --git a/internal/grpc/session/session.go b/internal/grpc/session/session.go new file mode 100644 index 000000000..d8e548938 --- /dev/null +++ b/internal/grpc/session/session.go @@ -0,0 +1,30 @@ +// Package session contains protobuf types for sessions. +package session + +import ( + context "context" + + "github.com/golang/protobuf/ptypes" + + "github.com/pomerium/pomerium/internal/grpc/databroker" +) + +// Get gets a session from the databroker. +func Get(ctx context.Context, client databroker.DataBrokerServiceClient, sessionID string) (*Session, error) { + any, _ := ptypes.MarshalAny(new(Session)) + + res, err := client.Get(ctx, &databroker.GetRequest{ + Type: any.GetTypeUrl(), + Id: sessionID, + }) + if err != nil { + return nil, err + } + + var s Session + err = ptypes.UnmarshalAny(res.GetRecord().GetData(), &s) + if err != nil { + return nil, err + } + return &s, nil +} diff --git a/internal/grpc/session/session.pb.go b/internal/grpc/session/session.pb.go new file mode 100644 index 000000000..77d3e11c9 --- /dev/null +++ b/internal/grpc/session/session.pb.go @@ -0,0 +1,775 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.24.0 +// protoc v3.12.1 +// source: session.proto + +package session + +import ( + context "context" + proto "github.com/golang/protobuf/proto" + any "github.com/golang/protobuf/ptypes/any" + empty "github.com/golang/protobuf/ptypes/empty" + timestamp "github.com/golang/protobuf/ptypes/timestamp" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// This is a compile-time assertion that a sufficiently up-to-date version +// of the legacy proto package is being used. +const _ = proto.ProtoPackageIsVersion4 + +type IDToken struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Issuer string `protobuf:"bytes,1,opt,name=issuer,proto3" json:"issuer,omitempty"` + Subject string `protobuf:"bytes,2,opt,name=subject,proto3" json:"subject,omitempty"` + ExpiresAt *timestamp.Timestamp `protobuf:"bytes,3,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` + IssuedAt *timestamp.Timestamp `protobuf:"bytes,4,opt,name=issued_at,json=issuedAt,proto3" json:"issued_at,omitempty"` +} + +func (x *IDToken) Reset() { + *x = IDToken{} + if protoimpl.UnsafeEnabled { + mi := &file_session_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *IDToken) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IDToken) ProtoMessage() {} + +func (x *IDToken) ProtoReflect() protoreflect.Message { + mi := &file_session_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IDToken.ProtoReflect.Descriptor instead. +func (*IDToken) Descriptor() ([]byte, []int) { + return file_session_proto_rawDescGZIP(), []int{0} +} + +func (x *IDToken) GetIssuer() string { + if x != nil { + return x.Issuer + } + return "" +} + +func (x *IDToken) GetSubject() string { + if x != nil { + return x.Subject + } + return "" +} + +func (x *IDToken) GetExpiresAt() *timestamp.Timestamp { + if x != nil { + return x.ExpiresAt + } + return nil +} + +func (x *IDToken) GetIssuedAt() *timestamp.Timestamp { + if x != nil { + return x.IssuedAt + } + return nil +} + +type OAuthToken struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + AccessToken string `protobuf:"bytes,1,opt,name=access_token,json=accessToken,proto3" json:"access_token,omitempty"` + TokenType string `protobuf:"bytes,2,opt,name=token_type,json=tokenType,proto3" json:"token_type,omitempty"` + ExpiresAt *timestamp.Timestamp `protobuf:"bytes,3,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` + RefreshToken string `protobuf:"bytes,4,opt,name=refresh_token,json=refreshToken,proto3" json:"refresh_token,omitempty"` +} + +func (x *OAuthToken) Reset() { + *x = OAuthToken{} + if protoimpl.UnsafeEnabled { + mi := &file_session_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *OAuthToken) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OAuthToken) ProtoMessage() {} + +func (x *OAuthToken) ProtoReflect() protoreflect.Message { + mi := &file_session_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OAuthToken.ProtoReflect.Descriptor instead. +func (*OAuthToken) Descriptor() ([]byte, []int) { + return file_session_proto_rawDescGZIP(), []int{1} +} + +func (x *OAuthToken) GetAccessToken() string { + if x != nil { + return x.AccessToken + } + return "" +} + +func (x *OAuthToken) GetTokenType() string { + if x != nil { + return x.TokenType + } + return "" +} + +func (x *OAuthToken) GetExpiresAt() *timestamp.Timestamp { + if x != nil { + return x.ExpiresAt + } + return nil +} + +func (x *OAuthToken) GetRefreshToken() string { + if x != nil { + return x.RefreshToken + } + return "" +} + +type Session struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` + Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"` + UserId string `protobuf:"bytes,3,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + ExpiresAt *timestamp.Timestamp `protobuf:"bytes,4,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` + DeletedAt *timestamp.Timestamp `protobuf:"bytes,5,opt,name=deleted_at,json=deletedAt,proto3" json:"deleted_at,omitempty"` + IdToken *IDToken `protobuf:"bytes,6,opt,name=id_token,json=idToken,proto3" json:"id_token,omitempty"` + OauthToken *OAuthToken `protobuf:"bytes,7,opt,name=oauth_token,json=oauthToken,proto3" json:"oauth_token,omitempty"` + Claims map[string]*any.Any `protobuf:"bytes,8,rep,name=claims,proto3" json:"claims,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` +} + +func (x *Session) Reset() { + *x = Session{} + if protoimpl.UnsafeEnabled { + mi := &file_session_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Session) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Session) ProtoMessage() {} + +func (x *Session) ProtoReflect() protoreflect.Message { + mi := &file_session_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Session.ProtoReflect.Descriptor instead. +func (*Session) Descriptor() ([]byte, []int) { + return file_session_proto_rawDescGZIP(), []int{2} +} + +func (x *Session) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *Session) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Session) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *Session) GetExpiresAt() *timestamp.Timestamp { + if x != nil { + return x.ExpiresAt + } + return nil +} + +func (x *Session) GetDeletedAt() *timestamp.Timestamp { + if x != nil { + return x.DeletedAt + } + return nil +} + +func (x *Session) GetIdToken() *IDToken { + if x != nil { + return x.IdToken + } + return nil +} + +func (x *Session) GetOauthToken() *OAuthToken { + if x != nil { + return x.OauthToken + } + return nil +} + +func (x *Session) GetClaims() map[string]*any.Any { + if x != nil { + return x.Claims + } + return nil +} + +type AddRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Session *Session `protobuf:"bytes,1,opt,name=session,proto3" json:"session,omitempty"` +} + +func (x *AddRequest) Reset() { + *x = AddRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_session_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AddRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddRequest) ProtoMessage() {} + +func (x *AddRequest) ProtoReflect() protoreflect.Message { + mi := &file_session_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AddRequest.ProtoReflect.Descriptor instead. +func (*AddRequest) Descriptor() ([]byte, []int) { + return file_session_proto_rawDescGZIP(), []int{3} +} + +func (x *AddRequest) GetSession() *Session { + if x != nil { + return x.Session + } + return nil +} + +type AddResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Session *Session `protobuf:"bytes,1,opt,name=session,proto3" json:"session,omitempty"` + ServerVersion string `protobuf:"bytes,2,opt,name=server_version,json=serverVersion,proto3" json:"server_version,omitempty"` +} + +func (x *AddResponse) Reset() { + *x = AddResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_session_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AddResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddResponse) ProtoMessage() {} + +func (x *AddResponse) ProtoReflect() protoreflect.Message { + mi := &file_session_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AddResponse.ProtoReflect.Descriptor instead. +func (*AddResponse) Descriptor() ([]byte, []int) { + return file_session_proto_rawDescGZIP(), []int{4} +} + +func (x *AddResponse) GetSession() *Session { + if x != nil { + return x.Session + } + return nil +} + +func (x *AddResponse) GetServerVersion() string { + if x != nil { + return x.ServerVersion + } + return "" +} + +type DeleteRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` +} + +func (x *DeleteRequest) Reset() { + *x = DeleteRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_session_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *DeleteRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteRequest) ProtoMessage() {} + +func (x *DeleteRequest) ProtoReflect() protoreflect.Message { + mi := &file_session_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteRequest.ProtoReflect.Descriptor instead. +func (*DeleteRequest) Descriptor() ([]byte, []int) { + return file_session_proto_rawDescGZIP(), []int{5} +} + +func (x *DeleteRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +var File_session_proto protoreflect.FileDescriptor + +var file_session_proto_rawDesc = []byte{ + 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, + 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x1a, 0x19, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, + 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x61, 0x6e, 0x79, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x1a, 0x1b, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x65, 0x6d, 0x70, 0x74, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, + 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x22, 0xaf, 0x01, 0x0a, 0x07, 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x16, 0x0a, + 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x69, + 0x73, 0x73, 0x75, 0x65, 0x72, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x12, + 0x39, 0x0a, 0x0a, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x5f, 0x61, 0x74, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, + 0x09, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x12, 0x37, 0x0a, 0x09, 0x69, 0x73, + 0x73, 0x75, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, + 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, + 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x08, 0x69, 0x73, 0x73, 0x75, 0x65, + 0x64, 0x41, 0x74, 0x22, 0xae, 0x01, 0x0a, 0x0a, 0x4f, 0x41, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, + 0x65, 0x6e, 0x12, 0x21, 0x0a, 0x0c, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, 0x74, 0x6f, 0x6b, + 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, + 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x1d, 0x0a, 0x0a, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x5f, 0x74, + 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x74, 0x6f, 0x6b, 0x65, 0x6e, + 0x54, 0x79, 0x70, 0x65, 0x12, 0x39, 0x0a, 0x0a, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x5f, + 0x61, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, + 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, + 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x12, + 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, + 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0xac, 0x03, 0x0a, 0x07, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, + 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, + 0x72, 0x49, 0x64, 0x12, 0x39, 0x0a, 0x0a, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x5f, 0x61, + 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, + 0x61, 0x6d, 0x70, 0x52, 0x09, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x12, 0x39, + 0x0a, 0x0a, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x05, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, + 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x2b, 0x0a, 0x08, 0x69, 0x64, 0x5f, + 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x73, 0x65, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x07, 0x69, + 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x34, 0x0a, 0x0b, 0x6f, 0x61, 0x75, 0x74, 0x68, 0x5f, + 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x73, 0x65, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x4f, 0x41, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, + 0x52, 0x0a, 0x6f, 0x61, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x34, 0x0a, 0x06, + 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x73, + 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x43, + 0x6c, 0x61, 0x69, 0x6d, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x63, 0x6c, 0x61, 0x69, + 0x6d, 0x73, 0x1a, 0x4f, 0x0a, 0x0b, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x73, 0x45, 0x6e, 0x74, 0x72, + 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, + 0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, + 0x02, 0x38, 0x01, 0x22, 0x38, 0x0a, 0x0a, 0x41, 0x64, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x2a, 0x0a, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x53, 0x65, 0x73, + 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x60, 0x0a, + 0x0b, 0x41, 0x64, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2a, 0x0a, 0x07, + 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, + 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, + 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x25, 0x0a, 0x0e, 0x73, 0x65, 0x72, 0x76, + 0x65, 0x72, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0d, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, + 0x1f, 0x0a, 0x0d, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, + 0x32, 0x7c, 0x0a, 0x0e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, + 0x63, 0x65, 0x12, 0x30, 0x0a, 0x03, 0x41, 0x64, 0x64, 0x12, 0x13, 0x2e, 0x73, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x2e, 0x41, 0x64, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x14, + 0x2e, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x41, 0x64, 0x64, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x38, 0x0a, 0x06, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x16, + 0x2e, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x42, 0x34, + 0x5a, 0x32, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x6f, 0x6d, + 0x65, 0x72, 0x69, 0x75, 0x6d, 0x2f, 0x70, 0x6f, 0x6d, 0x65, 0x72, 0x69, 0x75, 0x6d, 0x2f, 0x69, + 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x67, 0x72, 0x70, 0x63, 0x2f, 0x73, 0x65, 0x73, + 0x73, 0x69, 0x6f, 0x6e, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_session_proto_rawDescOnce sync.Once + file_session_proto_rawDescData = file_session_proto_rawDesc +) + +func file_session_proto_rawDescGZIP() []byte { + file_session_proto_rawDescOnce.Do(func() { + file_session_proto_rawDescData = protoimpl.X.CompressGZIP(file_session_proto_rawDescData) + }) + return file_session_proto_rawDescData +} + +var file_session_proto_msgTypes = make([]protoimpl.MessageInfo, 7) +var file_session_proto_goTypes = []interface{}{ + (*IDToken)(nil), // 0: session.IDToken + (*OAuthToken)(nil), // 1: session.OAuthToken + (*Session)(nil), // 2: session.Session + (*AddRequest)(nil), // 3: session.AddRequest + (*AddResponse)(nil), // 4: session.AddResponse + (*DeleteRequest)(nil), // 5: session.DeleteRequest + nil, // 6: session.Session.ClaimsEntry + (*timestamp.Timestamp)(nil), // 7: google.protobuf.Timestamp + (*any.Any)(nil), // 8: google.protobuf.Any + (*empty.Empty)(nil), // 9: google.protobuf.Empty +} +var file_session_proto_depIdxs = []int32{ + 7, // 0: session.IDToken.expires_at:type_name -> google.protobuf.Timestamp + 7, // 1: session.IDToken.issued_at:type_name -> google.protobuf.Timestamp + 7, // 2: session.OAuthToken.expires_at:type_name -> google.protobuf.Timestamp + 7, // 3: session.Session.expires_at:type_name -> google.protobuf.Timestamp + 7, // 4: session.Session.deleted_at:type_name -> google.protobuf.Timestamp + 0, // 5: session.Session.id_token:type_name -> session.IDToken + 1, // 6: session.Session.oauth_token:type_name -> session.OAuthToken + 6, // 7: session.Session.claims:type_name -> session.Session.ClaimsEntry + 2, // 8: session.AddRequest.session:type_name -> session.Session + 2, // 9: session.AddResponse.session:type_name -> session.Session + 8, // 10: session.Session.ClaimsEntry.value:type_name -> google.protobuf.Any + 3, // 11: session.SessionService.Add:input_type -> session.AddRequest + 5, // 12: session.SessionService.Delete:input_type -> session.DeleteRequest + 4, // 13: session.SessionService.Add:output_type -> session.AddResponse + 9, // 14: session.SessionService.Delete:output_type -> google.protobuf.Empty + 13, // [13:15] is the sub-list for method output_type + 11, // [11:13] is the sub-list for method input_type + 11, // [11:11] is the sub-list for extension type_name + 11, // [11:11] is the sub-list for extension extendee + 0, // [0:11] is the sub-list for field type_name +} + +func init() { file_session_proto_init() } +func file_session_proto_init() { + if File_session_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_session_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*IDToken); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_session_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*OAuthToken); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_session_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Session); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_session_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*AddRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_session_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*AddResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_session_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*DeleteRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_session_proto_rawDesc, + NumEnums: 0, + NumMessages: 7, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_session_proto_goTypes, + DependencyIndexes: file_session_proto_depIdxs, + MessageInfos: file_session_proto_msgTypes, + }.Build() + File_session_proto = out.File + file_session_proto_rawDesc = nil + file_session_proto_goTypes = nil + file_session_proto_depIdxs = nil +} + +// Reference imports to suppress errors if they are not otherwise used. +var _ context.Context +var _ grpc.ClientConnInterface + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +const _ = grpc.SupportPackageIsVersion6 + +// SessionServiceClient is the client API for SessionService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://godoc.org/google.golang.org/grpc#ClientConn.NewStream. +type SessionServiceClient interface { + Add(ctx context.Context, in *AddRequest, opts ...grpc.CallOption) (*AddResponse, error) + Delete(ctx context.Context, in *DeleteRequest, opts ...grpc.CallOption) (*empty.Empty, error) +} + +type sessionServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewSessionServiceClient(cc grpc.ClientConnInterface) SessionServiceClient { + return &sessionServiceClient{cc} +} + +func (c *sessionServiceClient) Add(ctx context.Context, in *AddRequest, opts ...grpc.CallOption) (*AddResponse, error) { + out := new(AddResponse) + err := c.cc.Invoke(ctx, "/session.SessionService/Add", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *sessionServiceClient) Delete(ctx context.Context, in *DeleteRequest, opts ...grpc.CallOption) (*empty.Empty, error) { + out := new(empty.Empty) + err := c.cc.Invoke(ctx, "/session.SessionService/Delete", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +// SessionServiceServer is the server API for SessionService service. +type SessionServiceServer interface { + Add(context.Context, *AddRequest) (*AddResponse, error) + Delete(context.Context, *DeleteRequest) (*empty.Empty, error) +} + +// UnimplementedSessionServiceServer can be embedded to have forward compatible implementations. +type UnimplementedSessionServiceServer struct { +} + +func (*UnimplementedSessionServiceServer) Add(context.Context, *AddRequest) (*AddResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Add not implemented") +} +func (*UnimplementedSessionServiceServer) Delete(context.Context, *DeleteRequest) (*empty.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method Delete not implemented") +} + +func RegisterSessionServiceServer(s *grpc.Server, srv SessionServiceServer) { + s.RegisterService(&_SessionService_serviceDesc, srv) +} + +func _SessionService_Add_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AddRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SessionServiceServer).Add(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/session.SessionService/Add", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SessionServiceServer).Add(ctx, req.(*AddRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SessionService_Delete_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DeleteRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SessionServiceServer).Delete(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/session.SessionService/Delete", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SessionServiceServer).Delete(ctx, req.(*DeleteRequest)) + } + return interceptor(ctx, in, info, handler) +} + +var _SessionService_serviceDesc = grpc.ServiceDesc{ + ServiceName: "session.SessionService", + HandlerType: (*SessionServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Add", + Handler: _SessionService_Add_Handler, + }, + { + MethodName: "Delete", + Handler: _SessionService_Delete_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "session.proto", +} diff --git a/internal/grpc/session/session.proto b/internal/grpc/session/session.proto new file mode 100644 index 000000000..b39e35fec --- /dev/null +++ b/internal/grpc/session/session.proto @@ -0,0 +1,46 @@ +syntax = "proto3"; + +package session; +option go_package = "github.com/pomerium/pomerium/internal/grpc/session"; + +import "google/protobuf/any.proto"; +import "google/protobuf/empty.proto"; +import "google/protobuf/timestamp.proto"; + +message IDToken { + string issuer = 1; + string subject = 2; + google.protobuf.Timestamp expires_at = 3; + google.protobuf.Timestamp issued_at = 4; +} + +message OAuthToken { + string access_token = 1; + string token_type = 2; + google.protobuf.Timestamp expires_at = 3; + string refresh_token = 4; +} + +message Session { + string version = 1; + string id = 2; + string user_id = 3; + google.protobuf.Timestamp expires_at = 4; + google.protobuf.Timestamp deleted_at = 5; + IDToken id_token = 6; + OAuthToken oauth_token = 7; + map claims = 8; +} + +message AddRequest { Session session = 1; } +message AddResponse { + Session session = 1; + string server_version = 2; +} + +message DeleteRequest { string id = 1; } + +service SessionService { + rpc Add(AddRequest) returns (AddResponse); + rpc Delete(DeleteRequest) returns (google.protobuf.Empty); +} diff --git a/internal/grpc/user/user.go b/internal/grpc/user/user.go new file mode 100644 index 000000000..142c74b27 --- /dev/null +++ b/internal/grpc/user/user.go @@ -0,0 +1,36 @@ +// Package user contains protobuf types for users. +package user + +import ( + context "context" + + "github.com/golang/protobuf/ptypes" + + "github.com/pomerium/pomerium/internal/grpc/databroker" + "github.com/pomerium/pomerium/internal/protoutil" +) + +// Get gets a user from the databroker. +func Get(ctx context.Context, client databroker.DataBrokerServiceClient, userID string) (*User, error) { + any, _ := ptypes.MarshalAny(new(User)) + + res, err := client.Get(ctx, &databroker.GetRequest{ + Type: any.GetTypeUrl(), + Id: userID, + }) + if err != nil { + return nil, err + } + + var u User + err = ptypes.UnmarshalAny(res.GetRecord().GetData(), &u) + if err != nil { + return nil, err + } + return &u, nil +} + +// GetClaim gets a claim. +func (user *User) GetClaim(claim string) interface{} { + return protoutil.AnyToInterface(user.GetClaims()[claim]) +} diff --git a/internal/grpc/user/user.pb.go b/internal/grpc/user/user.pb.go new file mode 100644 index 000000000..0bb7247e7 --- /dev/null +++ b/internal/grpc/user/user.pb.go @@ -0,0 +1,399 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.24.0 +// protoc v3.12.1 +// source: user.proto + +package user + +import ( + context "context" + proto "github.com/golang/protobuf/proto" + any "github.com/golang/protobuf/ptypes/any" + empty "github.com/golang/protobuf/ptypes/empty" + timestamp "github.com/golang/protobuf/ptypes/timestamp" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// This is a compile-time assertion that a sufficiently up-to-date version +// of the legacy proto package is being used. +const _ = proto.ProtoPackageIsVersion4 + +type User struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` + Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"` + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` + Email string `protobuf:"bytes,4,opt,name=email,proto3" json:"email,omitempty"` + CreatedAt *timestamp.Timestamp `protobuf:"bytes,5,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + ModifiedAt *timestamp.Timestamp `protobuf:"bytes,6,opt,name=modified_at,json=modifiedAt,proto3" json:"modified_at,omitempty"` + DeletedAt *timestamp.Timestamp `protobuf:"bytes,7,opt,name=deleted_at,json=deletedAt,proto3" json:"deleted_at,omitempty"` + Claims map[string]*any.Any `protobuf:"bytes,8,rep,name=claims,proto3" json:"claims,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` +} + +func (x *User) Reset() { + *x = User{} + if protoimpl.UnsafeEnabled { + mi := &file_user_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *User) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*User) ProtoMessage() {} + +func (x *User) ProtoReflect() protoreflect.Message { + mi := &file_user_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use User.ProtoReflect.Descriptor instead. +func (*User) Descriptor() ([]byte, []int) { + return file_user_proto_rawDescGZIP(), []int{0} +} + +func (x *User) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *User) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *User) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *User) GetEmail() string { + if x != nil { + return x.Email + } + return "" +} + +func (x *User) GetCreatedAt() *timestamp.Timestamp { + if x != nil { + return x.CreatedAt + } + return nil +} + +func (x *User) GetModifiedAt() *timestamp.Timestamp { + if x != nil { + return x.ModifiedAt + } + return nil +} + +func (x *User) GetDeletedAt() *timestamp.Timestamp { + if x != nil { + return x.DeletedAt + } + return nil +} + +func (x *User) GetClaims() map[string]*any.Any { + if x != nil { + return x.Claims + } + return nil +} + +type AddRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + User *User `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"` +} + +func (x *AddRequest) Reset() { + *x = AddRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_user_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AddRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddRequest) ProtoMessage() {} + +func (x *AddRequest) ProtoReflect() protoreflect.Message { + mi := &file_user_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AddRequest.ProtoReflect.Descriptor instead. +func (*AddRequest) Descriptor() ([]byte, []int) { + return file_user_proto_rawDescGZIP(), []int{1} +} + +func (x *AddRequest) GetUser() *User { + if x != nil { + return x.User + } + return nil +} + +var File_user_proto protoreflect.FileDescriptor + +var file_user_proto_rawDesc = []byte{ + 0x0a, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x04, 0x75, 0x73, + 0x65, 0x72, 0x1a, 0x19, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x62, 0x75, 0x66, 0x2f, 0x61, 0x6e, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1b, 0x67, + 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x65, + 0x6d, 0x70, 0x74, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, + 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, + 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x8e, 0x03, 0x0a, 0x04, + 0x55, 0x73, 0x65, 0x72, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x0e, + 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, + 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, + 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, + 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, + 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, + 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, + 0x64, 0x41, 0x74, 0x12, 0x3b, 0x0a, 0x0b, 0x6d, 0x6f, 0x64, 0x69, 0x66, 0x69, 0x65, 0x64, 0x5f, + 0x61, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, + 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, + 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0a, 0x6d, 0x6f, 0x64, 0x69, 0x66, 0x69, 0x65, 0x64, 0x41, 0x74, + 0x12, 0x39, 0x0a, 0x0a, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x07, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, + 0x52, 0x09, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x2e, 0x0a, 0x06, 0x63, + 0x6c, 0x61, 0x69, 0x6d, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x75, 0x73, + 0x65, 0x72, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x2e, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x73, 0x45, 0x6e, + 0x74, 0x72, 0x79, 0x52, 0x06, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x73, 0x1a, 0x4f, 0x0a, 0x0b, 0x43, + 0x6c, 0x61, 0x69, 0x6d, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, + 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x05, + 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, + 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, + 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x2c, 0x0a, 0x0a, + 0x41, 0x64, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1e, 0x0a, 0x04, 0x75, 0x73, + 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0a, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, + 0x55, 0x73, 0x65, 0x72, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x32, 0x3e, 0x0a, 0x0b, 0x55, 0x73, + 0x65, 0x72, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x2f, 0x0a, 0x03, 0x41, 0x64, 0x64, + 0x12, 0x10, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x41, 0x64, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x42, 0x31, 0x5a, 0x2f, 0x67, 0x69, + 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x6f, 0x6d, 0x65, 0x72, 0x69, 0x75, + 0x6d, 0x2f, 0x70, 0x6f, 0x6d, 0x65, 0x72, 0x69, 0x75, 0x6d, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, + 0x6e, 0x61, 0x6c, 0x2f, 0x67, 0x72, 0x70, 0x63, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x62, 0x06, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_user_proto_rawDescOnce sync.Once + file_user_proto_rawDescData = file_user_proto_rawDesc +) + +func file_user_proto_rawDescGZIP() []byte { + file_user_proto_rawDescOnce.Do(func() { + file_user_proto_rawDescData = protoimpl.X.CompressGZIP(file_user_proto_rawDescData) + }) + return file_user_proto_rawDescData +} + +var file_user_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_user_proto_goTypes = []interface{}{ + (*User)(nil), // 0: user.User + (*AddRequest)(nil), // 1: user.AddRequest + nil, // 2: user.User.ClaimsEntry + (*timestamp.Timestamp)(nil), // 3: google.protobuf.Timestamp + (*any.Any)(nil), // 4: google.protobuf.Any + (*empty.Empty)(nil), // 5: google.protobuf.Empty +} +var file_user_proto_depIdxs = []int32{ + 3, // 0: user.User.created_at:type_name -> google.protobuf.Timestamp + 3, // 1: user.User.modified_at:type_name -> google.protobuf.Timestamp + 3, // 2: user.User.deleted_at:type_name -> google.protobuf.Timestamp + 2, // 3: user.User.claims:type_name -> user.User.ClaimsEntry + 0, // 4: user.AddRequest.user:type_name -> user.User + 4, // 5: user.User.ClaimsEntry.value:type_name -> google.protobuf.Any + 1, // 6: user.UserService.Add:input_type -> user.AddRequest + 5, // 7: user.UserService.Add:output_type -> google.protobuf.Empty + 7, // [7:8] is the sub-list for method output_type + 6, // [6:7] is the sub-list for method input_type + 6, // [6:6] is the sub-list for extension type_name + 6, // [6:6] is the sub-list for extension extendee + 0, // [0:6] is the sub-list for field type_name +} + +func init() { file_user_proto_init() } +func file_user_proto_init() { + if File_user_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_user_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*User); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_user_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*AddRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_user_proto_rawDesc, + NumEnums: 0, + NumMessages: 3, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_user_proto_goTypes, + DependencyIndexes: file_user_proto_depIdxs, + MessageInfos: file_user_proto_msgTypes, + }.Build() + File_user_proto = out.File + file_user_proto_rawDesc = nil + file_user_proto_goTypes = nil + file_user_proto_depIdxs = nil +} + +// Reference imports to suppress errors if they are not otherwise used. +var _ context.Context +var _ grpc.ClientConnInterface + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +const _ = grpc.SupportPackageIsVersion6 + +// UserServiceClient is the client API for UserService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://godoc.org/google.golang.org/grpc#ClientConn.NewStream. +type UserServiceClient interface { + Add(ctx context.Context, in *AddRequest, opts ...grpc.CallOption) (*empty.Empty, error) +} + +type userServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewUserServiceClient(cc grpc.ClientConnInterface) UserServiceClient { + return &userServiceClient{cc} +} + +func (c *userServiceClient) Add(ctx context.Context, in *AddRequest, opts ...grpc.CallOption) (*empty.Empty, error) { + out := new(empty.Empty) + err := c.cc.Invoke(ctx, "/user.UserService/Add", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +// UserServiceServer is the server API for UserService service. +type UserServiceServer interface { + Add(context.Context, *AddRequest) (*empty.Empty, error) +} + +// UnimplementedUserServiceServer can be embedded to have forward compatible implementations. +type UnimplementedUserServiceServer struct { +} + +func (*UnimplementedUserServiceServer) Add(context.Context, *AddRequest) (*empty.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method Add not implemented") +} + +func RegisterUserServiceServer(s *grpc.Server, srv UserServiceServer) { + s.RegisterService(&_UserService_serviceDesc, srv) +} + +func _UserService_Add_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AddRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(UserServiceServer).Add(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/user.UserService/Add", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(UserServiceServer).Add(ctx, req.(*AddRequest)) + } + return interceptor(ctx, in, info, handler) +} + +var _UserService_serviceDesc = grpc.ServiceDesc{ + ServiceName: "user.UserService", + HandlerType: (*UserServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Add", + Handler: _UserService_Add_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "user.proto", +} diff --git a/internal/grpc/user/user.proto b/internal/grpc/user/user.proto new file mode 100644 index 000000000..02f32addf --- /dev/null +++ b/internal/grpc/user/user.proto @@ -0,0 +1,23 @@ +syntax = "proto3"; + +package user; +option go_package = "github.com/pomerium/pomerium/internal/grpc/user"; + +import "google/protobuf/any.proto"; +import "google/protobuf/empty.proto"; +import "google/protobuf/timestamp.proto"; + +message User { + string version = 1; + string id = 2; + string name = 3; + string email = 4; + google.protobuf.Timestamp created_at = 5; + google.protobuf.Timestamp modified_at = 6; + google.protobuf.Timestamp deleted_at = 7; + map claims = 8; +} + +message AddRequest { User user = 1; } + +service UserService { rpc Add(AddRequest) returns (google.protobuf.Empty); } diff --git a/internal/httputil/headers.go b/internal/httputil/headers.go index 6dff7b6db..87fc4f971 100644 --- a/internal/httputil/headers.go +++ b/internal/httputil/headers.go @@ -3,6 +3,11 @@ package httputil // AuthorizationTypePomerium is for Authorization: Pomerium JWT... headers const AuthorizationTypePomerium = "Pomerium" +// Standard headers +const ( + HeaderReferrer = "Referer" +) + // Pomerium headers contain information added to a request. const ( // HeaderPomeriumResponse is set when pomerium itself creates a response, diff --git a/internal/identity/manager/config.go b/internal/identity/manager/config.go new file mode 100644 index 000000000..af630bd15 --- /dev/null +++ b/internal/identity/manager/config.go @@ -0,0 +1,50 @@ +package manager + +import "time" + +var ( + defaultGroupRefreshInterval = 10 * time.Minute + defaultSessionRefreshGracePeriod = 1 * time.Minute + defaultSessionRefreshCoolOffDuration = 10 * time.Second +) + +type config struct { + groupRefreshInterval time.Duration + sessionRefreshGracePeriod time.Duration + sessionRefreshCoolOffDuration time.Duration +} + +func newConfig(options ...Option) *config { + cfg := new(config) + WithGroupRefreshInterval(defaultGroupRefreshInterval)(cfg) + WithSessionRefreshGracePeriod(defaultSessionRefreshGracePeriod)(cfg) + WithSessionRefreshCoolOffDuration(defaultSessionRefreshCoolOffDuration)(cfg) + for _, option := range options { + option(cfg) + } + return cfg +} + +// An Option customizes the configuration used for the identity manager. +type Option func(*config) + +// WithGroupRefreshInterval sets the group refresh interval used by the manager. +func WithGroupRefreshInterval(interval time.Duration) Option { + return func(cfg *config) { + cfg.groupRefreshInterval = interval + } +} + +// WithSessionRefreshGracePeriod sets the session refresh grace period used by the manager. +func WithSessionRefreshGracePeriod(dur time.Duration) Option { + return func(cfg *config) { + cfg.sessionRefreshGracePeriod = dur + } +} + +// WithSessionRefreshCoolOffDuration sets the session refresh cool-off duration used by the manager. +func WithSessionRefreshCoolOffDuration(dur time.Duration) Option { + return func(cfg *config) { + cfg.sessionRefreshCoolOffDuration = dur + } +} diff --git a/internal/identity/manager/data.go b/internal/identity/manager/data.go new file mode 100644 index 000000000..56cf842e1 --- /dev/null +++ b/internal/identity/manager/data.go @@ -0,0 +1,286 @@ +package manager + +import ( + "encoding/json" + "time" + + "github.com/golang/protobuf/ptypes" + "github.com/google/btree" + "google.golang.org/protobuf/types/known/anypb" + + "github.com/pomerium/pomerium/internal/grpc/session" + "github.com/pomerium/pomerium/internal/grpc/user" +) + +// A User is a user managed by the Manager. +type User struct { + *user.User + lastRefresh time.Time + refreshInterval time.Duration +} + +// NextRefresh returns the next time the user information needs to be refreshed. +func (u User) NextRefresh() time.Time { + return u.lastRefresh.Add(u.refreshInterval) +} + +// UnmarshalJSON unmarshals json data into the user object. +func (u *User) UnmarshalJSON(data []byte) error { + if u.User == nil { + u.User = new(user.User) + } + + var raw map[string]json.RawMessage + err := json.Unmarshal(data, &raw) + if err != nil { + return err + } + + if name, ok := raw["name"]; ok { + _ = json.Unmarshal(name, &u.User.Name) + delete(raw, "name") + } + if email, ok := raw["email"]; ok { + _ = json.Unmarshal(email, &u.User.Email) + delete(raw, "email") + } + + u.User.Claims = make(map[string]*anypb.Any) + for k, rawv := range raw { + var v interface{} + if json.Unmarshal(rawv, &v) != nil { + continue + } + + if anyv, err := toAny(v); err == nil { + u.User.Claims[k] = anyv + } + } + + return nil +} + +// A Session is a session managed by the Manager. +type Session struct { + *session.Session + lastRefresh time.Time + // gracePeriod is the amount of time before expiration to attempt a refresh. + gracePeriod time.Duration + // coolOffDuration is the amount of time to wait before attempting another refresh. + coolOffDuration time.Duration +} + +// NextRefresh returns the next time the session needs to be refreshed. +func (s Session) NextRefresh() time.Time { + var tm time.Time + + expiry, err := ptypes.Timestamp(s.GetOauthToken().GetExpiresAt()) + if err == nil { + expiry = expiry.Add(-s.gracePeriod) + if tm.IsZero() || expiry.Before(tm) { + tm = expiry + } + } + + expiry, err = ptypes.Timestamp(s.GetIdToken().GetExpiresAt()) + if err == nil { + expiry = expiry.Add(-s.gracePeriod) + if tm.IsZero() || expiry.Before(tm) { + tm = expiry + } + } + + expiry, err = ptypes.Timestamp(s.GetExpiresAt()) + if err == nil { + if tm.IsZero() || expiry.Before(tm) { + tm = expiry + } + } + + // don't refresh any quicker than the cool-off duration + min := s.lastRefresh.Add(s.coolOffDuration) + if tm.Before(min) { + tm = min + } + + return tm +} + +// UnmarshalJSON unmarshals json data into the session object. +func (s *Session) UnmarshalJSON(data []byte) error { + if s.Session == nil { + s.Session = new(session.Session) + } + + var raw map[string]json.RawMessage + err := json.Unmarshal(data, &raw) + if err != nil { + return err + } + + if s.Session.IdToken == nil { + s.Session.IdToken = new(session.IDToken) + } + + if iss, ok := raw["iss"]; ok { + _ = json.Unmarshal(iss, &s.Session.IdToken.Issuer) + delete(raw, "iss") + } + if sub, ok := raw["sub"]; ok { + _ = json.Unmarshal(sub, &s.Session.IdToken.Subject) + delete(raw, "sub") + } + if exp, ok := raw["exp"]; ok { + var secs int64 + if err := json.Unmarshal(exp, &secs); err == nil { + s.Session.IdToken.ExpiresAt, _ = ptypes.TimestampProto(time.Unix(secs, 0)) + } + delete(raw, "exp") + } + if iat, ok := raw["iat"]; ok { + var secs int64 + if err := json.Unmarshal(iat, &secs); err == nil { + s.Session.IdToken.IssuedAt, _ = ptypes.TimestampProto(time.Unix(secs, 0)) + } + delete(raw, "iat") + } + + s.Session.Claims = make(map[string]*anypb.Any) + for k, rawv := range raw { + var v interface{} + if json.Unmarshal(rawv, &v) != nil { + continue + } + + if anyv, err := toAny(v); err == nil { + s.Session.Claims[k] = anyv + } + } + + return nil +} + +type sessionCollectionItem struct { + Session +} + +func (item sessionCollectionItem) Less(than btree.Item) bool { + xUserID, yUserID := item.GetUserId(), than.(sessionCollectionItem).GetUserId() + switch { + case xUserID < yUserID: + return true + case yUserID < xUserID: + return false + } + + xID, yID := item.GetId(), than.(sessionCollectionItem).GetId() + switch { + case xID < yID: + return true + case yID < xID: + return false + } + return false +} + +type sessionCollection struct { + *btree.BTree +} + +func (c *sessionCollection) Delete(userID, sessionID string) { + c.BTree.Delete(sessionCollectionItem{ + Session: Session{ + Session: &session.Session{ + UserId: userID, + Id: sessionID, + }, + }, + }) +} + +func (c *sessionCollection) Get(userID, sessionID string) (Session, bool) { + item := c.BTree.Get(sessionCollectionItem{ + Session: Session{ + Session: &session.Session{ + UserId: userID, + Id: sessionID, + }, + }, + }) + if item == nil { + return Session{}, false + } + return item.(sessionCollectionItem).Session, true +} + +// GetSessionsForUser gets all the sessions for the given user. +func (c *sessionCollection) GetSessionsForUser(userID string) []Session { + var sessions []Session + c.AscendGreaterOrEqual(sessionCollectionItem{ + Session: Session{ + Session: &session.Session{ + UserId: userID, + }, + }, + }, func(item btree.Item) bool { + s := item.(sessionCollectionItem).Session + if s.UserId != userID { + return false + } + + sessions = append(sessions, s) + return true + }) + return sessions +} + +func (c *sessionCollection) ReplaceOrInsert(s Session) { + c.BTree.ReplaceOrInsert(sessionCollectionItem{Session: s}) +} + +type userCollectionItem struct { + User +} + +func (item userCollectionItem) Less(than btree.Item) bool { + xID, yID := item.GetId(), than.(userCollectionItem).GetId() + switch { + case xID < yID: + return true + case yID < xID: + return false + } + return false +} + +type userCollection struct { + *btree.BTree +} + +func (c *userCollection) Delete(userID string) { + c.BTree.Delete(userCollectionItem{ + User: User{ + User: &user.User{ + Id: userID, + }, + }, + }) +} + +func (c *userCollection) Get(userID string) (User, bool) { + item := c.BTree.Get(userCollectionItem{ + User: User{ + User: &user.User{ + Id: userID, + }, + }, + }) + if item == nil { + return User{}, false + } + return item.(userCollectionItem).User, true +} + +func (c *userCollection) ReplaceOrInsert(u User) { + c.BTree.ReplaceOrInsert(userCollectionItem{User: u}) +} diff --git a/internal/identity/manager/data_test.go b/internal/identity/manager/data_test.go new file mode 100644 index 000000000..793a3a01d --- /dev/null +++ b/internal/identity/manager/data_test.go @@ -0,0 +1,86 @@ +package manager + +import ( + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/golang/protobuf/ptypes" + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/types/known/anypb" + "google.golang.org/protobuf/types/known/wrapperspb" + + "github.com/pomerium/pomerium/internal/grpc/session" +) + +func TestUser_UnmarshalJSON(t *testing.T) { + var u User + err := json.Unmarshal([]byte(`{ + "name": "joe", + "email": "joe@test.com", + "some-other-claim": "xyz" + }`), &u) + assert.NoError(t, err) + assert.NotNil(t, u.User) + assert.Equal(t, "joe", u.User.Name) + assert.Equal(t, "joe@test.com", u.User.Email) + anyv, _ := ptypes.MarshalAny(&wrapperspb.StringValue{Value: "xyz"}) + assert.Equal(t, map[string]*anypb.Any{ + "some-other-claim": anyv, + }, u.Claims) +} + +func TestSession_NextRefresh(t *testing.T) { + tm1 := time.Date(2020, 6, 5, 12, 0, 0, 0, time.UTC) + s := Session{ + Session: &session.Session{}, + lastRefresh: tm1, + gracePeriod: time.Second * 10, + coolOffDuration: time.Minute, + } + assert.Equal(t, tm1.Add(time.Minute), s.NextRefresh()) + + tm2 := time.Date(2020, 6, 5, 13, 0, 0, 0, time.UTC) + pbtm2, _ := ptypes.TimestampProto(tm2) + s.OauthToken = &session.OAuthToken{ + ExpiresAt: pbtm2, + } + assert.Equal(t, tm2.Add(-time.Second*10), s.NextRefresh()) + + tm3 := time.Date(2020, 6, 5, 12, 30, 0, 0, time.UTC) + pbtm3, _ := ptypes.TimestampProto(tm3) + s.IdToken = &session.IDToken{ + ExpiresAt: pbtm3, + } + assert.Equal(t, tm3.Add(-time.Second*10), s.NextRefresh()) + + tm4 := time.Date(2020, 6, 5, 12, 15, 0, 0, time.UTC) + pbtm4, _ := ptypes.TimestampProto(tm4) + s.ExpiresAt = pbtm4 + assert.Equal(t, tm4, s.NextRefresh()) +} + +func TestSession_UnmarshalJSON(t *testing.T) { + tm := time.Date(2020, 6, 5, 12, 0, 0, 0, time.UTC) + pbtm, _ := ptypes.TimestampProto(tm) + var s Session + err := json.Unmarshal([]byte(`{ + "iss": "https://some.issuer.com", + "sub": "subject", + "exp": `+fmt.Sprint(tm.Unix())+`, + "iat": `+fmt.Sprint(tm.Unix())+`, + "some-other-claim": "xyz" + }`), &s) + assert.NoError(t, err) + assert.NotNil(t, s.Session) + assert.NotNil(t, s.Session.IdToken) + assert.Equal(t, "https://some.issuer.com", s.Session.IdToken.Issuer) + assert.Equal(t, "subject", s.Session.IdToken.Subject) + assert.Equal(t, pbtm, s.Session.IdToken.ExpiresAt) + assert.Equal(t, pbtm, s.Session.IdToken.IssuedAt) + anyv, _ := ptypes.MarshalAny(&wrapperspb.StringValue{Value: "xyz"}) + assert.Equal(t, map[string]*anypb.Any{ + "some-other-claim": anyv, + }, s.Claims) +} diff --git a/internal/identity/manager/manager.go b/internal/identity/manager/manager.go new file mode 100644 index 000000000..cc2e4ab3c --- /dev/null +++ b/internal/identity/manager/manager.go @@ -0,0 +1,553 @@ +// Package manager contains an identity manager responsible for refreshing sessions and creating users. +package manager + +import ( + "context" + "fmt" + "time" + + "github.com/golang/protobuf/proto" + "github.com/golang/protobuf/ptypes" + "github.com/google/btree" + "github.com/rs/zerolog" + "golang.org/x/oauth2" + "gopkg.in/tomb.v2" + + "github.com/pomerium/pomerium/internal/directory" + "github.com/pomerium/pomerium/internal/grpc/databroker" + "github.com/pomerium/pomerium/internal/grpc/session" + "github.com/pomerium/pomerium/internal/grpc/user" + "github.com/pomerium/pomerium/internal/log" + "github.com/pomerium/pomerium/internal/scheduler" +) + +// Authenticator is an identity.Provider with only the methods needed by the manager. +type Authenticator interface { + Refresh(context.Context, *oauth2.Token, interface{}) (*oauth2.Token, error) + Revoke(context.Context, *oauth2.Token) error + UpdateUserInfo(ctx context.Context, t *oauth2.Token, v interface{}) error +} + +// A Manager refreshes identity information using session and user data. +type Manager struct { + cfg *config + authenticator Authenticator + directory directory.Provider + sessionClient session.SessionServiceClient + userClient user.UserServiceClient + dataBrokerClient databroker.DataBrokerServiceClient + log zerolog.Logger + + sessions sessionCollection + sessionScheduler *scheduler.Scheduler + + users userCollection + userScheduler *scheduler.Scheduler + + directoryUsers map[string]*directory.User + directoryUsersServerVersion string + directoryUsersRecordVersion string + directoryUsersNextRefresh time.Time +} + +// New creates a new identity manager. +func New( + authenticator Authenticator, + directoryProvider directory.Provider, + sessionClient session.SessionServiceClient, + userClient user.UserServiceClient, + dataBrokerClient databroker.DataBrokerServiceClient, + options ...Option, +) *Manager { + mgr := &Manager{ + cfg: newConfig(options...), + authenticator: authenticator, + directory: directoryProvider, + sessionClient: sessionClient, + userClient: userClient, + dataBrokerClient: dataBrokerClient, + log: log.With().Str("service", "identity_manager").Logger(), + + sessions: sessionCollection{ + BTree: btree.New(8), + }, + sessionScheduler: scheduler.New(), + users: userCollection{ + BTree: btree.New(8), + }, + userScheduler: scheduler.New(), + } + return mgr +} + +// Run runs the manager. This method blocks until an error occurs or the given context is canceled. +func (mgr *Manager) Run(ctx context.Context) error { + err := mgr.initDirectoryUsers(ctx) + if err != nil { + return fmt.Errorf("failed to initialize directory users: %w", err) + } + + t, ctx := tomb.WithContext(ctx) + + updatedSession := make(chan *session.Session, 1) + t.Go(func() error { + return mgr.syncSessions(ctx, updatedSession) + }) + + updatedUser := make(chan *user.User, 1) + t.Go(func() error { + return mgr.syncUsers(ctx, updatedUser) + }) + + updatedDirectoryUser := make(chan *directory.User, 1) + t.Go(func() error { + return mgr.syncDirectoryUsers(ctx, updatedDirectoryUser) + }) + + t.Go(func() error { + return mgr.refreshLoop(ctx, updatedSession, updatedUser, updatedDirectoryUser) + }) + + return t.Wait() +} + +func (mgr *Manager) refreshLoop( + ctx context.Context, + updatedSession <-chan *session.Session, + updatedUser <-chan *user.User, + updatedDirectoryUser <-chan *directory.User, +) error { + maxWait := time.Minute * 10 + nextTime := time.Now().Add(maxWait) + if mgr.directoryUsersNextRefresh.Before(nextTime) { + nextTime = mgr.directoryUsersNextRefresh + } + + timer := time.NewTimer(time.Until(nextTime)) + defer timer.Stop() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case s := <-updatedSession: + mgr.onUpdateSession(ctx, s) + case u := <-updatedUser: + mgr.onUpdateUser(ctx, u) + case du := <-updatedDirectoryUser: + mgr.onUpdateDirectoryUser(ctx, du) + case <-timer.C: + } + + now := time.Now() + nextTime := now.Add(maxWait) + + // refresh groups + if mgr.directoryUsersNextRefresh.Before(now) { + mgr.refreshDirectoryUsers(ctx) + mgr.directoryUsersNextRefresh = now.Add(mgr.cfg.groupRefreshInterval) + if mgr.directoryUsersNextRefresh.Before(nextTime) { + nextTime = mgr.directoryUsersNextRefresh + } + } + + // refresh sessions + for { + tm, key := mgr.sessionScheduler.Next() + if now.Before(tm) { + if tm.Before(nextTime) { + nextTime = tm + } + break + } + mgr.sessionScheduler.Remove(key) + + userID, sessionID := fromSessionSchedulerKey(key) + mgr.refreshSession(ctx, userID, sessionID) + } + + // refresh users + for { + tm, key := mgr.userScheduler.Next() + if now.Before(tm) { + if tm.Before(nextTime) { + nextTime = tm + } + break + } + mgr.userScheduler.Remove(key) + + mgr.refreshUser(ctx, key) + } + + timer.Reset(time.Until(nextTime)) + } +} + +func (mgr *Manager) refreshDirectoryUsers(ctx context.Context) { + mgr.log.Info().Msg("refreshing directory users") + + ctx, clearTimeout := context.WithTimeout(ctx, time.Minute) + defer clearTimeout() + + directoryUsers, err := mgr.directory.UserGroups(ctx) + if err != nil { + mgr.log.Warn().Err(err).Msg("failed to refresh directory users and groups") + return + } + + lookup := map[string]*directory.User{} + for _, du := range directoryUsers { + lookup[du.GetId()] = du + } + + for userID, newDU := range lookup { + curDU, ok := mgr.directoryUsers[userID] + if !ok || !proto.Equal(newDU, curDU) { + any, err := ptypes.MarshalAny(newDU) + if err != nil { + mgr.log.Warn().Err(err).Msg("failed to marshal directory user") + return + } + _, err = mgr.dataBrokerClient.Set(ctx, &databroker.SetRequest{ + Type: any.GetTypeUrl(), + Id: newDU.GetId(), + Data: any, + }) + if err != nil { + mgr.log.Warn().Err(err).Msg("failed to update directory user") + return + } + } + } + + for userID, curDU := range mgr.directoryUsers { + _, ok := lookup[userID] + if !ok { + any, err := ptypes.MarshalAny(curDU) + if err != nil { + mgr.log.Warn().Err(err).Msg("failed to marshal directory user") + return + } + _, err = mgr.dataBrokerClient.Delete(ctx, &databroker.DeleteRequest{ + Type: any.GetTypeUrl(), + Id: curDU.GetId(), + }) + if err != nil { + mgr.log.Warn().Err(err).Msg("failed to delete directory user") + return + } + } + } +} + +func (mgr *Manager) refreshSession(ctx context.Context, userID, sessionID string) { + mgr.log.Info(). + Str("user_id", userID). + Str("session_id", sessionID). + Msg("refreshing session") + + s, ok := mgr.sessions.Get(userID, sessionID) + if !ok { + mgr.log.Warn(). + Str("user_id", userID). + Str("session_id", sessionID). + Msg("no session found for refresh") + return + } + + expiry, err := ptypes.Timestamp(s.GetExpiresAt()) + if err == nil && !expiry.After(time.Now()) { + mgr.log.Info(). + Str("user_id", userID). + Str("session_id", sessionID). + Msg("deleting expired session") + s.DeletedAt, _ = ptypes.TimestampProto(time.Now()) + _, err = mgr.sessionClient.Add(ctx, &session.AddRequest{Session: s.Session}) + if err != nil { + mgr.log.Error().Err(err). + Str("user_id", s.GetUserId()). + Str("session_id", s.GetId()). + Msg("failed to delete session") + return + } + return + } + + if s.Session == nil || s.Session.OauthToken == nil { + mgr.log.Warn(). + Str("user_id", userID). + Str("session_id", sessionID). + Msg("no session oauth2 token found for refresh") + return + } + + newToken, err := mgr.authenticator.Refresh(ctx, fromOAuthToken(s.OauthToken), &s) + if err != nil { + mgr.log.Error().Err(err). + Str("user_id", s.GetUserId()). + Str("session_id", s.GetId()). + Msg("failed to refresh oauth2 token") + return + } + s.OauthToken = toOAuthToken(newToken) + + _, err = mgr.sessionClient.Add(ctx, &session.AddRequest{Session: s.Session}) + if err != nil { + mgr.log.Error().Err(err). + Str("user_id", s.GetUserId()). + Str("session_id", s.GetId()). + Msg("failed to update session") + return + } + + mgr.onUpdateSession(ctx, s.Session) +} + +func (mgr *Manager) refreshUser(ctx context.Context, userID string) { + mgr.log.Info(). + Str("user_id", userID). + Msg("refreshing user") + + u, ok := mgr.users.Get(userID) + if !ok { + mgr.log.Warn(). + Str("user_id", userID). + Msg("no user found for refresh") + return + } + u.lastRefresh = time.Now() + mgr.userScheduler.Add(u.NextRefresh(), u.GetId()) + + for _, s := range mgr.sessions.GetSessionsForUser(userID) { + if s.Session == nil || s.Session.OauthToken == nil { + mgr.log.Warn(). + Str("user_id", userID). + Msg("no session oauth2 token found for refresh") + continue + } + + err := mgr.authenticator.UpdateUserInfo(ctx, fromOAuthToken(s.OauthToken), &u) + if err != nil { + mgr.log.Error().Err(err). + Str("user_id", s.GetUserId()). + Str("session_id", s.GetId()). + Msg("failed to update user info") + continue + } + + _, err = mgr.userClient.Add(ctx, &user.AddRequest{User: u.User}) + if err != nil { + mgr.log.Error().Err(err). + Str("user_id", s.GetUserId()). + Str("session_id", s.GetId()). + Msg("failed to update user") + continue + } + + mgr.onUpdateUser(ctx, u.User) + } +} + +func (mgr *Manager) syncSessions(ctx context.Context, ch chan<- *session.Session) error { + mgr.log.Info().Msg("syncing sessions") + + any, err := ptypes.MarshalAny(new(session.Session)) + if err != nil { + return err + } + + client, err := mgr.dataBrokerClient.Sync(ctx, &databroker.SyncRequest{ + Type: any.GetTypeUrl(), + }) + if err != nil { + return fmt.Errorf("error syncing sessions: %w", err) + } + for { + res, err := client.Recv() + if err != nil { + return fmt.Errorf("error receiving sessions: %w", err) + } + + for _, record := range res.GetRecords() { + var pbSession session.Session + err := ptypes.UnmarshalAny(record.GetData(), &pbSession) + if err != nil { + return fmt.Errorf("error unmarshaling session: %w", err) + } + + select { + case <-ctx.Done(): + return ctx.Err() + case ch <- &pbSession: + } + } + } +} + +func (mgr *Manager) syncUsers(ctx context.Context, ch chan<- *user.User) error { + mgr.log.Info().Msg("syncing users") + + any, err := ptypes.MarshalAny(new(user.User)) + if err != nil { + return err + } + + client, err := mgr.dataBrokerClient.Sync(ctx, &databroker.SyncRequest{ + Type: any.GetTypeUrl(), + }) + if err != nil { + return fmt.Errorf("error syncing users: %w", err) + } + for { + res, err := client.Recv() + if err != nil { + return fmt.Errorf("error receiving users: %w", err) + } + + for _, record := range res.GetRecords() { + var pbUser user.User + err := ptypes.UnmarshalAny(record.GetData(), &pbUser) + if err != nil { + return fmt.Errorf("error unmarshaling user: %w", err) + } + + select { + case <-ctx.Done(): + return ctx.Err() + case ch <- &pbUser: + } + } + } +} + +func (mgr *Manager) initDirectoryUsers(ctx context.Context) error { + mgr.log.Info().Msg("initializing directory users") + + any, err := ptypes.MarshalAny(new(directory.User)) + if err != nil { + return err + } + + res, err := mgr.dataBrokerClient.GetAll(ctx, &databroker.GetAllRequest{ + Type: any.GetTypeUrl(), + }) + if err != nil { + return fmt.Errorf("error getting all directory users: %w", err) + } + + mgr.directoryUsers = map[string]*directory.User{} + for _, record := range res.GetRecords() { + var pbDirectoryUser directory.User + err := ptypes.UnmarshalAny(record.GetData(), &pbDirectoryUser) + if err != nil { + return fmt.Errorf("error unmarshaling directory user: %w", err) + } + + mgr.directoryUsers[pbDirectoryUser.GetId()] = &pbDirectoryUser + } + mgr.directoryUsersRecordVersion = res.GetRecordVersion() + mgr.directoryUsersServerVersion = res.GetServerVersion() + + return nil +} + +func (mgr *Manager) syncDirectoryUsers(ctx context.Context, ch chan<- *directory.User) error { + mgr.log.Info().Msg("syncing directory users") + + any, err := ptypes.MarshalAny(new(directory.User)) + if err != nil { + return err + } + + client, err := mgr.dataBrokerClient.Sync(ctx, &databroker.SyncRequest{ + Type: any.GetTypeUrl(), + ServerVersion: mgr.directoryUsersServerVersion, + RecordVersion: mgr.directoryUsersRecordVersion, + }) + if err != nil { + return fmt.Errorf("error syncing directory users: %w", err) + } + for { + res, err := client.Recv() + if err != nil { + return fmt.Errorf("error receiving directory users: %w", err) + } + + for _, record := range res.GetRecords() { + var pbDirectoryUser directory.User + err := ptypes.UnmarshalAny(record.GetData(), &pbDirectoryUser) + if err != nil { + return fmt.Errorf("error unmarshaling directory user: %w", err) + } + + select { + case <-ctx.Done(): + return ctx.Err() + case ch <- &pbDirectoryUser: + } + } + } +} + +func (mgr *Manager) onUpdateSession(ctx context.Context, pbSession *session.Session) { + mgr.sessionScheduler.Remove(toSessionSchedulerKey(pbSession.GetUserId(), pbSession.GetId())) + + if pbSession.GetDeletedAt() != nil { + // remove from local store + mgr.sessions.Delete(pbSession.GetUserId(), pbSession.GetId()) + return + } + + // update session + s, _ := mgr.sessions.Get(pbSession.GetUserId(), pbSession.GetId()) + s.lastRefresh = time.Now() + s.gracePeriod = mgr.cfg.sessionRefreshGracePeriod + s.coolOffDuration = mgr.cfg.sessionRefreshCoolOffDuration + s.Session = pbSession + mgr.sessions.ReplaceOrInsert(s) + mgr.sessionScheduler.Add(s.NextRefresh(), toSessionSchedulerKey(pbSession.GetUserId(), pbSession.GetId())) + + // create the user if it doesn't exist yet + if _, ok := mgr.users.Get(pbSession.GetUserId()); !ok { + mgr.createUser(ctx, pbSession) + } +} + +func (mgr *Manager) onUpdateUser(_ context.Context, pbUser *user.User) { + if pbUser.DeletedAt != nil { + mgr.users.Delete(pbUser.GetId()) + mgr.userScheduler.Remove(pbUser.GetId()) + return + } + + u, ok := mgr.users.Get(pbUser.GetId()) + if ok { + // only reset the refresh time if this is an existing user + u.lastRefresh = time.Now() + } + u.refreshInterval = mgr.cfg.groupRefreshInterval + u.User = pbUser + mgr.users.ReplaceOrInsert(u) + mgr.userScheduler.Add(u.NextRefresh(), u.GetId()) +} + +func (mgr *Manager) onUpdateDirectoryUser(_ context.Context, pbDirectoryUser *directory.User) { + mgr.directoryUsers[pbDirectoryUser.GetId()] = pbDirectoryUser +} + +func (mgr *Manager) createUser(ctx context.Context, pbSession *session.Session) { + u := User{ + User: &user.User{ + Id: pbSession.GetUserId(), + }, + } + + _, err := mgr.userClient.Add(ctx, &user.AddRequest{User: u.User}) + if err != nil { + mgr.log.Error().Err(err). + Str("user_id", pbSession.GetUserId()). + Str("session_id", pbSession.GetId()). + Msg("failed to create user") + } +} diff --git a/internal/identity/manager/misc.go b/internal/identity/manager/misc.go new file mode 100644 index 000000000..84ab7ab2a --- /dev/null +++ b/internal/identity/manager/misc.go @@ -0,0 +1,121 @@ +package manager + +import ( + "fmt" + "strings" + + "github.com/golang/protobuf/ptypes" + structpb "github.com/golang/protobuf/ptypes/struct" + "golang.org/x/oauth2" + "google.golang.org/protobuf/types/known/anypb" + "google.golang.org/protobuf/types/known/wrapperspb" + + "github.com/pomerium/pomerium/internal/grpc/session" +) + +func toAny(value interface{}) (*anypb.Any, error) { + switch v := value.(type) { + case bool: + return ptypes.MarshalAny(&wrapperspb.BoolValue{Value: v}) + case []byte: + return ptypes.MarshalAny(&wrapperspb.BytesValue{Value: v}) + case float64: + return ptypes.MarshalAny(&wrapperspb.DoubleValue{Value: v}) + case float32: + return ptypes.MarshalAny(&wrapperspb.FloatValue{Value: v}) + case int32: + return ptypes.MarshalAny(&wrapperspb.Int32Value{Value: v}) + case int64: + return ptypes.MarshalAny(&wrapperspb.Int64Value{Value: v}) + case string: + return ptypes.MarshalAny(&wrapperspb.StringValue{Value: v}) + case uint32: + return ptypes.MarshalAny(&wrapperspb.UInt32Value{Value: v}) + case uint64: + return ptypes.MarshalAny(&wrapperspb.UInt64Value{Value: v}) + + case []interface{}: + lst := &structpb.ListValue{} + for _, c := range v { + if cv, err := toValue(c); err == nil { + lst.Values = append(lst.Values, cv) + } + } + return ptypes.MarshalAny(lst) + } + return nil, fmt.Errorf("unknown type %T", value) +} + +func toValue(value interface{}) (*structpb.Value, error) { + switch v := value.(type) { + case bool: + return &structpb.Value{ + Kind: &structpb.Value_BoolValue{BoolValue: v}, + }, nil + case float64: + return &structpb.Value{ + Kind: &structpb.Value_NumberValue{NumberValue: v}, + }, nil + case float32: + return &structpb.Value{ + Kind: &structpb.Value_NumberValue{NumberValue: float64(v)}, + }, nil + case int32: + return &structpb.Value{ + Kind: &structpb.Value_NumberValue{NumberValue: float64(v)}, + }, nil + case int64: + return &structpb.Value{ + Kind: &structpb.Value_NumberValue{NumberValue: float64(v)}, + }, nil + case string: + return &structpb.Value{ + Kind: &structpb.Value_StringValue{StringValue: v}, + }, nil + case uint32: + return &structpb.Value{ + Kind: &structpb.Value_NumberValue{NumberValue: float64(v)}, + }, nil + case uint64: + return &structpb.Value{ + Kind: &structpb.Value_NumberValue{NumberValue: float64(v)}, + }, nil + + } + return nil, fmt.Errorf("unknown type %T", value) +} + +func toSessionSchedulerKey(userID, sessionID string) string { + return userID + "\037" + sessionID +} + +func fromSessionSchedulerKey(key string) (userID, sessionID string) { + idx := strings.Index(key, "\037") + if idx >= 0 { + userID = key[:idx] + sessionID = key[idx+1:] + } else { + userID = key + } + return userID, sessionID +} + +func fromOAuthToken(token *session.OAuthToken) *oauth2.Token { + expiry, _ := ptypes.Timestamp(token.GetExpiresAt()) + return &oauth2.Token{ + AccessToken: token.GetAccessToken(), + TokenType: token.GetTokenType(), + RefreshToken: token.GetRefreshToken(), + Expiry: expiry, + } +} + +func toOAuthToken(token *oauth2.Token) *session.OAuthToken { + expiry, _ := ptypes.TimestampProto(token.Expiry) + return &session.OAuthToken{ + AccessToken: token.AccessToken, + TokenType: token.TokenType, + RefreshToken: token.RefreshToken, + ExpiresAt: expiry, + } +} diff --git a/internal/identity/mock_provider.go b/internal/identity/mock_provider.go index 943d9f977..73fc8cf42 100644 --- a/internal/identity/mock_provider.go +++ b/internal/identity/mock_provider.go @@ -17,6 +17,7 @@ type MockProvider struct { GetSignInURLResponse string LogOutResponse url.URL LogOutError error + UpdateUserInfoError error } // Authenticate is a mocked providers function. @@ -39,3 +40,8 @@ func (mp MockProvider) GetSignInURL(s string) string { return mp.GetSignInURLRes // LogOut is a mocked providers function. func (mp MockProvider) LogOut() (*url.URL, error) { return &mp.LogOutResponse, mp.LogOutError } + +// UpdateUserInfo is a mocked providers function. +func (mp MockProvider) UpdateUserInfo(ctx context.Context, t *oauth2.Token, v interface{}) error { + return mp.UpdateUserInfoError +} diff --git a/internal/identity/oauth/github/github.go b/internal/identity/oauth/github/github.go index 7c4caea05..906c44415 100644 --- a/internal/identity/oauth/github/github.go +++ b/internal/identity/oauth/github/github.go @@ -82,7 +82,7 @@ func (p *Provider) Authenticate(ctx context.Context, code string, v interface{}) return nil, fmt.Errorf("github: token exchange failed %v", err) } - err = p.updateSessionState(ctx, oauth2Token, v) + err = p.UpdateUserInfo(ctx, oauth2Token, v) if err != nil { return nil, err } @@ -90,10 +90,10 @@ func (p *Provider) Authenticate(ctx context.Context, code string, v interface{}) return oauth2Token, nil } -// updateSessionState will get the user information from github and also retrieve the user's team(s) +// UpdateUserInfo will get the user information from github and also retrieve the user's team(s) // // https://developer.github.com/v3/users/#get-the-authenticated-user -func (p *Provider) updateSessionState(ctx context.Context, t *oauth2.Token, v interface{}) error { +func (p *Provider) UpdateUserInfo(ctx context.Context, t *oauth2.Token, v interface{}) error { err := p.userInfo(ctx, t, v) if err != nil { @@ -113,12 +113,8 @@ func (p *Provider) updateSessionState(ctx context.Context, t *oauth2.Token, v in return nil } -// Refresh renews a user's session by making a new userInfo request. +// Refresh is a no-op for github, because github sessions never expire. func (p *Provider) Refresh(ctx context.Context, t *oauth2.Token, v interface{}) (*oauth2.Token, error) { - err := p.updateSessionState(ctx, t, v) - if err != nil { - return nil, err - } return t, nil } diff --git a/internal/identity/oidc/azure/microsoft.go b/internal/identity/oidc/azure/microsoft.go index 736a2f5c2..eb89a3e48 100644 --- a/internal/identity/oidc/azure/microsoft.go +++ b/internal/identity/oidc/azure/microsoft.go @@ -5,18 +5,12 @@ package azure import ( "context" - "encoding/json" "fmt" - "net/http" - "time" "golang.org/x/oauth2" - "github.com/pomerium/pomerium/internal/httputil" "github.com/pomerium/pomerium/internal/identity/oauth" pom_oidc "github.com/pomerium/pomerium/internal/identity/oidc" - "github.com/pomerium/pomerium/internal/log" - "github.com/pomerium/pomerium/internal/version" ) // Name identifies the Azure identity provider @@ -26,7 +20,6 @@ const Name = "azure" // account and a work or school account from Azure Active Directory (Azure AD) // an sign in to the application. const defaultProviderURL = "https://login.microsoftonline.com/common" -const defaultGroupURL = "https://graph.microsoft.com/v1.0/me/memberOf" // Provider is an Azure implementation of the Authenticator interface. type Provider struct { @@ -45,7 +38,6 @@ func New(ctx context.Context, o *oauth.Options) (*Provider, error) { return nil, fmt.Errorf("%s: failed creating oidc provider: %w", Name, err) } p.Provider = genericOidc - p.UserGroupFn = p.UserGroups return &p, nil } @@ -54,38 +46,3 @@ func New(ctx context.Context, o *oauth.Options) (*Provider, error) { func (p *Provider) GetSignInURL(state string) string { return p.Oauth.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", "select_account")) } - -// UserGroups returns a slice of group names a given user is in. -// `Directory.Read.All` is required. -// https://docs.microsoft.com/en-us/graph/api/resources/directoryobject?view=graph-rest-1.0 -// https://docs.microsoft.com/en-us/graph/api/user-list-memberof?view=graph-rest-1.0 -func (p *Provider) UserGroups(ctx context.Context, t *oauth2.Token, v interface{}) error { - var response struct { - Groups []struct { - ID string `json:"id"` - Description string `json:"description,omitempty"` - DisplayName string `json:"displayName"` - CreatedDateTime time.Time `json:"createdDateTime,omitempty"` - GroupTypes []string `json:"groupTypes,omitempty"` - } `json:"value"` - } - headers := map[string]string{"Authorization": fmt.Sprintf("Bearer %s", t.AccessToken)} - err := httputil.Client(ctx, http.MethodGet, defaultGroupURL, version.UserAgent(), headers, nil, &response) - if err != nil { - return err - } - - log.Debug().Interface("response", response).Msg("microsoft: groups") - var out struct { - Groups []string `json:"groups"` - } - for _, group := range response.Groups { - out.Groups = append(out.Groups, group.ID) - } - b, err := json.Marshal(out) - if err != nil { - return err - } - - return json.Unmarshal(b, v) -} diff --git a/internal/identity/oidc/gitlab/gitlab.go b/internal/identity/oidc/gitlab/gitlab.go index d86b22277..560b3f226 100644 --- a/internal/identity/oidc/gitlab/gitlab.go +++ b/internal/identity/oidc/gitlab/gitlab.go @@ -5,17 +5,12 @@ package gitlab import ( "context" - "encoding/json" "fmt" - "net/http" "github.com/coreos/go-oidc" - "github.com/pomerium/pomerium/internal/httputil" + "github.com/pomerium/pomerium/internal/identity/oauth" pom_oidc "github.com/pomerium/pomerium/internal/identity/oidc" - "github.com/pomerium/pomerium/internal/log" - "github.com/pomerium/pomerium/internal/version" - "golang.org/x/oauth2" ) // Name identifies the GitLab identity provider. @@ -25,17 +20,11 @@ var defaultScopes = []string{oidc.ScopeOpenID, "profile", "email", "api"} const ( defaultProviderURL = "https://gitlab.com" - - // groupPath is the url to return a list of groups for the authenticated user - // https://docs.gitlab.com/ee/api/groups.html - groupPath = "/api/v4/groups" ) // Provider is a Gitlab implementation of the Authenticator interface. type Provider struct { *pom_oidc.Provider - - userGroupURL string } // New instantiates an OpenID Connect (OIDC) provider for Gitlab. @@ -53,45 +42,6 @@ func New(ctx context.Context, o *oauth.Options) (*Provider, error) { return nil, fmt.Errorf("%s: failed creating oidc provider: %w", Name, err) } p.Provider = genericOidc - p.UserGroupFn = p.UserGroups - p.userGroupURL = o.ProviderURL + groupPath return &p, nil } - -// UserGroups returns a slice of groups for the user. -// -// Returns 20 results at a time because the API results are paginated. -// https://docs.gitlab.com/ee/api/groups.html#list-groups -func (p *Provider) UserGroups(ctx context.Context, t *oauth2.Token, v interface{}) error { - var response []struct { - ID json.Number `json:"id"` - Name string `json:"name,omitempty"` - Path string `json:"path,omitempty"` - Description string `json:"description,omitempty"` - Visibility string `json:"visibility,omitempty"` - ShareWithGroupLock bool `json:"share_with_group_lock,omitempty"` - RequireTwoFactorAuthentication bool `json:"require_two_factor_authentication,omitempty"` - SubgroupCreationLevel string `json:"subgroup_creation_level,omitempty"` - FullName string `json:"full_name,omitempty"` - FullPath string `json:"full_path,omitempty"` - } - headers := map[string]string{"Authorization": fmt.Sprintf("Bearer %s", t.AccessToken)} - err := httputil.Client(ctx, http.MethodGet, p.userGroupURL, version.UserAgent(), headers, nil, &response) - if err != nil { - return err - } - log.Debug().Interface("response", response).Msg("gitlab: groups") - var out struct { - Groups []string `json:"groups"` - } - for _, group := range response { - out.Groups = append(out.Groups, group.ID.String()) - } - b, err := json.Marshal(out) - if err != nil { - return err - } - - return json.Unmarshal(b, v) -} diff --git a/internal/identity/oidc/google/google.go b/internal/identity/oidc/google/google.go index d7ef90e27..1c886080d 100644 --- a/internal/identity/oidc/google/google.go +++ b/internal/identity/oidc/google/google.go @@ -6,19 +6,13 @@ package google import ( "context" - "encoding/base64" - "encoding/json" - "errors" "fmt" oidc "github.com/coreos/go-oidc" "golang.org/x/oauth2" - "golang.org/x/oauth2/google" - admin "google.golang.org/api/admin/directory/v1" "github.com/pomerium/pomerium/internal/identity/oauth" pom_oidc "github.com/pomerium/pomerium/internal/identity/oidc" - "github.com/pomerium/pomerium/internal/log" ) const ( @@ -33,10 +27,6 @@ var defaultScopes = []string{oidc.ScopeOpenID, "profile", "email"} // Provider is a Google implementation of the Authenticator interface. type Provider struct { *pom_oidc.Provider - - // todo(bdd): we could probably save on a big ol set of imports - // by calling this API directly - apiClient *admin.Service } // New instantiates an OpenID Connect (OIDC) session with Google. @@ -54,34 +44,6 @@ func New(ctx context.Context, o *oauth.Options) (*Provider, error) { return nil, fmt.Errorf("%s: failed creating oidc provider: %w", Name, err) } p.Provider = genericOidc - if o.ServiceAccount == "" { - log.Warn().Msg("google: no service account, will not fetch groups") - return &p, nil - } - - apiCreds, err := base64.StdEncoding.DecodeString(o.ServiceAccount) - if err != nil { - return nil, fmt.Errorf("google: could not decode service account json %w", err) - } - // Required scopes for groups api - // https://developers.google.com/admin-sdk/directory/v1/reference/groups/list - conf, err := google.JWTConfigFromJSON(apiCreds, admin.AdminDirectoryUserReadonlyScope, admin.AdminDirectoryGroupReadonlyScope) - if err != nil { - return nil, fmt.Errorf("google: failed making jwt config from json %w", err) - } - var credentialsFile struct { - ImpersonateUser string `json:"impersonate_user"` - } - if err := json.Unmarshal(apiCreds, &credentialsFile); err != nil { - return nil, err - } - conf.Subject = credentialsFile.ImpersonateUser - client := conf.Client(context.TODO()) - p.apiClient, err = admin.New(client) - if err != nil { - return nil, fmt.Errorf("google: failed creating admin service %w", err) - } - p.UserGroupFn = p.UserGroups return &p, nil } @@ -100,39 +62,3 @@ func New(ctx context.Context, o *oauth.Options) (*Provider, error) { func (p *Provider) GetSignInURL(state string) string { return p.Oauth.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", "select_account consent")) } - -// UserGroups returns a slice of group names a given user is in -// NOTE: groups via Directory API is limited to 1 QPS! -// https://developers.google.com/admin-sdk/directory/v1/reference/groups/list -// https://developers.google.com/admin-sdk/directory/v1/limits -func (p *Provider) UserGroups(ctx context.Context, t *oauth2.Token, v interface{}) error { - if p.apiClient == nil { - return errors.New("google: trying to fetch groups, but no api client") - } - s, err := p.GetSubject(v) - if err != nil { - return err - } - var out struct { - Groups []string `json:"groups"` - } - req := p.apiClient.Groups.List().Context(ctx).UserKey(s) - err = req.Pages(ctx, func(resp *admin.Groups) error { - for _, group := range resp.Groups { - out.Groups = append(out.Groups, group.Email) - } - return nil - }) - if err != nil { - return err - } - _, err = req.Do() - if err != nil { - return fmt.Errorf("google: group api request failed %w", err) - } - b, err := json.Marshal(out) - if err != nil { - return err - } - return json.Unmarshal(b, v) -} diff --git a/internal/identity/oidc/oidc.go b/internal/identity/oidc/oidc.go index 2dd2007fe..9ade877be 100644 --- a/internal/identity/oidc/oidc.go +++ b/internal/identity/oidc/oidc.go @@ -45,10 +45,6 @@ type Provider struct { // providers that doesn't implement the revocation endpoint but a logout session. // https://openid.net/specs/openid-connect-frontchannel-1_0.html#RPInitiated EndSessionURL string `json:"end_session_endpoint,omitempty"` - - // UserGroupFn is, if set, used to return a slice of group IDs the - // user is a member of - UserGroupFn func(context.Context, *oauth2.Token, interface{}) error } // New creates a new instance of a generic OpenID Connect provider. @@ -113,18 +109,18 @@ func (p *Provider) Authenticate(ctx context.Context, code string, v interface{}) return nil, fmt.Errorf("identity/oidc: couldn't unmarshal extra claims %w", err) } - if err := p.updateUserInfo(ctx, oauth2Token, v); err != nil { + if err := p.UpdateUserInfo(ctx, oauth2Token, v); err != nil { return nil, fmt.Errorf("identity/oidc: couldn't update user info %w", err) } return oauth2Token, nil } -// updateUserInfo calls the OIDC (spec required) UserInfo Endpoint as well as any +// UpdateUserInfo calls the OIDC (spec required) UserInfo Endpoint as well as any // groups endpoint (non-spec) to populate the rest of the user's information. // // https://openid.net/specs/openid-connect-core-1_0.html#UserInfo -func (p *Provider) updateUserInfo(ctx context.Context, t *oauth2.Token, v interface{}) error { +func (p *Provider) UpdateUserInfo(ctx context.Context, t *oauth2.Token, v interface{}) error { userInfo, err := getUserInfo(ctx, p.Provider, oauth2.StaticTokenSource(t)) if err != nil { return fmt.Errorf("identity/oidc: user info endpoint: %w", err) @@ -132,11 +128,6 @@ func (p *Provider) updateUserInfo(ctx context.Context, t *oauth2.Token, v interf if err := userInfo.Claims(v); err != nil { return fmt.Errorf("identity/oidc: failed parsing user info endpoint claims: %w", err) } - if p.UserGroupFn != nil { - if err := p.UserGroupFn(ctx, t, v); err != nil { - return fmt.Errorf("identity/oidc: could not retrieve groups: %w", err) - } - } return nil } @@ -164,9 +155,6 @@ func (p *Provider) Refresh(ctx context.Context, t *oauth2.Token, v interface{}) return nil, fmt.Errorf("identity/oidc: couldn't unmarshal extra claims %w", err) } } - if err := p.updateUserInfo(ctx, newToken, v); err != nil { - return nil, fmt.Errorf("identity/oidc: couldn't update user info %w", err) - } return newToken, nil } diff --git a/internal/identity/oidc/okta/okta.go b/internal/identity/oidc/okta/okta.go index 261c7e7df..7786812ae 100644 --- a/internal/identity/oidc/okta/okta.go +++ b/internal/identity/oidc/okta/okta.go @@ -5,37 +5,20 @@ package okta import ( "context" - "encoding/json" "fmt" - "net/http" - "net/url" - "github.com/pomerium/pomerium/internal/httputil" "github.com/pomerium/pomerium/internal/identity/oauth" pom_oidc "github.com/pomerium/pomerium/internal/identity/oidc" - "github.com/pomerium/pomerium/internal/log" - "github.com/pomerium/pomerium/internal/urlutil" - "github.com/pomerium/pomerium/internal/version" - "golang.org/x/oauth2" ) const ( // Name identifies the Okta identity provider Name = "okta" - - // https://developer.okta.com/docs/reference/api/users/ - userAPIPath = "/api/v1/users/" ) // Provider is an Okta implementation of the Authenticator interface. type Provider struct { *pom_oidc.Provider - - userAPI *url.URL - - // serviceAccount is the the custom HTTP authentication used for okta - // https://developer.okta.com/docs/reference/api-overview/#authentication - serviceAccount string } // New instantiates an OpenID Connect (OIDC) provider for Okta. @@ -48,53 +31,5 @@ func New(ctx context.Context, o *oauth.Options) (*Provider, error) { } p.Provider = genericOidc - if o.ServiceAccount != "" { - userAPI, err := urlutil.ParseAndValidateURL(o.ProviderURL) - if err != nil { - return nil, err - } - p.userAPI = userAPI - p.userAPI.Path = userAPIPath - p.serviceAccount = o.ServiceAccount - p.UserGroupFn = p.UserGroups - } else { - log.Warn().Msg("okta: api token not set, cannot retrieve groups") - } return &p, nil } - -// UserGroups fetches the groups of which the user is a member -// https://developer.okta.com/docs/reference/api/users/#get-user-s-groups -func (p *Provider) UserGroups(ctx context.Context, t *oauth2.Token, v interface{}) error { - s, err := p.GetSubject(v) - if err != nil { - return err - } - var response []struct { - ID string `json:"id"` - Profile struct { - Name string `json:"name"` - Description string `json:"description"` - } `json:"profile"` - } - - headers := map[string]string{"Authorization": fmt.Sprintf("SSWS %s", p.serviceAccount)} - uri := fmt.Sprintf("%s/%s/groups", p.userAPI.String(), s) - err = httputil.Client(ctx, http.MethodGet, uri, version.UserAgent(), headers, nil, &response) - if err != nil { - return err - } - log.Debug().Interface("response", response).Msg("okta: groups") - var out struct { - Groups []string `json:"groups"` - } - for _, group := range response { - out.Groups = append(out.Groups, group.ID) - } - b, err := json.Marshal(out) - if err != nil { - return err - } - - return json.Unmarshal(b, v) -} diff --git a/internal/identity/oidc/onelogin/onelogin.go b/internal/identity/oidc/onelogin/onelogin.go index 59e3d58ec..632a2fd5d 100644 --- a/internal/identity/oidc/onelogin/onelogin.go +++ b/internal/identity/oidc/onelogin/onelogin.go @@ -6,23 +6,18 @@ package onelogin import ( "context" "fmt" - "net/http" oidc "github.com/coreos/go-oidc" - "golang.org/x/oauth2" - "github.com/pomerium/pomerium/internal/httputil" "github.com/pomerium/pomerium/internal/identity/oauth" pom_oidc "github.com/pomerium/pomerium/internal/identity/oidc" - "github.com/pomerium/pomerium/internal/version" ) const ( // Name identifies the OneLogin identity provider Name = "onelogin" - defaultProviderURL = "https://openid-connect.onelogin.com/oidc" - defaultOneloginGroupURL = "https://openid-connect.onelogin.com/oidc/me" + defaultProviderURL = "https://openid-connect.onelogin.com/oidc" ) var defaultScopes = []string{oidc.ScopeOpenID, "profile", "email", "groups", "offline_access"} @@ -47,16 +42,5 @@ func New(ctx context.Context, o *oauth.Options) (*Provider, error) { return nil, fmt.Errorf("%s: failed creating oidc provider: %w", Name, err) } p.Provider = genericOidc - p.UserGroupFn = p.UserGroups return &p, nil } - -// UserGroups returns a slice of group names a given user is in. -// https://developers.onelogin.com/openid-connect/api/user-info -func (p *Provider) UserGroups(ctx context.Context, t *oauth2.Token, v interface{}) error { - if t == nil { - return pom_oidc.ErrMissingAccessToken - } - headers := map[string]string{"Authorization": fmt.Sprintf("Bearer %s", t.AccessToken)} - return httputil.Client(ctx, http.MethodGet, defaultOneloginGroupURL, version.UserAgent(), headers, nil, v) -} diff --git a/internal/identity/providers.go b/internal/identity/providers.go index 874ef3ebe..22d74cae1 100644 --- a/internal/identity/providers.go +++ b/internal/identity/providers.go @@ -38,6 +38,7 @@ type Authenticator interface { Revoke(context.Context, *oauth2.Token) error GetSignInURL(state string) string LogOut() (*url.URL, error) + UpdateUserInfo(ctx context.Context, t *oauth2.Token, v interface{}) error } // NewAuthenticator returns a new identity provider based on its name. diff --git a/internal/kv/autocache/autocache.go b/internal/kv/autocache/autocache.go deleted file mode 100644 index 4de4d9d76..000000000 --- a/internal/kv/autocache/autocache.go +++ /dev/null @@ -1,247 +0,0 @@ -// Package autocache implements a key value store (kv.Store) using autocache -// which combines functionality from groupcache, and memberlist libraries. -// For more details, see https://github.com/pomerium/autocache -package autocache - -import ( - "context" - "errors" - "fmt" - stdlog "log" - "net/http" - "sync" - - "github.com/golang/groupcache" - - "github.com/pomerium/autocache" - "github.com/pomerium/pomerium/internal/httputil" - "github.com/pomerium/pomerium/internal/kv" - "github.com/pomerium/pomerium/internal/telemetry" - "github.com/pomerium/pomerium/internal/telemetry/metrics" - "github.com/pomerium/pomerium/internal/urlutil" -) - -// Name represents autocache's shorthand named. -const Name = "autocache" - -const defaultQueryParamKey = "ati" - -var _ kv.Store = &Store{} - -// Store implements a the store interface for autocache, a distributed cache -// with gossip based peer membership enrollment. -// https://github.com/pomerium/autocache -type Store struct { - db *groupcache.Group - cluster *autocache.Autocache - sharedKey string - srv *http.Server -} - -// ErrCacheMiss is returned when the cache misses for a given key. -var ErrCacheMiss = errors.New("cache miss") - -// Options represent autocache options. -type Options struct { - Addr string - CacheSize int64 - ClusterDomain string - GetterFn groupcache.GetterFunc - Group string - Log *stdlog.Logger - Port int - Scheme string - SharedKey string -} - -// DefaultOptions are the default options used by the autocache service. -var DefaultOptions = &Options{ - Addr: ":8333", - Port: 8333, - Scheme: "http", - CacheSize: 10 << 20, - Group: "default", - GetterFn: func(ctx context.Context, id string, dest groupcache.Sink) error { - b := fromContext(ctx) - if len(b) == 0 { - return fmt.Errorf("autocache: id %s : %w", id, ErrCacheMiss) - } - if err := dest.SetBytes(b); err != nil { - return fmt.Errorf("autocache: sink error %w", err) - } - return nil - }, -} - -// New creates a new autocache key value store. Autocache will start several -// services to support distributed cluster management and membership. -// A HTTP server will be used by groupcache to perform cross node-RPC. By -// default that server will start on port ``:8333`. -// Memberlist will likewise start and listen for group membership on port -// -// -// NOTE: RPC communication between nodes is _authenticated_ but not encrypted. -// NOTE: Groupchache starts a HTTP listener (Default: :8333) -// NOTE: Memberlist starts a GOSSIP listener on TCP/UDP. (Default: :7946) -func New(o *Options) (*Store, error) { - var s Store - var err error - if o.SharedKey == "" { - return nil, errors.New("autocache: shared secret must be set") - } - if o.Addr == "" { - o.Addr = DefaultOptions.Addr - } - if o.Scheme == "" { - o.Scheme = DefaultOptions.Scheme - } - if o.Port == 0 { - o.Port = DefaultOptions.Port - } - if o.Group == "" { - o.Group = DefaultOptions.Group - } - if o.GetterFn == nil { - o.GetterFn = DefaultOptions.GetterFn - } - if o.CacheSize == 0 { - o.CacheSize = DefaultOptions.CacheSize - } - if o.ClusterDomain == "" { - o.Log.Println("") - } - s.db = groupcache.NewGroup(o.Group, o.CacheSize, o.GetterFn) - s.cluster, err = autocache.New(&autocache.Options{ - PoolTransportFn: s.addSessionToCtx, - PoolScheme: o.Scheme, - PoolPort: o.Port, - Logger: o.Log, - }) - if err != nil { - return nil, err - } - serverOpts := &httputil.ServerOptions{Addr: o.Addr, Insecure: true, Service: Name} - var wg sync.WaitGroup - s.srv, err = httputil.NewServer(serverOpts, telemetry.HTTPStatsHandler("groupcache")(QueryParamToCtx(s.cluster)), &wg) - if err != nil { - return nil, err - } - if _, err := s.cluster.Join([]string{o.ClusterDomain}); err != nil { - return nil, err - } - metrics.AddGroupCacheMetrics(s.db) - return &s, nil -} - -// Set stores a key value pair. Since group cache actually only implements -// Get, we have to be a little creative in how we smuggle in value using -// context. -func (s Store) Set(ctx context.Context, k string, v []byte) error { - // smuggle the value pair as a context value - ctx = newContext(ctx, v) - if err := s.db.Get(ctx, k, groupcache.AllocatingByteSliceSink(&v)); err != nil { - return fmt.Errorf("autocache: set %s failed: %w", k, err) - } - return nil -} - -// Get retrieves the value for a key in the bucket. -func (s *Store) Get(ctx context.Context, k string) (bool, []byte, error) { - var value []byte - if err := s.db.Get(ctx, k, groupcache.AllocatingByteSliceSink(&value)); err != nil { - return false, nil, fmt.Errorf("autocache: get %s failed: %w", k, err) - } - return true, value, nil -} - -// Close shuts down any HTTP server used for groupcache pool, and -// also stop any background maintenance of memberlist. -func (s Store) Close(ctx context.Context) error { - var retErr error - if s.srv != nil { - if err := s.srv.Shutdown(ctx); err != nil { - retErr = fmt.Errorf("autocache: http shutdown error: %w", err) - } - } - if s.cluster.Memberlist != nil { - if err := s.cluster.Memberlist.Shutdown(); err != nil { - retErr = fmt.Errorf("autocache: memberlist shutdown error: %w", err) - } - } - return retErr -} - -// addSessionToCtx is a wrapper function that allows us to add a session -// into http client's round trip and sign the outgoing request. -func (s *Store) addSessionToCtx(ctx context.Context) http.RoundTripper { - var sh signedSession - sh.session = string(fromContext(ctx)) - sh.sharedKey = s.sharedKey - return sh -} - -type signedSession struct { - session string - sharedKey string -} - -// RoundTrip copies the request's session context and adds it to the -// outgoing client request as a query param. The whole URL is then signed for -// authenticity. -func (s signedSession) RoundTrip(req *http.Request) (*http.Response, error) { - // clone request before mutating - // https://golang.org/src/net/http/client.go?s=4306:5535#L105 - newReq := cloneRequest(req) - session := s.session - newReqURL := *newReq.URL - q := newReqURL.Query() - q.Set(defaultQueryParamKey, session) - newReqURL.RawQuery = q.Encode() - newReq.URL = urlutil.NewSignedURL(s.sharedKey, &newReqURL).Sign() - - tripper := telemetry.HTTPStatsRoundTripper("cache", "groupcache")(http.DefaultTransport) - return tripper.RoundTrip(newReq) -} - -// QueryParamToCtx takes a value from a query param and adds it to the -// current request request context. -func QueryParamToCtx(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - session := r.FormValue(defaultQueryParamKey) - ctx := newContext(r.Context(), []byte(session)) - next.ServeHTTP(w, r.WithContext(ctx)) - }) -} - -var sessionCtxKey = &contextKey{"PomeriumCachedSessionBytes"} - -type contextKey struct { - name string -} - -func newContext(ctx context.Context, b []byte) context.Context { - ctx = context.WithValue(ctx, sessionCtxKey, b) - return ctx -} - -func fromContext(ctx context.Context) []byte { - b, _ := ctx.Value(sessionCtxKey).([]byte) - return b -} - -func cloneRequest(req *http.Request) *http.Request { - r := new(http.Request) - *r = *req - r.Header = cloneHeaders(req.Header) - return r -} - -func cloneHeaders(in http.Header) http.Header { - out := make(http.Header, len(in)) - for key, values := range in { - newValues := make([]string, len(values)) - copy(newValues, values) - out[key] = newValues - } - return out -} diff --git a/internal/kv/bolt/bolt.go b/internal/kv/bolt/bolt.go deleted file mode 100644 index fee1486ed..000000000 --- a/internal/kv/bolt/bolt.go +++ /dev/null @@ -1,113 +0,0 @@ -// Package bolt implements a key value store (kv.Store) using bbolt. -// For more details, see https://github.com/etcd-io/bbolt -package bolt - -import ( - "context" - - bolt "go.etcd.io/bbolt" - - "github.com/pomerium/pomerium/internal/kv" - "github.com/pomerium/pomerium/internal/telemetry/metrics" -) - -var _ kv.Store = &Store{} - -// Name represents bbolt's shorthand named. -const Name = "bolt" - -// Store implements a the Store interface for bolt. -// https://godoc.org/github.com/etcd-io/bbolt -type Store struct { - db *bolt.DB - bucket string -} - -// Options represents options for configuring the boltdb cache store. -type Options struct { - // Buckets are collections of key/value pairs within the database. - // All keys in a bucket must be unique. - Bucket string - // Path is where the database file will be stored. - Path string -} - -// DefaultOptions contain's bolts default options. -var DefaultOptions = &Options{ - Bucket: "default", - Path: Name + ".db", -} - -// New creates a new bolt cache store. -// It is up to the operator to make sure that the store's path -// is writeable. -func New(o *Options) (*Store, error) { - if o.Path == "" { - o.Path = DefaultOptions.Path - } - if o.Bucket == "" { - o.Bucket = DefaultOptions.Bucket - } - - db, err := bolt.Open(o.Path, 0600, nil) - if err != nil { - return nil, err - } - - err = db.Update(func(tx *bolt.Tx) error { - _, err := tx.CreateBucketIfNotExists([]byte(o.Bucket)) - if err != nil { - return err - } - return nil - }) - if err != nil { - return nil, err - } - - metrics.AddBoltDBMetrics(db.Stats) - return &Store{db: db, bucket: o.Bucket}, nil -} - -// Set sets the value for a key in the bucket. -// If the key exist then its previous value will be overwritten. -// Supplied value must remain valid for the life of the transaction. -// Returns an error if the bucket was created from a read-only transaction, -// if the key is blank, if the key is too large, or if the value is too large. -func (s Store) Set(ctx context.Context, k string, v []byte) error { - err := s.db.Update(func(tx *bolt.Tx) error { - b := tx.Bucket([]byte(s.bucket)) - return b.Put([]byte(k), v) - }) - if err != nil { - return err - } - return nil -} - -// Get retrieves the value for a key in the bucket. -// Returns a nil value if the key does not exist or if the key is a nested bucket. -// The returned value is only valid for the life of the transaction. -func (s *Store) Get(ctx context.Context, k string) (bool, []byte, error) { - var value []byte - err := s.db.View(func(tx *bolt.Tx) error { - b := tx.Bucket([]byte(s.bucket)) - txData := b.Get([]byte(k)) // only valid in transaction - value = append(txData[:0:0], txData...) - return nil - }) - if err != nil { - return false, nil, err - } - if value == nil { - return false, nil, nil - } - return true, value, nil -} - -// Close releases all database resources. -// It will block waiting for any open transactions to finish -// before closing the database and returning. -func (s Store) Close(ctx context.Context) error { - return s.db.Close() -} diff --git a/internal/kv/redis/redis.go b/internal/kv/redis/redis.go deleted file mode 100644 index 97f238b97..000000000 --- a/internal/kv/redis/redis.go +++ /dev/null @@ -1,94 +0,0 @@ -// Package redis implements a key value store (kv.Store) using redis. -// For more details, see https://redis.io/ -package redis - -import ( - "context" - "crypto/tls" - "errors" - "fmt" - - "github.com/go-redis/redis/v7" - - "github.com/pomerium/pomerium/internal/kv" - "github.com/pomerium/pomerium/internal/telemetry/metrics" -) - -var _ kv.Store = &Store{} - -// Name represents redis's shorthand name. -const Name = "redis" - -// Store implements a the Store interface for redis. -// https://godoc.org/github.com/go-redis/redis -type Store struct { - db *redis.Client -} - -// Options represents options for configuring the redis store. -type Options struct { - // host:port Addr. - Addr string - // Optional password. Must match the password specified in the - // requirepass server configuration option. - Password string - // Database to be selected after connecting to the server. - DB int - // TLS Config to use. When set TLS will be negotiated. - TLSConfig *tls.Config -} - -// New creates a new redis cache store. -// It is up to the operator to make sure that the store's path -// is writeable. -func New(o *Options) (*Store, error) { - if o.Addr == "" { - return nil, fmt.Errorf("kv/redis: connection address is required") - } - - db := redis.NewClient( - &redis.Options{ - Addr: o.Addr, - Password: o.Password, - DB: o.DB, - TLSConfig: o.TLSConfig, - }) - - if _, err := db.Ping().Result(); err != nil { - return nil, fmt.Errorf("kv/redis: error connecting to redis: %w", err) - } - - metrics.AddRedisMetrics(db.PoolStats) - return &Store{db: db}, nil -} - -// Set is equivalent to redis `SET key value [expiration]` command. -// -// Use expiration for `SETEX`-like behavior. -// Zero expiration means the key has no expiration time. -func (s Store) Set(ctx context.Context, k string, v []byte) error { - if err := s.db.Set(k, string(v), 0).Err(); err != nil { - return err - } - return nil -} - -// Get is equivalent to Redis `GET key` command. -// It returns redis.Nil error when key does not exist. -func (s *Store) Get(ctx context.Context, k string) (bool, []byte, error) { - v, err := s.db.Get(k).Result() - if errors.Is(err, redis.Nil) { - return false, nil, nil - } else if err != nil { - return false, nil, err - } - return true, []byte(v), nil -} - -// Close closes the client, releasing any open resources. -// -// It is rare to Close a Client, as the Client is meant to be -// long-lived and shared between many goroutines. -func (s Store) Close(ctx context.Context) error { - return s.db.Close() -} diff --git a/internal/kv/store.go b/internal/kv/store.go deleted file mode 100644 index fb236d58e..000000000 --- a/internal/kv/store.go +++ /dev/null @@ -1,12 +0,0 @@ -// Package kv defines a Store interfaces that can be implemented by -// datastores to provide key value storage capabilities. -package kv - -import "context" - -// Store specifies a key value storage interface. -type Store interface { - Set(ctx context.Context, key string, value []byte) error - Get(ctx context.Context, key string) (keyExists bool, value []byte, err error) - Close(ctx context.Context) error -} diff --git a/internal/protoutil/protoutil.go b/internal/protoutil/protoutil.go new file mode 100644 index 000000000..46b339be5 --- /dev/null +++ b/internal/protoutil/protoutil.go @@ -0,0 +1,70 @@ +// Package protoutil contains helper functions for protobufs. +package protoutil + +import ( + "github.com/golang/protobuf/proto" + "github.com/golang/protobuf/ptypes" + "google.golang.org/protobuf/reflect/protoregistry" + "google.golang.org/protobuf/types/known/anypb" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +// AnyToInterface converts an any type into a go-native interface. +func AnyToInterface(any *anypb.Any) interface{} { + if any == nil { + return nil + } + + // basic wrapped types + switch any.GetTypeUrl() { + case "type.googleapis.com/google.protobuf.BoolValue": + var v wrapperspb.BoolValue + _ = ptypes.UnmarshalAny(any, &v) + return v.GetValue() + case "type.googleapis.com/google.protobuf.BytesValue": + var v wrapperspb.BytesValue + _ = ptypes.UnmarshalAny(any, &v) + return v.GetValue() + case "type.googleapis.com/google.protobuf.DoubleValue": + var v wrapperspb.DoubleValue + _ = ptypes.UnmarshalAny(any, &v) + return v.GetValue() + case "type.googleapis.com/google.protobuf.FloatValue": + var v wrapperspb.FloatValue + _ = ptypes.UnmarshalAny(any, &v) + return v.GetValue() + case "type.googleapis.com/google.protobuf.Int32Value": + var v wrapperspb.Int32Value + _ = ptypes.UnmarshalAny(any, &v) + return v.GetValue() + case "type.googleapis.com/google.protobuf.Int64Value": + var v wrapperspb.Int64Value + _ = ptypes.UnmarshalAny(any, &v) + return v.GetValue() + case "type.googleapis.com/google.protobuf.StringValue": + var v wrapperspb.StringValue + _ = ptypes.UnmarshalAny(any, &v) + return v.GetValue() + case "type.googleapis.com/google.protobuf.UInt32Value": + var v wrapperspb.UInt32Value + _ = ptypes.UnmarshalAny(any, &v) + return v.GetValue() + case "type.googleapis.com/google.protobuf.UInt64Value": + var v wrapperspb.UInt64Value + _ = ptypes.UnmarshalAny(any, &v) + return v.GetValue() + } + + // all other message types + messageType, err := protoregistry.GlobalTypes.FindMessageByURL(any.GetTypeUrl()) + if err != nil { + return nil + } + msg := proto.MessageV1(messageType.New()) + err = ptypes.UnmarshalAny(any, msg) + if err != nil { + return nil + } + + return msg +} diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go new file mode 100644 index 000000000..dc96a089c --- /dev/null +++ b/internal/scheduler/scheduler.go @@ -0,0 +1,85 @@ +// Package scheduler contains a priority queue based on time. +package scheduler + +import ( + "strings" + "time" + + "github.com/google/btree" +) + +var maxTime = time.Unix(1<<63-62135596801, 999999999) + +type itemByKey struct { + time time.Time + key string +} + +func (i itemByKey) Less(than btree.Item) bool { + return strings.Compare(i.key, than.(itemByKey).key) < 0 +} + +type itemByTime struct { + time time.Time + key string +} + +func (i itemByTime) Less(than btree.Item) bool { + xTime, yTime := i.time, than.(itemByTime).time + switch { + case xTime.Before(yTime): + return true + case yTime.Before(xTime): + return false + } + + return itemByKey(i).Less(itemByKey(than.(itemByTime))) +} + +// A Scheduler implements a priority queue based on time. The scheduler is not thread-safe for multiple writers. +type Scheduler struct { + byTime *btree.BTree + byKey *btree.BTree +} + +// New creates a new Scheduler. +func New() *Scheduler { + return &Scheduler{ + byTime: btree.New(8), + byKey: btree.New(8), + } +} + +// Add adds an item to the scheduler. +func (s *Scheduler) Add(due time.Time, key string) { + i := s.byKey.Get(itemByKey{key: key}) + if i != nil { + s.byTime.Delete(itemByTime(i.(itemByKey))) + } + s.byKey.ReplaceOrInsert(itemByKey{ + time: due, + key: key, + }) + s.byTime.ReplaceOrInsert(itemByTime{ + time: due, + key: key, + }) +} + +// Remove removes an item from the scheduler and de-schedules it. +func (s *Scheduler) Remove(key string) { + i := s.byKey.Get(itemByKey{key: key}) + if i != nil { + s.byKey.Delete(i) + s.byTime.Delete(itemByTime(i.(itemByKey))) + } +} + +// Next retrieves the next time an item is due. +func (s *Scheduler) Next() (time.Time, string) { + if s.byTime.Len() == 0 { + return maxTime, "" + } + item := s.byTime.Min().(itemByTime) + return item.time, item.key +} diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go new file mode 100644 index 000000000..1ddc52152 --- /dev/null +++ b/internal/scheduler/scheduler_test.go @@ -0,0 +1,39 @@ +package scheduler + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestScheduler(t *testing.T) { + tm1 := time.Date(2020, 6, 5, 12, 0, 0, 0, time.UTC) + tm2 := tm1.Add(time.Minute) + + s := New() + s.Add(tm2, "a") + { + tm, key := s.Next() + assert.Equal(t, tm2, tm) + assert.Equal(t, "a", key) + } + s.Add(tm1, "b") + { + tm, key := s.Next() + assert.Equal(t, tm1, tm) + assert.Equal(t, "b", key) + } + s.Remove("b") + { + tm, key := s.Next() + assert.Equal(t, tm2, tm) + assert.Equal(t, "a", key) + } + s.Remove("a") + { + tm, key := s.Next() + assert.Equal(t, maxTime, tm) + assert.Equal(t, "", key) + } +} diff --git a/internal/sessions/cookie/cookie_store_test.go b/internal/sessions/cookie/cookie_store_test.go index 33f4475ed..189452a37 100644 --- a/internal/sessions/cookie/cookie_store_test.go +++ b/internal/sessions/cookie/cookie_store_test.go @@ -106,11 +106,11 @@ func TestStore_SaveSession(t *testing.T) { wantErr bool wantLoadErr bool }{ - {"good", &sessions.State{Email: "user@domain.com", User: "user"}, ecjson.New(c), ecjson.New(c), false, false}, - {"bad cipher", &sessions.State{Email: "user@domain.com", User: "user"}, nil, nil, true, true}, - {"huge cookie", &sessions.State{Subject: fmt.Sprintf("%x", hugeString), Email: "user@domain.com", User: "user"}, ecjson.New(c), ecjson.New(c), false, false}, - {"marshal error", &sessions.State{Email: "user@domain.com", User: "user"}, mock.Encoder{MarshalError: errors.New("error")}, ecjson.New(c), true, true}, - {"nil encoder cannot save non string type", &sessions.State{Email: "user@domain.com", User: "user"}, nil, ecjson.New(c), true, true}, + {"good", &sessions.State{Version: "v1", ID: "xyz"}, ecjson.New(c), ecjson.New(c), false, false}, + {"bad cipher", &sessions.State{Version: "v1", ID: "xyz"}, nil, nil, true, true}, + {"huge cookie", &sessions.State{Version: "v1", ID: "xyz", Subject: fmt.Sprintf("%x", hugeString)}, ecjson.New(c), ecjson.New(c), false, false}, + {"marshal error", &sessions.State{Version: "v1", ID: "xyz"}, mock.Encoder{MarshalError: errors.New("error")}, ecjson.New(c), true, true}, + {"nil encoder cannot save non string type", &sessions.State{Version: "v1", ID: "xyz"}, nil, ecjson.New(c), true, true}, {"good marshal string directly", cryptutil.NewBase64Key(), nil, ecjson.New(c), false, true}, {"good marshal bytes directly", cryptutil.NewKey(), nil, ecjson.New(c), false, true}, } diff --git a/internal/sessions/cookie/middleware_test.go b/internal/sessions/cookie/middleware_test.go index 87ce90ee4..0e6eaeed1 100644 --- a/internal/sessions/cookie/middleware_test.go +++ b/internal/sessions/cookie/middleware_test.go @@ -11,9 +11,10 @@ import ( "github.com/pomerium/pomerium/internal/sessions" "github.com/google/go-cmp/cmp" + "gopkg.in/square/go-jose.v2/jwt" + "github.com/pomerium/pomerium/internal/cryptutil" "github.com/pomerium/pomerium/internal/encoding/ecjson" - "gopkg.in/square/go-jose.v2/jwt" ) func testAuthorizer(next http.Handler) http.Handler { @@ -41,7 +42,12 @@ func TestVerifier(t *testing.T) { wantBody string wantStatus int }{ - {"good cookie session", sessions.State{Email: "user@pomerium.io", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}, http.StatusText(http.StatusOK), http.StatusOK}, + { + "good cookie session", + sessions.State{Version: "v1", ID: "xyz", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}, + http.StatusText(http.StatusOK), + http.StatusOK, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/sessions/header/middleware_test.go b/internal/sessions/header/middleware_test.go index 36c7f4606..9f8bf2854 100644 --- a/internal/sessions/header/middleware_test.go +++ b/internal/sessions/header/middleware_test.go @@ -41,9 +41,9 @@ func TestVerifier(t *testing.T) { wantBody string wantStatus int }{ - {"good auth header session", "Bearer ", sessions.State{Email: "user@pomerium.io", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}, http.StatusText(http.StatusOK), http.StatusOK}, - {"empty auth header", "Bearer ", sessions.State{Email: "user@pomerium.io", Expiry: jwt.NewNumericDate(time.Now().Add(-10 * time.Minute))}, "internal/sessions: session is not found\n", http.StatusUnauthorized}, - {"bad auth type", "bees ", sessions.State{Email: "user@pomerium.io", Expiry: jwt.NewNumericDate(time.Now().Add(-10 * time.Minute))}, "internal/sessions: session is not found\n", http.StatusUnauthorized}, + {"good auth header session", "Bearer ", sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}, http.StatusText(http.StatusOK), http.StatusOK}, + {"empty auth header", "Bearer ", sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(-10 * time.Minute))}, "internal/sessions: session is not found\n", http.StatusUnauthorized}, + {"bad auth type", "bees ", sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(-10 * time.Minute))}, "internal/sessions: session is not found\n", http.StatusUnauthorized}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/sessions/middleware_test.go b/internal/sessions/middleware_test.go index 584d7a74e..adf58dac0 100644 --- a/internal/sessions/middleware_test.go +++ b/internal/sessions/middleware_test.go @@ -26,7 +26,7 @@ func TestNewContext(t *testing.T) { err error want context.Context }{ - {"simple", context.Background(), &sessions.State{Email: "bdd@pomerium.io"}, nil, nil}, + {"simple", context.Background(), &sessions.State{Version: "v1", ID: "xyz"}, nil, nil}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -45,9 +45,6 @@ func TestNewContext(t *testing.T) { if err != nil { t.Fatal(err) } - if diff := cmp.Diff(tt.t.Email, stateOut.Email); diff != "" { - t.Errorf("NewContext() = %s", diff) - } if diff := cmp.Diff(tt.err, errOut); diff != "" { t.Errorf("NewContext() = %s", diff) } @@ -79,9 +76,24 @@ func TestVerifier(t *testing.T) { state sessions.State wantStatus int }{ - {"empty session", mock.Store{LoadError: sessions.ErrNoSessionFound}, sessions.State{}, 401}, - {"simple good load", mock.Store{Session: &sessions.State{Subject: "hi", Expiry: jwt.NewNumericDate(time.Now().Add(time.Second))}}, sessions.State{}, 200}, - {"session error", mock.Store{LoadError: errors.New("err")}, sessions.State{}, 401}, + { + "empty session", + mock.Store{LoadError: sessions.ErrNoSessionFound}, + sessions.State{Version: "v1", ID: "xyz"}, + 401, + }, + { + "simple good load", + mock.Store{Session: &sessions.State{Version: "v1", ID: "xyz", Subject: "hi", Expiry: jwt.NewNumericDate(time.Now().Add(time.Second))}}, + sessions.State{Version: "v1", ID: "xyz"}, + 200, + }, + { + "session error", + mock.Store{LoadError: errors.New("err")}, + sessions.State{Version: "v1", ID: "xyz"}, + 401, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/sessions/mock/mock_store_test.go b/internal/sessions/mock/mock_store_test.go index c9642012a..f20d70322 100644 --- a/internal/sessions/mock/mock_store_test.go +++ b/internal/sessions/mock/mock_store_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/pomerium/pomerium/internal/sessions" ) @@ -23,7 +24,7 @@ func TestStore(t *testing.T) { SaveError: nil, LoadError: nil, }, - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6IiIsInByb2dyYW1hdGljIjpmYWxzZSwic3ViIjoiMDEwMSJ9.u0dzrEkbt-Bec7Rq85E8pbglE61D7UqGN33MFtfoCCM", + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwcm9ncmFtYXRpYyI6ZmFsc2UsInN1YiI6IjAxMDEifQ.xQQPXGN3q3j_CHbz6p9D-vZ1DaiPWwKdQhNxNHoYzvM", &sessions.State{Subject: "0101"}, false, false}, diff --git a/internal/sessions/queryparam/middleware_test.go b/internal/sessions/queryparam/middleware_test.go index 4fadb9e86..862403a42 100644 --- a/internal/sessions/queryparam/middleware_test.go +++ b/internal/sessions/queryparam/middleware_test.go @@ -41,8 +41,8 @@ func TestVerifier(t *testing.T) { wantBody string wantStatus int }{ - {"good auth query param session", sessions.State{Email: "user@pomerium.io", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}, http.StatusText(http.StatusOK), http.StatusOK}, - {"empty auth query param", sessions.State{Email: "user@pomerium.io", Expiry: jwt.NewNumericDate(time.Now().Add(-10 * time.Minute))}, "internal/sessions: session is not found\n", http.StatusUnauthorized}, + {"good auth query param session", sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}, http.StatusText(http.StatusOK), http.StatusOK}, + {"empty auth query param", sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(-10 * time.Minute))}, "internal/sessions: session is not found\n", http.StatusUnauthorized}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/sessions/queryparam/query_store_test.go b/internal/sessions/queryparam/query_store_test.go index 70f30e359..36f82b176 100644 --- a/internal/sessions/queryparam/query_store_test.go +++ b/internal/sessions/queryparam/query_store_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/pomerium/pomerium/internal/encoding" "github.com/pomerium/pomerium/internal/encoding/mock" "github.com/pomerium/pomerium/internal/sessions" @@ -23,8 +24,8 @@ func TestNewQueryParamStore(t *testing.T) { wantErr bool wantURL *url.URL }{ - {"simple good", &sessions.State{Email: "user@domain.com", User: "user"}, mock.Encoder{MarshalResponse: []byte("ok")}, "", false, &url.URL{Path: "/", RawQuery: "pomerium_session=ok"}}, - {"marshall error", &sessions.State{Email: "user@domain.com", User: "user"}, mock.Encoder{MarshalError: errors.New("error")}, "", true, &url.URL{Path: "/"}}, + {"simple good", &sessions.State{}, mock.Encoder{MarshalResponse: []byte("ok")}, "", false, &url.URL{Path: "/", RawQuery: "pomerium_session=ok"}}, + {"marshall error", &sessions.State{}, mock.Encoder{MarshalError: errors.New("error")}, "", true, &url.URL{Path: "/"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/sessions/state.go b/internal/sessions/state.go index a889b6d2a..5a5feb7e7 100644 --- a/internal/sessions/state.go +++ b/internal/sessions/state.go @@ -2,15 +2,16 @@ package sessions import ( "encoding/json" - "fmt" + "errors" "strings" "time" - "github.com/pomerium/pomerium/internal/hashutil" - "golang.org/x/oauth2" "gopkg.in/square/go-jose.v2/jwt" ) +// ErrMissingID is the error for a session state that has no ID set. +var ErrMissingID = errors.New("invalid session: missing id") + // timeNow is time.Now but pulled out as a variable for tests. var timeNow = time.Now @@ -24,23 +25,7 @@ type State struct { NotBefore *jwt.NumericDate `json:"nbf,omitempty"` IssuedAt *jwt.NumericDate `json:"iat,omitempty"` ID string `json:"jti,omitempty"` - // At_hash is an OPTIONAL Access Token hash value - // https://ldapwiki.com/wiki/At_hash - AccessTokenHash string `json:"at_hash,omitempty"` - - // core pomerium identity claims ; not standard to RFC 7519 - Email string `json:"email"` - Groups []string `json:"groups,omitempty"` - User string `json:"user,omitempty"` // google - - // commonly supported IdP information - // https://www.iana.org/assignments/jwt/jwt.xhtml#claims - Name string `json:"name,omitempty"` // google - GivenName string `json:"given_name,omitempty"` // google - FamilyName string `json:"family_name,omitempty"` // google - Picture string `json:"picture,omitempty"` // google - EmailVerified bool `json:"email_verified,omitempty"` // google - Nickname string `json:"nickname,omitempty"` // gitlab + Version string `json:"ver,omitempty"` // Impersonate-able fields ImpersonateEmail string `json:"impersonate_email,omitempty"` @@ -53,14 +38,12 @@ type State struct { // NewSession updates issuer, audience, and issuance timestamps but keeps // parent expiry. -func NewSession(s *State, issuer string, audience []string, accessToken *oauth2.Token) State { +func NewSession(s *State, issuer string, audience []string) State { newState := *s newState.IssuedAt = jwt.NewNumericDate(timeNow()) newState.NotBefore = newState.IssuedAt newState.Audience = audience newState.Issuer = issuer - newState.AccessTokenHash = fmt.Sprintf("%x", hashutil.Hash(accessToken)) - newState.Expiry = jwt.NewNumericDate(accessToken.Expiry) return newState } @@ -97,8 +80,10 @@ func (s *State) UnmarshalJSON(data []byte) error { if err := json.Unmarshal(data, &a); err != nil { return err } - if a.User == "" { - a.User = a.Subject + + if s.ID == "" { + return ErrMissingID } + return nil } diff --git a/internal/sessions/state_test.go b/internal/sessions/state_test.go index ce86bb696..3cb807a41 100644 --- a/internal/sessions/state_test.go +++ b/internal/sessions/state_test.go @@ -15,25 +15,20 @@ func TestState_Impersonating(t *testing.T) { t.Parallel() tests := []struct { name string - Email string - Groups []string ImpersonateEmail string ImpersonateGroups []string want bool wantResponseEmail string wantResponseGroups string }{ - {"impersonating", "actual@user.com", []string{"actual-group"}, "impersonating@user.com", []string{"impersonating-group"}, true, "impersonating@user.com", "impersonating-group"}, - {"not impersonating", "actual@user.com", []string{"actual-group"}, "", []string{}, false, "actual@user.com", "actual-group"}, - {"impersonating user only", "actual@user.com", []string{"actual-group"}, "impersonating@user.com", []string{}, true, "impersonating@user.com", "actual-group"}, - {"impersonating group only", "actual@user.com", []string{"actual-group"}, "", []string{"impersonating-group"}, true, "actual@user.com", "impersonating-group"}, + {"impersonating", "impersonating@user.com", []string{"impersonating-group"}, true, "impersonating@user.com", "impersonating-group"}, + {"not impersonating", "", []string{}, false, "actual@user.com", "actual-group"}, + {"impersonating user only", "impersonating@user.com", []string{}, true, "impersonating@user.com", "actual-group"}, + {"impersonating group only", "", []string{"impersonating-group"}, true, "actual@user.com", "impersonating-group"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - s := &State{ - Email: tt.Email, - Groups: tt.Groups, - } + s := &State{} s.SetImpersonation(tt.ImpersonateEmail, strings.Join(tt.ImpersonateGroups, ",")) if got := s.Impersonating(); got != tt.want { t.Errorf("State.Impersonating() = %v, want %v", got, tt.want) @@ -85,9 +80,30 @@ func TestState_UnmarshalJSON(t *testing.T) { want State wantErr bool }{ - {"good", &State{}, State{NotBefore: jwt.NewNumericDate(fixedTime), IssuedAt: jwt.NewNumericDate(fixedTime), AccessTokenHash: "bbd82197d215198f"}, false}, - {"with user", &State{User: "user"}, State{User: "user", NotBefore: jwt.NewNumericDate(fixedTime), IssuedAt: jwt.NewNumericDate(fixedTime), AccessTokenHash: "bbd82197d215198f"}, false}, - {"without", &State{Subject: "user"}, State{User: "user", Subject: "user", NotBefore: jwt.NewNumericDate(fixedTime), IssuedAt: jwt.NewNumericDate(fixedTime), AccessTokenHash: "bbd82197d215198f"}, false}, + { + "good", + &State{ID: "xyz"}, + State{ID: "xyz", NotBefore: jwt.NewNumericDate(fixedTime), IssuedAt: jwt.NewNumericDate(fixedTime)}, + false, + }, + { + "with user", + &State{ID: "xyz"}, + State{ID: "xyz", NotBefore: jwt.NewNumericDate(fixedTime), IssuedAt: jwt.NewNumericDate(fixedTime)}, + false, + }, + { + "without", + &State{ID: "xyz", Subject: "user"}, + State{ID: "xyz", Subject: "user", NotBefore: jwt.NewNumericDate(fixedTime), IssuedAt: jwt.NewNumericDate(fixedTime)}, + false, + }, + { + "missing id", + &State{}, + State{NotBefore: jwt.NewNumericDate(fixedTime), IssuedAt: jwt.NewNumericDate(fixedTime)}, + true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -96,11 +112,11 @@ func TestState_UnmarshalJSON(t *testing.T) { t.Fatal(err) } - s := NewSession(&State{}, "", nil, &oauth2.Token{}) + s := NewSession(&State{}, "", nil) if err := s.UnmarshalJSON(data); (err != nil) != tt.wantErr { t.Errorf("State.UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr) } - if diff := cmp.Diff(s, tt.want); diff != "" { + if diff := cmp.Diff(tt.want, s); diff != "" { t.Errorf("State.UnmarshalJSON() error = %v", diff) } }) diff --git a/proxy/forward_auth_test.go b/proxy/forward_auth_test.go index 7ddbaeea9..067307193 100644 --- a/proxy/forward_auth_test.go +++ b/proxy/forward_auth_test.go @@ -60,23 +60,23 @@ func TestProxy_ForwardAuth(t *testing.T) { wantStatus int wantBody string }{ - {"good redirect not required", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/", "https://some.domain.example", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, allowClient, http.StatusOK, "Access to some.domain.example is allowed."}, - {"good verify only, no redirect", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/verify", "https://some.domain.example", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, allowClient, http.StatusOK, ""}, - {"bad empty domain uri", opts, nil, http.MethodGet, nil, map[string]string{"uri": ""}, "https://some.domain.example/", "", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, allowClient, http.StatusBadRequest, "{\"Status\":400,\"Error\":\"Bad Request: no uri to validate\"}\n"}, - {"bad naked domain uri", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/", "a.naked.domain", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, allowClient, http.StatusBadRequest, "{\"Status\":400,\"Error\":\"Bad Request: a.naked.domain url does contain a valid scheme\"}\n"}, - {"bad naked domain uri verify only", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/verify", "a.naked.domain", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, allowClient, http.StatusBadRequest, "{\"Status\":400,\"Error\":\"Bad Request: a.naked.domain url does contain a valid scheme\"}\n"}, - {"bad empty verification uri", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/", " ", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, allowClient, http.StatusBadRequest, "{\"Status\":400,\"Error\":\"Bad Request: %20 url does contain a valid scheme\"}\n"}, - {"bad empty verification uri verify only", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/verify", " ", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, allowClient, http.StatusBadRequest, "{\"Status\":400,\"Error\":\"Bad Request: %20 url does contain a valid scheme\"}\n"}, + {"good redirect not required", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/", "https://some.domain.example", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, allowClient, http.StatusOK, "Access to some.domain.example is allowed."}, + {"good verify only, no redirect", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/verify", "https://some.domain.example", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, allowClient, http.StatusOK, ""}, + {"bad empty domain uri", opts, nil, http.MethodGet, nil, map[string]string{"uri": ""}, "https://some.domain.example/", "", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, allowClient, http.StatusBadRequest, "{\"Status\":400,\"Error\":\"Bad Request: no uri to validate\"}\n"}, + {"bad naked domain uri", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/", "a.naked.domain", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, allowClient, http.StatusBadRequest, "{\"Status\":400,\"Error\":\"Bad Request: a.naked.domain url does contain a valid scheme\"}\n"}, + {"bad naked domain uri verify only", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/verify", "a.naked.domain", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, allowClient, http.StatusBadRequest, "{\"Status\":400,\"Error\":\"Bad Request: a.naked.domain url does contain a valid scheme\"}\n"}, + {"bad empty verification uri", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/", " ", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, allowClient, http.StatusBadRequest, "{\"Status\":400,\"Error\":\"Bad Request: %20 url does contain a valid scheme\"}\n"}, + {"bad empty verification uri verify only", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/verify", " ", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, allowClient, http.StatusBadRequest, "{\"Status\":400,\"Error\":\"Bad Request: %20 url does contain a valid scheme\"}\n"}, // traefik - {"good traefik callback", opts, nil, http.MethodGet, map[string]string{httputil.HeaderForwardedURI: "https://some.domain.example?" + urlutil.QuerySessionEncrypted + "=" + goodEncryptionString}, nil, "https://some.domain.example/", "https://some.domain.example", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, allowClient, http.StatusFound, ""}, - {"bad traefik callback bad session", opts, nil, http.MethodGet, map[string]string{httputil.HeaderForwardedURI: "https://some.domain.example?" + urlutil.QuerySessionEncrypted + "=" + goodEncryptionString + "garbage"}, nil, "https://some.domain.example/", "https://some.domain.example", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, allowClient, http.StatusBadRequest, ""}, - {"bad traefik callback bad url", opts, nil, http.MethodGet, map[string]string{httputil.HeaderForwardedURI: urlutil.QuerySessionEncrypted + ""}, nil, "https://some.domain.example/", "https://some.domain.example", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, allowClient, http.StatusBadRequest, ""}, - {"good traefik verify uri from headers", opts, nil, http.MethodGet, map[string]string{httputil.HeaderForwardedProto: "https", httputil.HeaderForwardedHost: "some.domain.example:8080"}, nil, "https://some.domain.example/", "", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, allowClient, http.StatusOK, ""}, + {"good traefik callback", opts, nil, http.MethodGet, map[string]string{httputil.HeaderForwardedURI: "https://some.domain.example?" + urlutil.QuerySessionEncrypted + "=" + goodEncryptionString}, nil, "https://some.domain.example/", "https://some.domain.example", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, allowClient, http.StatusFound, ""}, + {"bad traefik callback bad session", opts, nil, http.MethodGet, map[string]string{httputil.HeaderForwardedURI: "https://some.domain.example?" + urlutil.QuerySessionEncrypted + "=" + goodEncryptionString + "garbage"}, nil, "https://some.domain.example/", "https://some.domain.example", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, allowClient, http.StatusBadRequest, ""}, + {"bad traefik callback bad url", opts, nil, http.MethodGet, map[string]string{httputil.HeaderForwardedURI: urlutil.QuerySessionEncrypted + ""}, nil, "https://some.domain.example/", "https://some.domain.example", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, allowClient, http.StatusBadRequest, ""}, + {"good traefik verify uri from headers", opts, nil, http.MethodGet, map[string]string{httputil.HeaderForwardedProto: "https", httputil.HeaderForwardedHost: "some.domain.example:8080"}, nil, "https://some.domain.example/", "", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, allowClient, http.StatusOK, ""}, // // nginx - {"good nginx callback redirect", opts, nil, http.MethodGet, nil, map[string]string{urlutil.QueryRedirectURI: "https://some.domain.example/", urlutil.QuerySessionEncrypted: goodEncryptionString}, "https://some.domain.example/", "https://some.domain.example", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, allowClient, http.StatusFound, ""}, - {"good nginx callback set session okay but return unauthorized", opts, nil, http.MethodGet, nil, map[string]string{urlutil.QueryRedirectURI: "https://some.domain.example/", urlutil.QuerySessionEncrypted: goodEncryptionString}, "https://some.domain.example/verify", "https://some.domain.example", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, allowClient, http.StatusUnauthorized, ""}, - {"bad nginx callback failed to set session", opts, nil, http.MethodGet, nil, map[string]string{urlutil.QueryRedirectURI: "https://some.domain.example/", urlutil.QuerySessionEncrypted: goodEncryptionString + "nope"}, "https://some.domain.example/verify", "https://some.domain.example", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, allowClient, http.StatusBadRequest, ""}, + {"good nginx callback redirect", opts, nil, http.MethodGet, nil, map[string]string{urlutil.QueryRedirectURI: "https://some.domain.example/", urlutil.QuerySessionEncrypted: goodEncryptionString}, "https://some.domain.example/", "https://some.domain.example", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, allowClient, http.StatusFound, ""}, + {"good nginx callback set session okay but return unauthorized", opts, nil, http.MethodGet, nil, map[string]string{urlutil.QueryRedirectURI: "https://some.domain.example/", urlutil.QuerySessionEncrypted: goodEncryptionString}, "https://some.domain.example/verify", "https://some.domain.example", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, allowClient, http.StatusUnauthorized, ""}, + {"bad nginx callback failed to set session", opts, nil, http.MethodGet, nil, map[string]string{urlutil.QueryRedirectURI: "https://some.domain.example/", urlutil.QuerySessionEncrypted: goodEncryptionString + "nope"}, "https://some.domain.example/verify", "https://some.domain.example", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, allowClient, http.StatusBadRequest, ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/proxy/handlers.go b/proxy/handlers.go index 4599f23d4..8869fa627 100644 --- a/proxy/handlers.go +++ b/proxy/handlers.go @@ -18,7 +18,7 @@ import ( // registerDashboardHandlers returns the proxy service's ServeMux func (p *Proxy) registerDashboardHandlers(r *mux.Router) *mux.Router { - h := r.PathPrefix(dashboardURL).Subrouter() + h := r.PathPrefix(dashboardPath).Subrouter() h.Use(middleware.SetHeaders(httputil.HeadersContentSecurityPolicy)) // 1. Retrieve the user session and add it to the request context h.Use(sessions.RetrieveSession(p.sessionStore)) @@ -32,7 +32,7 @@ func (p *Proxy) registerDashboardHandlers(r *mux.Router) *mux.Router { csrf.ErrorHandler(httputil.HandlerFunc(httputil.CSRFFailureHandler)), )) // dashboard endpoints can be used by user's to view, or modify their session - h.Path("/").Handler(httputil.HandlerFunc(p.UserDashboard)).Methods(http.MethodGet) + h.Path("/").HandlerFunc(p.UserDashboard).Methods(http.MethodGet) h.Path("/sign_out").HandlerFunc(p.SignOut).Methods(http.MethodGet, http.MethodPost) // admin endpoints authorization is also delegated to authorizer service admin := h.PathPrefix("/admin").Subrouter() @@ -41,7 +41,7 @@ func (p *Proxy) registerDashboardHandlers(r *mux.Router) *mux.Router { // Authenticate service callback handlers and middleware // callback used to set route-scoped session and redirect back to destination // only accept signed requests (hmac) from other trusted pomerium services - c := r.PathPrefix(dashboardURL + "/callback").Subrouter() + c := r.PathPrefix(dashboardPath + "/callback").Subrouter() c.Use(middleware.ValidateSignature(p.SharedKey)) c.Path("/"). @@ -51,7 +51,7 @@ func (p *Proxy) registerDashboardHandlers(r *mux.Router) *mux.Router { c.Path("/").Handler(httputil.HandlerFunc(p.Callback)).Methods(http.MethodGet) // Programmatic API handlers and middleware - a := r.PathPrefix(dashboardURL + "/api").Subrouter() + a := r.PathPrefix(dashboardPath + "/api").Subrouter() // login api handler generates a user-navigable login url to authenticate a.Path("/v1/login").Handler(httputil.HandlerFunc(p.ProgrammaticLogin)). Queries(urlutil.QueryRedirectURI, ""). @@ -85,28 +85,19 @@ func (p *Proxy) SignOut(w http.ResponseWriter, r *http.Request) { httputil.Redirect(w, r, urlutil.NewSignedURL(p.SharedKey, &signoutURL).String(), http.StatusFound) } -// UserDashboard lets users investigate, and refresh their current session. -// It also contains certain administrative actions like user impersonation. -// -// Nota bene: This endpoint does authentication, not authorization. -func (p *Proxy) UserDashboard(w http.ResponseWriter, r *http.Request) error { - jwt, err := sessions.FromContext(r.Context()) - if err != nil { - return err - } - var s sessions.State - if err := p.encoder.Unmarshal([]byte(jwt), &s); err != nil { - return httputil.NewError(http.StatusBadRequest, err) +// UserDashboard redirects to the authenticate dasbhoard. +func (p *Proxy) UserDashboard(w http.ResponseWriter, r *http.Request) { + redirectURL := urlutil.GetAbsoluteURL(r).String() + if ref := r.Header.Get(httputil.HeaderReferrer); ref != "" { + redirectURL = ref } - p.templates.ExecuteTemplate(w, "dashboard.html", map[string]interface{}{ - "Session": s, - "csrfField": csrf.TemplateField(r), - "ImpersonateAction": urlutil.QueryImpersonateAction, - "ImpersonateEmail": urlutil.QueryImpersonateEmail, - "ImpersonateGroups": urlutil.QueryImpersonateGroups, + url := p.authenticateDashboardURL.ResolveReference(&url.URL{ + RawQuery: url.Values{ + urlutil.QueryRedirectURI: {redirectURL}, + }.Encode(), }) - return nil + httputil.Redirect(w, r, url.String(), http.StatusFound) } // Impersonate takes the result of a form and adds user impersonation details @@ -114,7 +105,7 @@ func (p *Proxy) UserDashboard(w http.ResponseWriter, r *http.Request) error { // administrative user. Requests are redirected back to the user dashboard. func (p *Proxy) Impersonate(w http.ResponseWriter, r *http.Request) error { redirectURL := urlutil.GetAbsoluteURL(r) - redirectURL.Path = dashboardURL // redirect back to the dashboard + redirectURL.Path = dashboardPath // redirect back to the dashboard signinURL := *p.authenticateSigninURL q := signinURL.Query() q.Set(urlutil.QueryRedirectURI, redirectURL.String()) @@ -168,7 +159,7 @@ func (p *Proxy) ProgrammaticLogin(w http.ResponseWriter, r *http.Request) error } signinURL := *p.authenticateSigninURL callbackURI := urlutil.GetAbsoluteURL(r) - callbackURI.Path = dashboardURL + "/callback/" + callbackURI.Path = dashboardPath + "/callback/" q := signinURL.Query() q.Set(urlutil.QueryCallbackURI, callbackURI.String()) q.Set(urlutil.QueryRedirectURI, redirectURI.String()) diff --git a/proxy/handlers_test.go b/proxy/handlers_test.go index c7beb00aa..f4044133e 100644 --- a/proxy/handlers_test.go +++ b/proxy/handlers_test.go @@ -66,58 +66,6 @@ func TestProxy_Signout(t *testing.T) { } } -func TestProxy_UserDashboard(t *testing.T) { - opts := testOptions(t) - tests := []struct { - name string - ctxError error - options config.Options - method string - cipher encoding.MarshalUnmarshaler - session sessions.SessionStore - authorizer client.Authorizer - - wantAdminForm bool - wantStatus int - }{ - {"good", nil, opts, http.MethodGet, &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{}, true, http.StatusOK}, - {"session context error", errors.New("error"), opts, http.MethodGet, &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{}, false, http.StatusInternalServerError}, - {"bad encoder unmarshal", nil, opts, http.MethodGet, &mock.Encoder{UnmarshalError: errors.New("err")}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{}, false, http.StatusBadRequest}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - p, err := New(tt.options) - if err != nil { - t.Fatal(err) - } - p.encoder = tt.cipher - p.sessionStore = tt.session - - r := httptest.NewRequest(tt.method, "/", nil) - state, _ := tt.session.LoadSession(r) - ctx := r.Context() - ctx = sessions.NewContext(ctx, state, tt.ctxError) - r = r.WithContext(ctx) - - r.Header.Set("Accept", "application/json") - - w := httptest.NewRecorder() - httputil.HandlerFunc(p.UserDashboard).ServeHTTP(w, r) - if status := w.Code; status != tt.wantStatus { - t.Errorf("status code: got %v want %v", status, tt.wantStatus) - t.Errorf("\n%+v", opts) - t.Errorf("\n%+v", w.Body.String()) - - } - if adminForm := strings.Contains(w.Body.String(), "impersonate"); adminForm != tt.wantAdminForm { - t.Errorf("wanted admin form got %v want %v", adminForm, tt.wantAdminForm) - t.Errorf("\n%+v", w.Body.String()) - } - }) - } -} - func TestProxy_Impersonate(t *testing.T) { t.Parallel() opts := testOptions(t) @@ -135,8 +83,8 @@ func TestProxy_Impersonate(t *testing.T) { authorizer client.Authorizer wantStatus int }{ - {"good", false, opts, nil, http.MethodPost, "user@blah.com", "", "", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Email: "user@test.example"}}, client.MockAuthorize{IsAdminResponse: true}, http.StatusFound}, - {"bad session state", false, opts, errors.New("error"), http.MethodPost, "user@blah.com", "", "", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Email: "user@test.example"}}, client.MockAuthorize{IsAdminResponse: true}, http.StatusFound}, + {"good", false, opts, nil, http.MethodPost, "user@blah.com", "", "", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{}}, client.MockAuthorize{IsAdminResponse: true}, http.StatusFound}, + {"bad session state", false, opts, errors.New("error"), http.MethodPost, "user@blah.com", "", "", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{}}, client.MockAuthorize{IsAdminResponse: true}, http.StatusFound}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -240,12 +188,12 @@ func TestProxy_Callback(t *testing.T) { wantStatus int wantBody string }{ - {"good", opts, http.MethodGet, "http", "example.com", "/", nil, map[string]string{urlutil.QueryCallbackURI: "ok", urlutil.QuerySessionEncrypted: goodEncryptionString}, &mock.Encoder{MarshalResponse: []byte("x")}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: true}}, http.StatusFound, ""}, - {"good programmatic", opts, http.MethodGet, "http", "example.com", "/", nil, map[string]string{urlutil.QueryIsProgrammatic: "true", urlutil.QueryCallbackURI: "ok", urlutil.QuerySessionEncrypted: goodEncryptionString}, &mock.Encoder{MarshalResponse: []byte("x")}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: true}}, http.StatusFound, ""}, - {"bad decrypt", opts, http.MethodGet, "http", "example.com", "/", nil, map[string]string{urlutil.QuerySessionEncrypted: "KBEjQ9rnCxaAX-GOqexGw9ivEQURqts3zZ2mNGy0wnVa3SbtM399KlBq2nZ-9wM21FfsZX52er4jlmC7kPEKM3P7uZ41zR0zeys1-_74a5tQp-vsf1WXZfRsgVOuBcWPkMiWEoc379JFHxGDudp5VhU8B-dcQt4f3_PtLTHARkuH54io1Va2gNMq4Hiy8sQ1MPGCQeltH_JMzzdDpXdmdusWrXUvCGkba24muvAV06D8XRVJj6Iu9eK94qFnqcHc7wzziEbb8ADBues9dwbtb6jl8vMWz5rN6XvXqA5YpZv_MQZlsrO4oXFFQDevdgB84cX1tVbVu6qZvK_yQBZqzpOjWA9uIaoSENMytoXuWAlFO_sXjswfX8JTNdGwzB7qQRNPqxVG_sM_tzY3QhPm8zqwEzsXG5DokxZfVt2I5WJRUEovFDb4BnK9KFnnkEzLEdMudixVnXeGmTtycgJvoTeTCQRPfDYkcgJ7oKf4tGea-W7z5UAVa2RduJM9ZoM6YtJX7jgDm__PvvqcE0knJUF87XHBzdcOjoDF-CUze9xDJgNBlvPbJqVshKrwoqSYpePSDH9GUCNKxGequW3Ma8GvlFfhwd0rK6IZG-XWkyk0XSWQIGkDSjAvhB1wsOusCCguDjbpVZpaW5MMyTkmx68pl6qlIKT5UCcrVPl4ix5ZEj91mUDF0O1t04haD7VZuLVFXVGmqtFrBKI76sdYN-zkokaa1_chPRTyqMQFlqu_8LD6-RiK3UccGM-dEmnX72i91NP9F9OK0WJr9Cheup1C_P0mjqAO4Cb8oIHm0Oxz_mRqv5QbTGJtb3xwPLPuVjVCiE4gGBcuU2ixpSVf5HUF7y1KicVMCKiX9ATCBtg8sTdQZQnPEtHcHHAvdsnDVwev1LGfqA-Gdvg="}, &mock.Encoder{MarshalResponse: []byte("x")}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: true}}, http.StatusBadRequest, ""}, + {"good", opts, http.MethodGet, "http", "example.com", "/", nil, map[string]string{urlutil.QueryCallbackURI: "ok", urlutil.QuerySessionEncrypted: goodEncryptionString}, &mock.Encoder{MarshalResponse: []byte("x")}, &mstore.Store{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: true}}, http.StatusFound, ""}, + {"good programmatic", opts, http.MethodGet, "http", "example.com", "/", nil, map[string]string{urlutil.QueryIsProgrammatic: "true", urlutil.QueryCallbackURI: "ok", urlutil.QuerySessionEncrypted: goodEncryptionString}, &mock.Encoder{MarshalResponse: []byte("x")}, &mstore.Store{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: true}}, http.StatusFound, ""}, + {"bad decrypt", opts, http.MethodGet, "http", "example.com", "/", nil, map[string]string{urlutil.QuerySessionEncrypted: "KBEjQ9rnCxaAX-GOqexGw9ivEQURqts3zZ2mNGy0wnVa3SbtM399KlBq2nZ-9wM21FfsZX52er4jlmC7kPEKM3P7uZ41zR0zeys1-_74a5tQp-vsf1WXZfRsgVOuBcWPkMiWEoc379JFHxGDudp5VhU8B-dcQt4f3_PtLTHARkuH54io1Va2gNMq4Hiy8sQ1MPGCQeltH_JMzzdDpXdmdusWrXUvCGkba24muvAV06D8XRVJj6Iu9eK94qFnqcHc7wzziEbb8ADBues9dwbtb6jl8vMWz5rN6XvXqA5YpZv_MQZlsrO4oXFFQDevdgB84cX1tVbVu6qZvK_yQBZqzpOjWA9uIaoSENMytoXuWAlFO_sXjswfX8JTNdGwzB7qQRNPqxVG_sM_tzY3QhPm8zqwEzsXG5DokxZfVt2I5WJRUEovFDb4BnK9KFnnkEzLEdMudixVnXeGmTtycgJvoTeTCQRPfDYkcgJ7oKf4tGea-W7z5UAVa2RduJM9ZoM6YtJX7jgDm__PvvqcE0knJUF87XHBzdcOjoDF-CUze9xDJgNBlvPbJqVshKrwoqSYpePSDH9GUCNKxGequW3Ma8GvlFfhwd0rK6IZG-XWkyk0XSWQIGkDSjAvhB1wsOusCCguDjbpVZpaW5MMyTkmx68pl6qlIKT5UCcrVPl4ix5ZEj91mUDF0O1t04haD7VZuLVFXVGmqtFrBKI76sdYN-zkokaa1_chPRTyqMQFlqu_8LD6-RiK3UccGM-dEmnX72i91NP9F9OK0WJr9Cheup1C_P0mjqAO4Cb8oIHm0Oxz_mRqv5QbTGJtb3xwPLPuVjVCiE4gGBcuU2ixpSVf5HUF7y1KicVMCKiX9ATCBtg8sTdQZQnPEtHcHHAvdsnDVwev1LGfqA-Gdvg="}, &mock.Encoder{MarshalResponse: []byte("x")}, &mstore.Store{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: true}}, http.StatusBadRequest, ""}, {"bad save session", opts, http.MethodGet, "http", "example.com", "/", nil, map[string]string{urlutil.QuerySessionEncrypted: goodEncryptionString}, &mock.Encoder{MarshalResponse: []byte("x")}, &mstore.Store{SaveError: errors.New("hi")}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: true}}, http.StatusBadRequest, ""}, - {"bad base64", opts, http.MethodGet, "http", "example.com", "/", nil, map[string]string{urlutil.QuerySessionEncrypted: "^"}, &mock.Encoder{MarshalResponse: []byte("x")}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: true}}, http.StatusBadRequest, ""}, - {"malformed redirect", opts, http.MethodGet, "http", "example.com", "/", nil, nil, &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: true}}, http.StatusBadRequest, ""}, + {"bad base64", opts, http.MethodGet, "http", "example.com", "/", nil, map[string]string{urlutil.QuerySessionEncrypted: "^"}, &mock.Encoder{MarshalResponse: []byte("x")}, &mstore.Store{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: true}}, http.StatusBadRequest, ""}, + {"malformed redirect", opts, http.MethodGet, "http", "example.com", "/", nil, nil, &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: true}}, http.StatusBadRequest, ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -380,12 +328,12 @@ func TestProxy_ProgrammaticCallback(t *testing.T) { wantStatus int wantBody string }{ - {"good", opts, http.MethodGet, "http://pomerium.io/", nil, map[string]string{urlutil.QueryCallbackURI: "ok", urlutil.QuerySessionEncrypted: goodEncryptionString}, &mock.Encoder{MarshalResponse: []byte("x")}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: true}}, http.StatusFound, ""}, - {"good programmatic", opts, http.MethodGet, "http://pomerium.io/", nil, map[string]string{urlutil.QueryIsProgrammatic: "true", urlutil.QueryCallbackURI: "ok", urlutil.QuerySessionEncrypted: goodEncryptionString}, &mock.Encoder{MarshalResponse: []byte("x")}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: true}}, http.StatusFound, ""}, - {"bad decrypt", opts, http.MethodGet, "http://pomerium.io/", nil, map[string]string{urlutil.QuerySessionEncrypted: goodEncryptionString + cryptutil.NewBase64Key()}, &mock.Encoder{MarshalResponse: []byte("x")}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: true}}, http.StatusBadRequest, ""}, + {"good", opts, http.MethodGet, "http://pomerium.io/", nil, map[string]string{urlutil.QueryCallbackURI: "ok", urlutil.QuerySessionEncrypted: goodEncryptionString}, &mock.Encoder{MarshalResponse: []byte("x")}, &mstore.Store{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: true}}, http.StatusFound, ""}, + {"good programmatic", opts, http.MethodGet, "http://pomerium.io/", nil, map[string]string{urlutil.QueryIsProgrammatic: "true", urlutil.QueryCallbackURI: "ok", urlutil.QuerySessionEncrypted: goodEncryptionString}, &mock.Encoder{MarshalResponse: []byte("x")}, &mstore.Store{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: true}}, http.StatusFound, ""}, + {"bad decrypt", opts, http.MethodGet, "http://pomerium.io/", nil, map[string]string{urlutil.QuerySessionEncrypted: goodEncryptionString + cryptutil.NewBase64Key()}, &mock.Encoder{MarshalResponse: []byte("x")}, &mstore.Store{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: true}}, http.StatusBadRequest, ""}, {"bad save session", opts, http.MethodGet, "http://pomerium.io/", nil, map[string]string{urlutil.QuerySessionEncrypted: goodEncryptionString}, &mock.Encoder{MarshalResponse: []byte("x")}, &mstore.Store{SaveError: errors.New("hi")}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: true}}, http.StatusBadRequest, ""}, - {"bad base64", opts, http.MethodGet, "http://pomerium.io/", nil, map[string]string{urlutil.QuerySessionEncrypted: "^"}, &mock.Encoder{MarshalResponse: []byte("x")}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: true}}, http.StatusBadRequest, ""}, - {"malformed redirect", opts, http.MethodGet, "http://pomerium.io/", nil, nil, &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: true}}, http.StatusBadRequest, ""}, + {"bad base64", opts, http.MethodGet, "http://pomerium.io/", nil, map[string]string{urlutil.QuerySessionEncrypted: "^"}, &mock.Encoder{MarshalResponse: []byte("x")}, &mstore.Store{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: true}}, http.StatusBadRequest, ""}, + {"malformed redirect", opts, http.MethodGet, "http://pomerium.io/", nil, nil, &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: true}}, http.StatusBadRequest, ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/proxy/middleware_test.go b/proxy/middleware_test.go index bf388fa5b..afbda08b4 100644 --- a/proxy/middleware_test.go +++ b/proxy/middleware_test.go @@ -10,13 +10,14 @@ import ( "time" "github.com/google/go-cmp/cmp" + "gopkg.in/square/go-jose.v2/jwt" + "github.com/pomerium/pomerium/internal/encoding" "github.com/pomerium/pomerium/internal/encoding/jws" "github.com/pomerium/pomerium/internal/encoding/mock" "github.com/pomerium/pomerium/internal/identity" "github.com/pomerium/pomerium/internal/sessions" mstore "github.com/pomerium/pomerium/internal/sessions/mock" - "gopkg.in/square/go-jose.v2/jwt" ) func TestProxy_AuthenticateSession(t *testing.T) { @@ -39,9 +40,9 @@ func TestProxy_AuthenticateSession(t *testing.T) { wantStatus int }{ - {"good", 200, false, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Second))}}, nil, identity.MockProvider{}, &mock.Encoder{}, "", http.StatusOK}, - {"invalid session", 200, false, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Second))}}, errors.New("hi"), identity.MockProvider{}, &mock.Encoder{}, "", http.StatusFound}, - {"expired", 200, false, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(-10 * time.Minute))}}, sessions.ErrExpired, identity.MockProvider{}, &mock.Encoder{}, "", http.StatusFound}, + {"good", 200, false, &mstore.Store{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Second))}}, nil, identity.MockProvider{}, &mock.Encoder{}, "", http.StatusOK}, + {"invalid session", 200, false, &mstore.Store{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Second))}}, errors.New("hi"), identity.MockProvider{}, &mock.Encoder{}, "", http.StatusFound}, + {"expired", 200, false, &mstore.Store{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(-10 * time.Minute))}}, sessions.ErrExpired, identity.MockProvider{}, &mock.Encoder{}, "", http.StatusFound}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -82,12 +83,10 @@ func TestProxy_AuthenticateSession(t *testing.T) { } func Test_jwtClaimMiddleware(t *testing.T) { - email := "test@pomerium.example" - groups := []string{"foo", "bar"} claimHeaders := []string{"email", "groups", "missing"} sharedKey := "80ldlrU2d7w+wVpKNfevk6fmb8otEx6CqOfshj2LwhQ=" - session := &sessions.State{Email: email, Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Second)), Groups: groups} + session := &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Second))} encoder, _ := jws.NewHS256Signer([]byte(sharedKey), "https://authenticate.pomerium.example") state, err := encoder.Marshal(session) @@ -114,27 +113,6 @@ func Test_jwtClaimMiddleware(t *testing.T) { proxyHandler := a.jwtClaimMiddleware(true)(handler) proxyHandler.ServeHTTP(w, r) - t.Run("email claim", func(t *testing.T) { - emailHeader := r.Header.Get("x-pomerium-claim-email") - if emailHeader != email { - t.Errorf("did not find claim email, want=%q, got=%q", email, emailHeader) - } - }) - - t.Run("groups claim", func(t *testing.T) { - groupsHeader := r.Header.Get("x-pomerium-claim-groups") - if groupsHeader != strings.Join(groups, ",") { - t.Errorf("did not find claim groups, want=%q, got=%q", groups, groupsHeader) - } - }) - - t.Run("email response claim", func(t *testing.T) { - emailHeader := w.Header().Get("x-pomerium-claim-email") - if emailHeader != email { - t.Errorf("did not find claim email in response, want=%q, got=%q", email, emailHeader) - } - }) - t.Run("missing claim", func(t *testing.T) { absentHeader := r.Header.Get("x-pomerium-claim-missing") if absentHeader != "" { diff --git a/proxy/proxy.go b/proxy/proxy.go index 83d03b529..ebc5d4873 100644 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -35,10 +35,10 @@ import ( const ( // authenticate urls - dashboardURL = "/.pomerium" - signinURL = "/.pomerium/sign_in" - signoutURL = "/.pomerium/sign_out" - refreshURL = "/.pomerium/refresh" + dashboardPath = "/.pomerium" + signinURL = "/.pomerium/sign_in" + signoutURL = "/.pomerium/sign_out" + refreshURL = "/.pomerium/refresh" ) // ValidateOptions checks that proper configuration settings are set to create @@ -68,11 +68,12 @@ type Proxy struct { SharedKey string sharedCipher cipher.AEAD - authorizeURL *url.URL - authenticateURL *url.URL - authenticateSigninURL *url.URL - authenticateSignoutURL *url.URL - authenticateRefreshURL *url.URL + authorizeURL *url.URL + authenticateURL *url.URL + authenticateDashboardURL *url.URL + authenticateSigninURL *url.URL + authenticateSignoutURL *url.URL + authenticateRefreshURL *url.URL encoder encoding.Unmarshaler cookieOptions *cookie.Options @@ -136,6 +137,7 @@ func New(opts config.Options) (*Proxy, error) { // errors checked in ValidateOptions p.authorizeURL, _ = urlutil.DeepCopy(opts.AuthorizeURL) p.authenticateURL, _ = urlutil.DeepCopy(opts.AuthenticateURL) + p.authenticateDashboardURL = p.authenticateURL.ResolveReference(&url.URL{Path: dashboardPath}) p.authenticateSigninURL = p.authenticateURL.ResolveReference(&url.URL{Path: signinURL}) p.authenticateSignoutURL = p.authenticateURL.ResolveReference(&url.URL{Path: signoutURL}) p.authenticateRefreshURL = p.authenticateURL.ResolveReference(&url.URL{Path: refreshURL}) diff --git a/scripts/programmatic_access.py b/scripts/programmatic_access.py index e091dea82..358aa9aec 100755 --- a/scripts/programmatic_access.py +++ b/scripts/programmatic_access.py @@ -16,9 +16,6 @@ parser.add_argument("--login", action="store_true") parser.add_argument( "--dst", default="https://httpbin.example.com/headers", ) -parser.add_argument( - "--refresh-endpoint", default="https://authenticate.example.com/api/v1/refresh", -) parser.add_argument("--server", default="localhost", type=str) parser.add_argument("--port", default=8000, type=int) parser.add_argument( @@ -111,27 +108,6 @@ def main(): args.dst, response.status_code, response.text ) ) - # if response.status_code == 200: - if response.status_code == 401: - # user our refresh token to get a new cred - print("==> got a 401, let's try to refresh that credential") - response = requests.get( - args.refresh_endpoint, - headers={ - "Authorization": "Pomerium {}".format(cred.refresh_token), - "Content-type": "application/json", - "Accept": "application/json", - }, - ) - print( - "==>request\n{}\n ==> response.status_code\n{}\nresponse.text==>\n{}\n".format( - args.refresh_endpoint, response.status_code, response.text - ) - ) - # update our cred! - with open(args.cred, "w", encoding="utf-8") as f: - f.write(response.text) - print("=> pomerium json credential saved to:\n{}".format(f.name)) if __name__ == "__main__": diff --git a/scripts/protoc b/scripts/protoc index 7f0ed0ce9..f717d76d1 100755 --- a/scripts/protoc +++ b/scripts/protoc @@ -1,7 +1,7 @@ #!/bin/bash set -euo pipefail -_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" +_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" _protoc_version="3.12.1" _protoc_path="/tmp/pomerium-protoc/protoc-$_protoc_version" _os="linux" @@ -9,14 +9,14 @@ if [ "$(uname -s)" == "Darwin" ]; then _os="osx" fi -if [ ! -f "$_protoc_path" ]; then +mkdir -p "$_protoc_path" +if [ ! -f "$_protoc_path/bin/protoc" ]; then echo "downloading protoc" - mkdir -p "/tmp/pomerium-protoc" curl -L \ -o protoc.zip \ "https://github.com/protocolbuffers/protobuf/releases/download/v$_protoc_version/protoc-$_protoc_version-$_os-x86_64.zip" - unzip -p protoc.zip bin/protoc >"$_protoc_path" + unzip -o -d "$_protoc_path" protoc.zip + rm protoc.zip fi -chmod +x "$_protoc_path" -exec "$_protoc_path" --plugin="protoc-gen-go=$_dir/protoc-gen-go" "$@" +exec "$_protoc_path/bin/protoc" --plugin="protoc-gen-go=$_dir/protoc-gen-go" "$@"