├── .dockerignore ├── .github └── workflows │ └── image-on-tag.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── cmd ├── config.go ├── grafana.go ├── main.go ├── role.go ├── rules.go └── sync.go ├── demoConfig.yaml ├── go.mod ├── go.sum └── pkg ├── groups ├── groups.go └── helpers.go └── watcher └── watcher.go /.dockerignore: -------------------------------------------------------------------------------- 1 | .gitignore 2 | .dockerignore 3 | .idea 4 | .vscode 5 | .demoConfig.yaml 6 | .github 7 | -------------------------------------------------------------------------------- /.github/workflows/image-on-tag.yml: -------------------------------------------------------------------------------- 1 | name: Build image on tag 2 | 3 | on: 4 | push: 5 | tags: 6 | - "**" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@master 14 | 15 | - name: Extract ref/branch name 16 | shell: bash 17 | run: echo "##[set-output name=ref;]$(echo ${GITHUB_REF##*/})" 18 | id: extract_ref 19 | 20 | - name: Set timestamp 21 | shell: bash 22 | run: echo "::set-output name=time::$(date +%s)" 23 | id: set_timestamp 24 | 25 | - name: Login to Quay 26 | env: 27 | QUAY_ENCRYPTED_PASS: ${{ secrets.QUAY_ENCRYPTED_PASS }} 28 | run: docker login quay.io -u=google-cloud-tools+github_actions_bot -p=${QUAY_ENCRYPTED_PASS} 29 | 30 | - name: Build Docker Image 31 | run: docker build 32 | --build-arg "GIT_SHA=${GITHUB_SHA}" 33 | --build-arg "GIT_REF=${{ steps.extract_ref.outputs.ref }}" 34 | --build-arg "TIMESTAMP=${{ steps.set_timestamp.outputs.time }}" 35 | -t quay.io/google-cloud-tools/grafana-permission-sync:${{ steps.extract_ref.outputs.ref }} 36 | . 37 | 38 | - name: Push Image 39 | run: | 40 | docker push quay.io/google-cloud-tools/grafana-permission-sync:${{ steps.extract_ref.outputs.ref }} 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | notes.md 18 | 19 | google_admin_service_creds.json 20 | config.yaml 21 | config2.yaml 22 | config3.yaml 23 | config4.yaml 24 | 25 | **/.vscode 26 | **/.idea 27 | 28 | __debug_bin 29 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Backend Build 2 | FROM golang:1.13-alpine as builder 3 | RUN apk update && apk add --no-cache git ca-certificates && update-ca-certificates 4 | 5 | WORKDIR /app 6 | 7 | COPY ./go.mod . 8 | COPY ./go.sum . 9 | RUN go mod download 10 | 11 | COPY . . 12 | RUN CGO_ENABLED=0 go build -o ./bin/grafana-permission-sync ./cmd 13 | # Compiled backend binary is in '/app/bin/' named 'grafana-permission-sync' 14 | 15 | 16 | 17 | # Create executable image 18 | FROM alpine:3.11 19 | WORKDIR /app 20 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 21 | COPY --from=builder /app/bin/grafana-permission-sync /app/grafana-permission-sync 22 | 23 | 24 | # From: https://docs.docker.com/engine/reference/builder/#using-arg-variables 25 | # We want to bake the commit sha into the image, or abort if the value is not set 26 | # ENV values are persistet in the built image, ARG instructions are not! 27 | 28 | # # git sha of the commit 29 | # ARG GIT_SHA 30 | # RUN test -n "$GIT_SHA" || (echo "GIT_SHA must be set" && false) 31 | # ENV REACT_APP_GIT_SHA ${GIT_SHA} 32 | 33 | # # name of the git branch 34 | # ARG GIT_REF 35 | # RUN test -n "$GIT_REF" || (echo "GIT_REF must be set" && false) 36 | # ENV REACT_APP_GIT_REF ${GIT_REF} 37 | 38 | # # timestamp in unix seconds when the image was built 39 | # ARG TIMESTAMP 40 | # RUN test -n "$TIMESTAMP" || (echo "TIMESTAMP must be set" && false) 41 | # ENV REACT_APP_TIMESTAMP ${TIMESTAMP} 42 | 43 | 44 | ENTRYPOINT ["./grafana-permission-sync"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Developing tools for the Cloud 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Grafana Permission Sync 2 | [![Docker Repository on Quay](https://quay.io/repository/google-cloud-tools/grafana-permission-sync/status "Docker Repository on Quay")](https://quay.io/repository/google-cloud-tools/grafana-permission-sync) 3 | [![license](https://img.shields.io/github/license/google-cloud-tools/grafana-gsuite-sync.svg?maxAge=604800)](https://github.com/google-cloud-tools/grafana-gsuite-sync) 4 | 5 | 6 | ### What does it do? 7 | This tool assigns roles to users in Grafana - based on what Google Groups they are in. 8 | The mapping of what group results in what org/role is managed through [the config file](#config). 9 | 10 | This tool is a full rewrite of (rewrite of [grafana-gsuite-sync](https://github.com/cloudworkz/grafana-gsuite-sync)) 11 | 12 | 13 | ### How does it work? 14 | 1. Get all orgs and all users from grafana 15 | 2. Fetch all relevant google groups (once every `settings.groupsFetchInterval`) 16 | 3. For each user, compute what orgs they should be in and what role they should have. This "update plan" (the list of changes to be made) that will be printed to stdout, for example: 17 | ```json 18 | {"level":"info", "msg":"Promote user", "user":"Alice@COMPANY.com", "org":"Some Org Name [INT]", "oldRole":"Viewer", "role":"Admin"}` 19 | {"level":"info", "msg":"Remove user from org", "user":"Alice@COMPANY.com", "org":"Controlling"} 20 | {"level":"info", "msg":"Demote user", "user":"Alice@COMPANY.com", "org":"Some Org Name [PRD]", "oldRole":"Admin", "role":"Viewer"} 21 | {"level":"info", "msg":"Add user to org", "user":"SomeOtherUser", "org":"Some Org Name [PRD]", "role":"Viewer"} 22 | ``` 23 | 4. Apply the changes slowly (capped at 10 operations per second) 24 | 5. Wait for `settings.applyInterval`, then repeat 25 | 26 | 27 | ### Requirements 28 | - **Docker Image**: `docker pull quay.io/google-cloud-tools/grafana-permission-sync` 29 | - **Google Service Account**: credentials (in `.json`) of a Google service account, which has permissions to impersonate a user that can see all groups ([instructions on how to set that up](https://developers.google.com/admin-sdk/directory/v1/guides/delegation#delegate_domain-wide_authority_to_your_service_account)) 30 | - **Grafana Admin**: credentails of a Grafana user that has 'server admin' set 31 | - **Config**: use [the example](https://github.com/cloudworkz/grafana-permission-sync/blob/master/demoConfig.yaml) as a starting point and add your rules 32 | 33 | 34 | ### Config 35 | - By default the config file is loaded from `./config.yaml`, 36 | but you can override the path using the configPath flag: `--configPath=some/other/path/config.yaml` 37 | 38 | Take a look at the [**the demo config file**](https://github.com/cloudworkz/grafana-permission-sync/blob/master/demoConfig.yaml) to see all settings 39 | 40 | - The config supports hot reloading. When the file changes, it will be automatically reloaded. 41 | When a new config is loaded successfully (no parsing or validation errors), it will be applied (actually used) from the next iteration onwards. That basically just means a new config won't be applied in the middle of a running permission update. 42 | 43 | - Hot reloading does currently only affect changes to the `settings:` and `rules:` blocks. Other blocks (`google:`, `grafana:`) are not updated and require the application to be restarted. (Will be fixed soon) 44 | 45 | 46 | ### Rules 47 | - The `orgs: ` property supports regex, but only if the element is enclosed in `//`! 48 | That means `orgs: [ ".*" ]` will not work, it will not be interpreted as a regex! 49 | For example: to match everything you'd write `orgs: [ /.*/ ]` or with quotes `orgs: [ "/.*/" ]` (because regex can contain all sorts of symbols). 50 | 51 | - The `note: ` property will be shown as the reason for each change 52 | 53 | - The only required property in each rule is `role: ` 54 | 55 | Example: 56 | ```yaml 57 | rules: [ 58 | { 59 | # Everyone in the technology group should be able to view the two grafana organizations 60 | note: "tech viewers", # used to show in the reason field 61 | groups: [technology@my-company.com], 62 | orgs: ["Main Grafana Org", "Testing"], 63 | role: Viewer, 64 | }, 65 | { 66 | # Also assign the Admin role to certain users 67 | note: "global admins", 68 | users: [ admin@my-company.com ], # individual users 69 | orgs: ["/.*/"], 70 | role: Admin, 71 | }, 72 | ] 73 | ``` 74 | 75 | 76 | ### Why are there two different time intervals? 77 | - `settings.groupsFetchInterval` controls how often google groups are fetched. 78 | To avoid hitting googles rate limit, you probably want this to have a pretty high value (30 minutes or so). 79 | 80 | - `settings.applyInterval` controls how often the main loop runs. 81 | Grafana creates an account for a user when they login for the first time. 82 | When the new user account is created, grafana-permission-sync can assign the correct 83 | permissions (organization membership and roles) the next time it computes an update. 84 | So we want to do this pretty often (scanning for newly created users and assigning the right permissions to them). 85 | 86 | ### Health/Liveness 87 | 88 | Kubernetes ready and liveness probes: `/admin/ready` and `/admin/alive` -------------------------------------------------------------------------------- /cmd/config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | 7 | "time" 8 | 9 | "gopkg.in/yaml.v2" 10 | ) 11 | 12 | // GoogleConfig - 13 | type GoogleConfig struct { 14 | CredentialsPath string `yaml:"credentialsPath"` 15 | AdminEmail string `yaml:"adminEmail"` 16 | Domain string `yaml:"domain"` 17 | GroupBlacklist []string `yaml:"groupBlacklist"` 18 | } 19 | 20 | // GrafanaConfig - 21 | type GrafanaConfig struct { 22 | URL string `yaml:"url"` 23 | User string `yaml:"user"` 24 | Password string `yaml:"-"` // password is retreived from GRAFANA_PASS 25 | } 26 | 27 | // Settings - 28 | type Settings struct { 29 | GroupsFetchInterval time.Duration `yaml:"groupsFetchInterval"` 30 | ApplyInterval time.Duration `yaml:"applyInterval"` 31 | 32 | CanDemote bool `yaml:"canDemote"` // can demote a user to a lower role, or even completely remove them from an org 33 | RemoveFromMainOrg bool `yaml:"removeFromMainOrg"` 34 | } 35 | 36 | // Config - 37 | type Config struct { 38 | Google GoogleConfig `yaml:"google"` 39 | Grafana GrafanaConfig `yaml:"grafana"` 40 | Settings Settings `yaml:"settings"` 41 | Rules []*Rule `yaml:"rules"` 42 | } 43 | 44 | // may return nil in case of errors 45 | func tryLoadConfig(configPath string) *Config { 46 | configBytes, err := ioutil.ReadFile(configPath) 47 | if err != nil { 48 | log.Errorw("can not read config file", "path", configPath, "error", err) 49 | return nil 50 | } 51 | 52 | c := Config{} 53 | err = yaml.Unmarshal(configBytes, &c) 54 | if err != nil { 55 | log.Errorw("parsing error in config file", "error", err) 56 | return nil 57 | } 58 | 59 | for i, r := range c.Rules { 60 | r.Index = i 61 | err := r.verify() 62 | if err != nil { 63 | log.Errorw("error verifying rule", "error", err, "role", r.Role) 64 | return nil 65 | } 66 | } 67 | 68 | c.Grafana.Password = os.Getenv("GRAFANA_PASS") 69 | 70 | return &c 71 | } 72 | 73 | func (c *Config) getAllGroups() []string { 74 | var ar []string 75 | for _, e := range c.Rules { 76 | ar = append(ar, e.Groups...) 77 | } 78 | return distinct(ar) 79 | } 80 | 81 | func (c *Config) getAllUsers() []string { 82 | var ar []string 83 | for _, e := range c.Rules { 84 | ar = append(ar, e.Users...) 85 | } 86 | return distinct(ar) 87 | } 88 | -------------------------------------------------------------------------------- /cmd/grafana.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/rikimaru0345/sdk" 7 | "golang.org/x/time/rate" 8 | ) 9 | 10 | type grafanaState struct { 11 | *sdk.Client 12 | 13 | // todo: move this into a separate "Grafana" package and don't use package-globals 14 | allUsers []sdk.User 15 | organizations map[uint]*grafanaOrganization // [orgID]Org 16 | 17 | rateLimit *rate.Limiter 18 | } 19 | 20 | type grafanaOrganization struct { 21 | *sdk.Org 22 | Users []sdk.OrgUser 23 | } 24 | 25 | func (g *grafanaState) fetchState() { 26 | 27 | // get all users (including those that don't belong to any org) 28 | var err error 29 | g.Wait() 30 | g.allUsers, err = g.GetAllUsers() 31 | if err != nil { 32 | log.Errorf("unable to fetch all users from grafana: %v", err.Error()) 33 | return 34 | } 35 | 36 | // get all orgs... 37 | g.organizations = make(map[uint]*grafanaOrganization) 38 | g.Wait() 39 | orgs, err := g.GetAllOrgs() 40 | if err != nil { 41 | log.Errorf("unable to list all orgs: %v" + err.Error()) 42 | return 43 | } 44 | 45 | for _, org := range orgs { 46 | // ...and their users 47 | g.Wait() 48 | users, err := g.GetOrgUsers(org.ID) 49 | if err != nil { 50 | log.Error("error listing users for org: " + err.Error()) 51 | continue 52 | } 53 | orgCopy := org // need to create a local copy of the org... 54 | g.organizations[org.ID] = &grafanaOrganization{&orgCopy, users} 55 | } 56 | } 57 | 58 | // Wait consumes a token for an api request against grafana (or waits until a token is available!) 59 | func (g *grafanaState) Wait() { 60 | g.rateLimit.Wait(context.Background()) 61 | } 62 | 63 | func (g *grafanaOrganization) findUser(userEmail string) *sdk.OrgUser { 64 | for _, u := range g.Users { 65 | if u.Email == userEmail { 66 | return &u 67 | } 68 | } 69 | return nil 70 | } 71 | -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "go.uber.org/zap" 11 | "go.uber.org/zap/zapcore" 12 | "gopkg.in/yaml.v2" 13 | 14 | "github.com/cloudworkz/grafana-permission-sync/pkg/watcher" 15 | "github.com/gin-gonic/gin" 16 | ) 17 | 18 | var ( 19 | config *Config // current 20 | newConfig *Config // new config to use next 21 | 22 | log *zap.SugaredLogger 23 | 24 | configPath string 25 | 26 | dryRunNoPlanNoExec = len(os.Getenv("GRAFANA_PERMISSION_SYNC_NO_PLAN_NO_EXEC")) > 0 27 | dryRunNoExec = len(os.Getenv("GRAFANA_PERMISSION_SYNC_NO_EXEC")) > 0 28 | ) 29 | 30 | func main() { 31 | // 1. Setup, load config, ... 32 | logRaw, _ := zap.NewProduction() 33 | log = logRaw.Sugar() 34 | 35 | flag.StringVar(&configPath, "configPath", "./config.yaml", "alternative path to the config file") 36 | flag.Parse() 37 | 38 | // Load config 39 | config = tryLoadConfig(configPath) 40 | if config == nil { 41 | log.Fatal("can't start, error loading config. initial config must be valid (hot reloaded config may be invalid, in which case the old/previous config will be kept)") 42 | } 43 | 44 | // Configure hot-reload 45 | setupConfigHotReload(configPath) 46 | 47 | // 2. Start sync 48 | setupSync() 49 | go startSync() 50 | 51 | // 3. Start HTTP server 52 | startWebServer() 53 | } 54 | 55 | func startWebServer() { 56 | gin.SetMode(gin.ReleaseMode) 57 | r := gin.New() 58 | 59 | rawLog := log.Desugar() 60 | r.Use(gin.LoggerWithConfig(gin.LoggerConfig{ 61 | Formatter: func(param gin.LogFormatterParams) string { 62 | // don't log liveness checks 63 | if strings.HasPrefix(param.Path, "/admin/") { 64 | return "" 65 | } 66 | 67 | fields := []zapcore.Field{ 68 | zap.String("clientIP", param.ClientIP), 69 | zap.String("method", param.Method), 70 | zap.String("path", param.Path), 71 | zap.String("protocol", param.Request.Proto), 72 | zap.Int("statusCode", param.StatusCode), 73 | zap.Duration("latency", param.Latency), 74 | zap.String("userAgent", param.Request.UserAgent()), 75 | } 76 | 77 | if param.ErrorMessage != "" { 78 | fields = append(fields, zap.String("error", param.ErrorMessage)) 79 | } 80 | 81 | rawLog.Info("handling http request", fields...) 82 | return "" // prevent unstructured logging 83 | }, 84 | })) 85 | 86 | r.Use(gin.Recovery()) 87 | 88 | r.GET("/admin/ready", func(c *gin.Context) { 89 | if createdPlans > 0 { 90 | renderYAML(c, 200, gin.H{"status": "ready"}) 91 | } else { 92 | renderYAML(c, 503, gin.H{"status": "starting"}) 93 | } 94 | }) 95 | 96 | r.GET("/admin/alive", func(c *gin.Context) { 97 | renderYAML(c, 200, gin.H{"status": "ready"}) 98 | }) 99 | 100 | r.GET("/admin/groups/:email", func(c *gin.Context) { 101 | email := c.Param("email") 102 | recurse := c.Query("recurse") == "true" 103 | members, err := groupTree.ListGroupMembersForDisplay(email, recurse) 104 | if err != nil { 105 | renderJSON(c, 500, gin.H{"error": err.Error()}) 106 | return 107 | } 108 | renderJSON(c, 200, members) 109 | }) 110 | 111 | r.GET("/admin/users/:email", func(c *gin.Context) { 112 | email := c.Param("email") 113 | groups, err := groupTree.ListUserGroupsForDisplay(email) 114 | if err != nil { 115 | renderJSON(c, 500, gin.H{"error": err.Error()}) 116 | return 117 | } 118 | renderJSON(c, 200, groups) 119 | // todo: 120 | // 1. resulting in permissions: x, y, z, ... 121 | // 2. and the user would not be in organizations: a, b, c, ... 122 | }) 123 | 124 | err := r.Run(":3000") 125 | if err != nil { 126 | log.Fatalw("error in router.Run", "error", err) 127 | } 128 | } 129 | 130 | func renderYAML(c *gin.Context, code int, obj interface{}) { 131 | bytes, err := yaml.Marshal(obj) 132 | if err != nil { 133 | c.String(500, "error: "+err.Error()) 134 | } 135 | c.Data(code, "text/plain; charset=utf-8", bytes) 136 | } 137 | 138 | func renderJSON(c *gin.Context, code int, obj interface{}) { 139 | bytes, err := json.MarshalIndent(obj, "", " ") 140 | if err != nil { 141 | c.String(500, "error: "+err.Error()) 142 | } 143 | c.Data(code, "text/plain; charset=utf-8", bytes) 144 | } 145 | 146 | func setupConfigHotReload(configPath string) { 147 | 148 | configPathAbs, err := filepath.Abs(configPath) 149 | if err != nil { 150 | log.Fatal("cannot build absolute path", "path", configPath, "error", err) 151 | } 152 | 153 | watcher, err := watcher.WatchPath(configPath) 154 | if err != nil { 155 | log.Errorw("can't start config file watcher. config hot-reloading will be disabled!", "error", err) 156 | } 157 | watcher.OnError = func(err error) { 158 | log.Errorw("error in config watcher", "error", err) 159 | } 160 | watcher.OnChange = func(filePath string) { 161 | filePathAbs, err := filepath.Abs(filePath) 162 | if err != nil { 163 | log.Fatal("cannot build absolute path", "path", filePath, "error", err) 164 | } 165 | if filePathAbs != configPathAbs { 166 | log.Warnw("config file watcher notified us about a file we don't want to know about", "fileWeWantToWatch", configPath, "fileReportedByWatcher", filePath) 167 | return 168 | } 169 | 170 | // Try to reload the config, and if it is valid, set it 171 | c := tryLoadConfig(configPath) 172 | if c == nil { 173 | log.Error("Config file changed, but loading failed. Will continue with already loaded config and ignore new config.") 174 | return 175 | } 176 | newConfig = c 177 | 178 | log.Info("new config loaded successfully, swapping on next idle phase") 179 | 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /cmd/role.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | basicLog "log" 5 | ) 6 | 7 | // Role is one of the 4 grafana roles: viewer, editor, admin, and (meaning user is not part of the org) 8 | type Role string 9 | 10 | var ( 11 | roleLevels = map[Role]int{ 12 | "": 0, 13 | "Viewer": 1, 14 | "Editor": 2, 15 | "Admin": 3, 16 | } 17 | ) 18 | 19 | func (r Role) level() int { 20 | level, exists := roleLevels[r] 21 | if !exists { 22 | basicLog.Fatal("invalid role: " + string(r)) 23 | } 24 | 25 | return level 26 | } 27 | 28 | // Compare two roles, returning the higher role (the one that gives more permissions) 29 | func (r Role) isHigherThan(other Role) bool { 30 | thisLevel := r.level() 31 | otherLevel := other.level() 32 | 33 | if thisLevel > otherLevel { 34 | return true 35 | } 36 | return false 37 | } 38 | func (r Role) isHigherOrEqThan(other Role) bool { 39 | thisLevel := r.level() 40 | otherLevel := other.level() 41 | 42 | if thisLevel >= otherLevel { 43 | return true 44 | } 45 | return false 46 | } 47 | func (r Role) isLowerThan(other Role) bool { 48 | return !r.isHigherOrEqThan(other) 49 | } 50 | -------------------------------------------------------------------------------- /cmd/rules.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "errors" 4 | 5 | import "strings" 6 | 7 | import "regexp" 8 | 9 | // Rule is a single mapping rule that specifies 10 | // what google groups/users get what grafana-role in which grafana-org 11 | type Rule struct { 12 | Note string `yaml:"note"` // will be displayed in the 'reason' field for every change 13 | Index int // used as a fallback reason 14 | 15 | Groups FlattenedArray `yaml:"groups"` 16 | Users FlattenedArray `yaml:"users"` 17 | Organizations FlattenedArray `yaml:"orgs"` 18 | Role Role `yaml:"role"` 19 | } 20 | 21 | func (r *Rule) verify() error { 22 | if r.Role != "Viewer" && r.Role != "Editor" && r.Role != "Admin" { 23 | return errors.New("Invalid role \"%s\". Must be one of [Viewer, Editor, Admin]") 24 | } 25 | 26 | for _, o := range r.Organizations { 27 | if strings.HasPrefix(o, "/") && strings.HasSuffix(o, "/") { 28 | pattern := o[1 : len(o)-1] 29 | _, err := regexp.Compile(pattern) 30 | if err != nil { 31 | log.Fatalw("your regex pattern can not be compiled", "pattern", pattern, "error", err.Error()) 32 | } 33 | } 34 | } 35 | 36 | return nil 37 | } 38 | 39 | func (r *Rule) matchesOrg(org string) bool { 40 | // check if it contains an exact match, or regex match 41 | for _, item := range r.Organizations { 42 | if strings.HasPrefix(item, "/") && strings.HasSuffix(item, "/") { 43 | // is regex match? 44 | pattern := item[1 : len(item)-1] 45 | isMatch, err := regexp.MatchString(pattern, org) 46 | if err == nil && isMatch { 47 | return true 48 | } 49 | } else { 50 | // is regular match? 51 | if item == org { 52 | return true 53 | } 54 | } 55 | } 56 | 57 | return false 58 | } 59 | 60 | // FlattenedArray - 61 | type FlattenedArray []string 62 | 63 | // UnmarshalYAML - 64 | func (r *FlattenedArray) UnmarshalYAML(unmarshal func(interface{}) error) error { 65 | var x []interface{} 66 | if err := unmarshal(&x); err != nil { 67 | return err 68 | } 69 | 70 | // Unpack nested arrays; remove duplicates 71 | *r = distinct(flatten(x)) 72 | 73 | return nil 74 | } 75 | 76 | func flatten(ar []interface{}) (r []string) { 77 | for _, item := range ar { 78 | switch i := item.(type) { 79 | case string: 80 | r = append(r, i) 81 | case []interface{}: 82 | r = append(r, flatten(i)...) 83 | } 84 | } 85 | return 86 | } 87 | 88 | func contains(ar []string, item string) bool { 89 | for _, e := range ar { 90 | if e == item { 91 | return true 92 | } 93 | } 94 | return false 95 | } 96 | 97 | func distinct(ar []string) []string { 98 | seen := make(map[string]bool) 99 | var new []string 100 | 101 | for _, str := range ar { 102 | if seen[str] == false { 103 | new = append(new, str) 104 | seen[str] = true 105 | } 106 | } 107 | 108 | return new 109 | } 110 | -------------------------------------------------------------------------------- /cmd/sync.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/cloudworkz/grafana-permission-sync/pkg/groups" 7 | "github.com/rikimaru0345/sdk" 8 | "golang.org/x/time/rate" 9 | ) 10 | 11 | // describes an update to a user, 12 | // how to adjust roles in each org 13 | type userUpdate struct { 14 | Email string 15 | Changes []*userRoleChange 16 | } 17 | type userRoleChange struct { 18 | Organization *grafanaOrganization 19 | OldRole Role 20 | NewRole Role 21 | Reason *Rule 22 | } 23 | 24 | const ( 25 | noUpdatesMessageInterval = 2 * time.Hour // how often to print the "no changes" message 26 | ) 27 | 28 | var ( 29 | createdPlans = 0 30 | 31 | applyRateLimit *rate.Limiter 32 | 33 | lastGoogleGroupFetch time.Time 34 | googleGroupRefreshRateLimit *rate.Limiter 35 | 36 | noUpdatesMessageRateLimit *rate.Limiter 37 | 38 | grafana *grafanaState 39 | groupTree *groups.GroupTree 40 | ) 41 | 42 | func setupSync() { 43 | setupRateLimits() 44 | 45 | log.Infow("Starting Grafana-Permission-Sync", 46 | "applyInterval", config.Settings.ApplyInterval.String(), 47 | "groupRefreshInterval", config.Settings.GroupsFetchInterval.String(), 48 | "grafana_url", config.Grafana.URL, 49 | "rules", len(config.Rules)) 50 | 51 | // 1. grafana state 52 | grafanaClient := sdk.NewClient(config.Grafana.URL, config.Grafana.User+":"+config.Grafana.Password, sdk.DefaultHTTPClient) 53 | grafana = &grafanaState{grafanaClient, nil, make(map[uint]*grafanaOrganization), rate.NewLimiter(rate.Every(time.Second/10), 2)} 54 | 55 | // 2. google groups service 56 | var err error 57 | groupTree, err = groups.CreateGroupTree(log, config.Google.Domain, config.Google.AdminEmail, config.Google.CredentialsPath, config.Google.GroupBlacklist, []string{ 58 | "https://www.googleapis.com/auth/admin.directory.group.member.readonly", 59 | "https://www.googleapis.com/auth/admin.directory.group.readonly", 60 | //"https://www.googleapis.com/auth/admin.directory.user.readonly", 61 | }...) 62 | if err != nil { 63 | log.Fatalw("unable to create google directory service", "error", err.Error()) 64 | } 65 | } 66 | 67 | func setupRateLimits() { 68 | applyRateLimit = rate.NewLimiter(rate.Every(config.Settings.ApplyInterval), 1) 69 | googleGroupRefreshRateLimit = rate.NewLimiter(rate.Every(config.Settings.GroupsFetchInterval), 1) 70 | noUpdatesMessageRateLimit = rate.NewLimiter(rate.Every(noUpdatesMessageInterval), 1) 71 | } 72 | 73 | // startSync is the main loop, it does not return. 74 | func startSync() { 75 | 76 | for { 77 | // 1. 78 | // Wait for rate limit or config change 79 | for { 80 | if applyRateLimit.Allow() { 81 | break // its time for the next update 82 | } 83 | if newConfig != nil { 84 | break // config has changed 85 | } 86 | time.Sleep(time.Millisecond * 500) 87 | } 88 | 89 | // 2. 90 | // Load new config (if there is one) 91 | next := newConfig // todo: most likely nothing will go wrong here, but it would be cleaner to do a real "interlocked compare exchange" 92 | if next != nil { 93 | config = next 94 | newConfig = nil 95 | setupRateLimits() 96 | log.Info("A new config has been loaded and applied!") 97 | } 98 | 99 | // 3. 100 | // Create and execute update plan 101 | if dryRunNoPlanNoExec { 102 | log.Warn("mode: dryRunNoPlanNoExec") 103 | continue // skip rest 104 | } 105 | 106 | updatePlan := createUpdatePlan() 107 | createdPlans++ 108 | 109 | if len(updatePlan) > 0 { 110 | printPlan(updatePlan) 111 | 112 | if !dryRunNoExec { 113 | executePlan(updatePlan) 114 | } else { 115 | log.Warn("mode: dryRunNoExec") 116 | } 117 | } else { 118 | printNoNewUpdates() 119 | } 120 | } 121 | } 122 | 123 | func createUpdatePlan() []userUpdate { 124 | 125 | // - Grafana: fetch all users and orgs from grafana 126 | grafana.fetchState() 127 | 128 | // - Rules: from the rules get set of all groups and set of all explicit users; fetch them from google 129 | fetchGoogleGroups() 130 | 131 | updates := make(map[string]*userUpdate) // user email -> update 132 | 133 | // 1. setup initial state: nobody is in any organization! 134 | for _, grafUser := range grafana.allUsers { 135 | var initialChangeSet []*userRoleChange 136 | for _, org := range grafana.organizations { 137 | orgUser := org.findUser(grafUser.Email) 138 | var currentRole Role 139 | if orgUser != nil { 140 | currentRole = Role(orgUser.Role) 141 | } 142 | initialChangeSet = append(initialChangeSet, &userRoleChange{org, currentRole, "", nil}) 143 | } 144 | 145 | updates[grafUser.Email] = &userUpdate{grafUser.Email, initialChangeSet} 146 | } 147 | 148 | // 2. apply all rules, keep highest permission 149 | for _, rule := range config.Rules { 150 | applyRule(updates, rule) 151 | } 152 | 153 | // 3. filter changes: 154 | // - remove entries that don't do anything (same new and old role) 155 | // - remove demotions if we're not allowed to 156 | // - do not remove anyone from orgID 1 157 | for _, userUpdate := range updates { 158 | var realChanges []*userRoleChange 159 | for _, change := range userUpdate.Changes { 160 | 161 | keepChange := true 162 | 163 | if change.OldRole == change.NewRole { 164 | keepChange = false // not a change 165 | } 166 | 167 | if !config.Settings.CanDemote && change.NewRole.isLowerThan(change.OldRole) { 168 | keepChange = false // prevent demotion / removal 169 | } 170 | 171 | if change.Organization.ID == 1 && change.NewRole == "" && !config.Settings.RemoveFromMainOrg { 172 | keepChange = false // don't remove from main org 173 | } 174 | 175 | if keepChange { 176 | realChanges = append(realChanges, change) 177 | } 178 | } 179 | userUpdate.Changes = realChanges 180 | } 181 | 182 | // convert update map to slice, filter entries that don't do anything 183 | var result []userUpdate 184 | for _, update := range updates { 185 | if len(update.Changes) > 0 { 186 | result = append(result, *update) 187 | } 188 | } 189 | return result 190 | } 191 | 192 | func printPlan(plan []userUpdate) { 193 | 194 | totalChanges := 0 195 | for _, uu := range plan { 196 | totalChanges += len(uu.Changes) 197 | } 198 | 199 | log.Info("") 200 | log.Infow("New update-plan computed!", "affectedUsers", len(plan), "totalChanges", totalChanges) 201 | 202 | for _, uu := range plan { 203 | for _, change := range uu.Changes { 204 | if change.OldRole == "" { 205 | // Add to org 206 | log.Infow("Add user to org", "user", uu.Email, "org", change.Organization.Name, "role", change.NewRole) 207 | } else if change.NewRole == "" { 208 | // Remove from org 209 | log.Infow("Remove user from org", "user", uu.Email, "org", change.Organization.Name) 210 | } else { 211 | // Change role in org 212 | var verb string 213 | if change.NewRole.isHigherThan(change.OldRole) { 214 | verb = "Promote" 215 | } else { 216 | verb = "Demote" 217 | } 218 | 219 | log.Infow(verb+" user", "user", uu.Email, "org", change.Organization.Name, "oldRole", change.OldRole, "role", change.NewRole, "reasonIndex", change.Reason.Index, "reasonNote", change.Reason.Note) 220 | } 221 | } 222 | } 223 | 224 | log.Info("") 225 | } 226 | 227 | func executePlan(plan []userUpdate) { 228 | 229 | log.Infow("Applying updates to Grafana...") 230 | 231 | for _, uu := range plan { 232 | for _, change := range uu.Changes { 233 | var status sdk.StatusMessage 234 | var err error = nil 235 | var user *sdk.OrgUser = nil 236 | 237 | if change.OldRole != "" { 238 | user = change.Organization.findUser(uu.Email) 239 | if user == nil { 240 | log.Warnw("cannot find orgUser", "action", "remove from org", "user", uu.Email) 241 | continue 242 | } 243 | } 244 | 245 | if change.OldRole == "" { 246 | // Add to org 247 | grafana.Wait() 248 | status, err = grafana.AddOrgUser(sdk.UserRole{LoginOrEmail: uu.Email, Role: string(change.NewRole)}, change.Organization.ID) 249 | } else if change.NewRole == "" { 250 | // Remove from org 251 | grafana.Wait() 252 | status, err = grafana.DeleteOrgUser(change.Organization.ID, user.ID) 253 | } else { 254 | // Change role in org 255 | grafana.Wait() 256 | status, err = grafana.UpdateOrgUser(sdk.UserRole{LoginOrEmail: uu.Email, Role: string(change.NewRole)}, change.Organization.ID, user.ID) 257 | } 258 | 259 | if err != nil { 260 | log.Errorw("error applying update", 261 | "userEmail", uu.Email, 262 | "org", change.Organization.Name, 263 | "oldRole", change.OldRole, 264 | "newRole", change.NewRole, 265 | "error", err, 266 | "message", status.Message, 267 | "slug", status.Slug, 268 | "version", status.Version, 269 | "status", status.Status, 270 | "UID", status.UID, 271 | "URL", status.URL) 272 | } 273 | } 274 | } 275 | } 276 | 277 | func applyRule(userUpdates map[string]*userUpdate, rule *Rule) { 278 | 279 | // 1. find set of all affected users 280 | // users = rule.Groups.Select(g=>g.Email).Concat(rule.Users).Distinct(); 281 | var users []string // user emails 282 | 283 | for _, groupEmail := range rule.Groups { 284 | group, err := groupTree.GetGroup(groupEmail) 285 | if err != nil { 286 | log.Errorw("unable to get group", "email", groupEmail, "error", err) 287 | } 288 | for _, user := range group.AllUsers() { 289 | users = append(users, user.Email) 290 | } 291 | } 292 | 293 | for _, userEmail := range rule.Users { 294 | users = append(users, userEmail) 295 | } 296 | 297 | users = distinct(users) 298 | 299 | // 2. update the role in the corrosponding org for each user 300 | for _, u := range users { 301 | update, exists := userUpdates[u] 302 | if !exists { 303 | continue 304 | } 305 | 306 | for _, change := range update.Changes { 307 | if rule.matchesOrg(change.Organization.Name) { 308 | if rule.Role.isHigherThan(change.NewRole) { 309 | // this rule applies a "higher" role than is already set 310 | change.NewRole = rule.Role 311 | change.Reason = rule 312 | } 313 | } 314 | } 315 | } 316 | } 317 | 318 | func fetchGoogleGroups() { 319 | 320 | r := googleGroupRefreshRateLimit.Reserve() 321 | if r.OK() == false { 322 | // should not be possible because we're the only function and go-routine that ever uses this! 323 | log.Error("google groups refresh: rateLimit.Reserve().OK() returned false") 324 | return 325 | } 326 | 327 | readyIn := r.Delay() 328 | if readyIn > 0 { 329 | r.Cancel() // dont actually consume a token 330 | log.Debugw("refresh google groups: not ready yet", "nextRefreshAllowedIn", readyIn.String()) 331 | return 332 | } 333 | 334 | now := time.Now() 335 | timeSinceLast := now.Sub(lastGoogleGroupFetch) 336 | lastGoogleGroupFetch = now 337 | 338 | groupTree.Clear() 339 | 340 | // prefetch all groups and users 341 | distinctGroups := config.getAllGroups() 342 | 343 | log.Infow("Refreshing google groups...", "timeSinceLastGroupFetch", timeSinceLast.String(), "groupCount", len(distinctGroups)) 344 | 345 | for _, reqGroup := range distinctGroups { 346 | log.Debugw("fetching google group", "email", reqGroup) 347 | _, err := groupTree.GetGroup(reqGroup) 348 | if err != nil { 349 | log.Errorw("error fetching group", "error", err) 350 | } 351 | } 352 | } 353 | 354 | func printNoNewUpdates() { 355 | if noUpdatesMessageRateLimit.Allow() == false { 356 | return 357 | } 358 | 359 | log.Infow("Computed update plan contains no changes. (This message will be throttled in order to prevent the log from being spammed. "+"Grafana-Permission-Sync will continue to run as usual and check for updates with the same frequency.)", 360 | "applyInterval", config.Settings.ApplyInterval.String(), 361 | "noUpdatesMessageInterval", noUpdatesMessageInterval.String()) 362 | } 363 | -------------------------------------------------------------------------------- /demoConfig.yaml: -------------------------------------------------------------------------------- 1 | # 2 | # This is an example config 3 | # 4 | 5 | grafana: 6 | url: https://grafana.prd.EXAMPLE-EXAMPLE-EXAMPLE.com/ # where to reach grafana at 7 | user: grafana-admin # name of the grafana user (must be an admin obviously) 8 | # password for the grafana account is read from the 'GRAFANA_PASS' environment variable 9 | 10 | google: 11 | credentialsPath: ./google_admin_service_creds.json # service account 12 | adminEmail: admin@EXAMPLE-EXAMPLE-EXAMPLE.com # name of the admin account to use (needed to access the google admin API) 13 | domain: EXAMPLE-EXAMPLE-EXAMPLE.com # domain for the google service 14 | # You can blacklist some google groups. The tool will not try to resolve matching groups. 15 | # This is useful if you have some groups in your organization that are managed externally. 16 | # You can specify exact matches (just plain strings), or regex patterns (must be enclosed in // to mark them as regex!) 17 | groupBlacklist: ["/.*@some-external-group\\.com/"] 18 | 19 | settings: 20 | # how often to fetch all groups from google 21 | # you most likely want to keep this value as you'd hit the rate limit otherwise 22 | groupsFetchInterval: 30m 23 | # applyInterval means how often to apply the latest information to grafana. 24 | # you most likely want to keep doing this very often, because checking the current roles of all 25 | # grafana users is essentially free, and changes (updating users) are only made when needed. 26 | # this ensures that a new user will get their permissions very quickly 27 | # (assuming their account was already listed in the latest updated we fetched from googleGroups) 28 | applyInterval: 20s 29 | # if true, the syncer can 30 | # (1) demote a user (change their role to one with less permissions e.g. from Admin to Viewer) 31 | # (2) remove users from an organization entirely 32 | canDemote: false 33 | 34 | yamlVars: # yamlVars is not an actual setting, I just use it to group my yaml anchors (aka variables) 35 | var1: &MyOrgs ["Main Grafana Org", "Testing"] 36 | 37 | rules: [ 38 | # { 39 | # groups: [ ], # List of Google Groups (specified by Email-Address) 40 | # users: [ ], # List of users (specified by Email-Address) 41 | # orgs: [ ], # List of Grafana organizations the role gets applied in 42 | # role: Viewer, # The grafana role that gets applied; can be: Viewer, Editor, or Admin 43 | # }, 44 | { 45 | # Everyone in the technology group should be able to view the two grafana organizations 46 | note: "some optional note entry that will be displayed as the 'reason' for a change", 47 | groups: [technology@my-company.com], 48 | orgs: ["Main Grafana Org", "Testing", *MyOrgs], # This is valid because nested arrays will automatically be flattened (and duplicates removed)! 49 | role: Viewer, 50 | }, 51 | { 52 | # Also assign the Admin role to certain users 53 | users: [admin@my-company.com], 54 | orgs: ["Main Grafana Org", "Testing"], 55 | role: Admin, 56 | }, 57 | ] 58 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cloudworkz/grafana-permission-sync 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/bep/debounce v1.2.0 7 | github.com/fsnotify/fsnotify v1.4.9 8 | github.com/gin-gonic/gin v1.5.0 9 | github.com/go-playground/universal-translator v0.17.0 // indirect 10 | github.com/gosimple/slug v1.9.0 // indirect 11 | github.com/leodido/go-urn v1.2.0 // indirect 12 | github.com/mattn/go-isatty v0.0.12 // indirect 13 | github.com/pkg/errors v0.9.1 14 | github.com/prometheus/client_golang v1.6.0 // indirect 15 | github.com/rikimaru0345/sdk v0.0.0-20200129142910-2c80f41386a8 16 | github.com/sqs/goreturns v0.0.0-20181028201513-538ac6014518 // indirect 17 | go.uber.org/atomic v1.5.1 // indirect 18 | go.uber.org/multierr v1.4.0 // indirect 19 | go.uber.org/zap v1.13.0 20 | golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f // indirect 21 | golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d 22 | golang.org/x/time v0.0.0-20181108054448-85acf8d2951c 23 | golang.org/x/tools v0.0.0-20200123022218-593de606220b // indirect 24 | google.golang.org/api v0.15.0 25 | gopkg.in/go-playground/validator.v9 v9.31.0 // indirect 26 | gopkg.in/yaml.v2 v2.2.8 27 | ) 28 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 3 | cloud.google.com/go v0.38.0 h1:ROfEUZz+Gh5pa62DJWXSaonyu3StP6EA6lPEXPI6mCo= 4 | cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= 5 | github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= 6 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 7 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 8 | github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 9 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 10 | github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 11 | github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= 12 | github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= 13 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 14 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 15 | github.com/bep/debounce v1.2.0 h1:wXds8Kq8qRfwAOpAxHrJDbCXgC5aHSzgQb/0gKsHQqo= 16 | github.com/bep/debounce v1.2.0/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= 17 | github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY= 18 | github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 19 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 20 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 21 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 22 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 23 | github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= 24 | github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 25 | github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= 26 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 27 | github.com/gin-gonic/gin v1.5.0 h1:fi+bqFAx/oLK54somfCtEZs9HeH1LHVoEPUgARpTqyc= 28 | github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmCsR2Do= 29 | github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= 30 | github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= 31 | github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= 32 | github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= 33 | github.com/go-playground/locales v0.12.1 h1:2FITxuFt/xuCNP1Acdhv62OzaCiviiE4kotfhkmOqEc= 34 | github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM= 35 | github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= 36 | github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= 37 | github.com/go-playground/universal-translator v0.16.0 h1:X++omBR/4cE2MNg91AoC3rmGrCjJ8eAeUP/K/EKx4DM= 38 | github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY= 39 | github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= 40 | github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= 41 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 42 | github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 43 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 44 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 45 | github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 46 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 47 | github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg= 48 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 49 | github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= 50 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 51 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 52 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 53 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 54 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 55 | github.com/golang/protobuf v1.4.0 h1:oOuy+ugB+P/kBdUnG5QaMXSIyJ1q38wWSojYCb3z5VQ= 56 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 57 | github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 58 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 59 | github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= 60 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 61 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 62 | github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4= 63 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 64 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 65 | github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= 66 | github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 67 | github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 68 | github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= 69 | github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+Tv3SM= 70 | github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= 71 | github.com/gosimple/slug v1.1.1 h1:fRu/digW+NMwBIP+RmviTK97Ho/bEj/C9swrCspN3D4= 72 | github.com/gosimple/slug v1.1.1/go.mod h1:ER78kgg1Mv0NQGlXiDe57DpCyfbNywXXZ9mIorhxAf0= 73 | github.com/gosimple/slug v1.9.0 h1:r5vDcYrFz9BmfIAMC829un9hq7hKM4cHUrsv36LbEqs= 74 | github.com/gosimple/slug v1.9.0/go.mod h1:AMZ+sOVe65uByN3kgEyf9WEBKBCSS+dJjMX9x4vDJbg= 75 | github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 76 | github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= 77 | github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 78 | github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= 79 | github.com/json-iterator/go v1.1.7 h1:KfgG9LzI+pYjr4xvmz/5H4FXjokeP+rlHLhv3iH62Fo= 80 | github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= 81 | github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns= 82 | github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= 83 | github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= 84 | github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= 85 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 86 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 87 | github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= 88 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 89 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 90 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 91 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 92 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 93 | github.com/leodido/go-urn v1.1.0 h1:Sm1gr51B1kKyfD2BlRcLSiEkffoG96g6TPv6eRoEiB8= 94 | github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw= 95 | github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= 96 | github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= 97 | github.com/mattn/go-isatty v0.0.9 h1:d5US/mDsogSGW37IV293h//ZFaeajb69h+EHFsv2xGg= 98 | github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= 99 | github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= 100 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 101 | github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= 102 | github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= 103 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= 104 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 105 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 106 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 107 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg= 108 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 109 | github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= 110 | github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 111 | github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= 112 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 113 | github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= 114 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 115 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 116 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 117 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 118 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 119 | github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= 120 | github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= 121 | github.com/prometheus/client_golang v1.6.0 h1:YVPodQOcK15POxhgARIvnDRVpLcuK8mglnMrWfyrw6A= 122 | github.com/prometheus/client_golang v1.6.0/go.mod h1:ZLOG9ck3JLRdB5MgO8f+lLTe83AXG6ro35rLTxvnIl4= 123 | github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= 124 | github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 125 | github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M= 126 | github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 127 | github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= 128 | github.com/prometheus/common v0.9.1 h1:KOMtN28tlbam3/7ZKEYKHhKoJZYYj3gMH4uc62x7X7U= 129 | github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= 130 | github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= 131 | github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= 132 | github.com/prometheus/procfs v0.0.11 h1:DhHlBtkHWPYi8O2y31JkK0TF+DGM+51OopZjH/Ia5qI= 133 | github.com/prometheus/procfs v0.0.11/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= 134 | github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ= 135 | github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q= 136 | github.com/rikimaru0345/sdk v0.0.0-20200129142910-2c80f41386a8 h1:dgfdBcyryTR1LMh9a9olledzaMeG4tyOJxgsf3gu3gw= 137 | github.com/rikimaru0345/sdk v0.0.0-20200129142910-2c80f41386a8/go.mod h1:uanaUMAwFO/kmP6KXupEeImXl36EisGAQZn1q6/4Gdc= 138 | github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 139 | github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= 140 | github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= 141 | github.com/sqs/goreturns v0.0.0-20181028201513-538ac6014518 h1:iD+PFTQwKEmbwSdwfvP5ld2WEI/g7qbdhmHJ2ASfYGs= 142 | github.com/sqs/goreturns v0.0.0-20181028201513-538ac6014518/go.mod h1:CKI4AZ4XmGV240rTHfO0hfE83S6/a3/Q1siZJ/vXf7A= 143 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 144 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 145 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 146 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 147 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 148 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 149 | github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo= 150 | github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= 151 | github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs= 152 | github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= 153 | go.opencensus.io v0.21.0 h1:mU6zScU4U1YAFPHEHYk+3JC4SY7JxgkqS10ZOSyksNg= 154 | go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= 155 | go.uber.org/atomic v1.5.0 h1:OI5t8sDa1Or+q8AeE+yKeB/SDYioSHAgcVljj9JIETY= 156 | go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= 157 | go.uber.org/atomic v1.5.1 h1:rsqfU5vBkVknbhUGbAUwQKR2H4ItV8tjJ+6kJX4cxHM= 158 | go.uber.org/atomic v1.5.1/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= 159 | go.uber.org/multierr v1.3.0 h1:sFPn2GLc3poCkfrpIXGhBD2X0CMIo4Q/zSULXrj/+uc= 160 | go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= 161 | go.uber.org/multierr v1.4.0 h1:f3WCSC2KzAcBXGATIxAB1E2XuCpNU255wNKZ505qi3E= 162 | go.uber.org/multierr v1.4.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= 163 | go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee h1:0mgffUl7nfd+FpvXMVz4IDEaUSmT1ysygQC7qYo7sG4= 164 | go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= 165 | go.uber.org/zap v1.13.0 h1:nR6NoDBgAf67s68NhaXbsojM+2gxp3S1hWkHDl27pVU= 166 | go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= 167 | golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 168 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 169 | golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 170 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 171 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 172 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 173 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 174 | golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 175 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 176 | golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 177 | golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs= 178 | golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 179 | golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f h1:J5lckAjkw6qYlOZNj90mLYNTEKDvWeuc1yieZ8qUzUE= 180 | golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= 181 | golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 182 | golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 183 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 184 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 185 | golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 186 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 187 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 188 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 189 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 190 | golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c h1:uOCk1iQW6Vc18bnC13MfzScl+wdKBmM9Y9kU7Z83/lw= 191 | golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 192 | golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 193 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI= 194 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 195 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 196 | golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 197 | golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 198 | golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d h1:TzXSXBo42m9gQenoE3b9BGiEpg5IG2JkU5FkPIawgtw= 199 | golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 200 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 201 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 202 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 203 | golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 204 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 205 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 206 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 207 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 208 | golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 209 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 210 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 211 | golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 212 | golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 213 | golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a h1:aYOabOQFp6Vj6W1F80affTUvO9UxmJRx8K0gsfABByQ= 214 | golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 215 | golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 216 | golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 217 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 218 | golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9 h1:1/DFK4b7JH8DmkqhUk48onnSfrPzImPoVxuomtbT2nk= 219 | golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 220 | golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f h1:gWF768j/LaZugp8dyS4UwsslYCYz9XgFxvlgsn0n9H8= 221 | golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 222 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 223 | golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 224 | golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= 225 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 226 | golang.org/x/time v0.0.0-20181108054448-85acf8d2951c h1:fqgJT0MGcGpPgpWU7VRdRjuArfcOvC4AoJmILihzhDg= 227 | golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 228 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 229 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 230 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 231 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 232 | golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 233 | golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 234 | golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 235 | golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 236 | golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5 h1:hKsoRgsbwY1NafxrwTs+k64bikrLBkAgPir1TNCj3Zs= 237 | golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 238 | golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 239 | golang.org/x/tools v0.0.0-20200123022218-593de606220b h1:ztSlcncMErSAUzXwnVO1iTPxHwtvOHBB26SGiyYXIEE= 240 | golang.org/x/tools v0.0.0-20200123022218-593de606220b/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 241 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 242 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 243 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 244 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 245 | google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= 246 | google.golang.org/api v0.15.0 h1:yzlyyDW/J0w8yNFJIhiAJy4kq74S+1DOLdawELNxFMA= 247 | google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= 248 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 249 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 250 | google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 251 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 252 | google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 253 | google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 254 | google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873 h1:nfPFGzJkUDX6uBmpN/pSw7MbOAWegH5QDQuoXFHedLg= 255 | google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 256 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 257 | google.golang.org/grpc v1.20.1 h1:Hz2g2wirWK7H0qIIhGIqRGTuMwTE8HEKFnDZZ7lm9NU= 258 | google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= 259 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 260 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 261 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 262 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 263 | google.golang.org/protobuf v1.21.0 h1:qdOKuR/EIArgaWNjetjgTzgVTAZ+S/WXVrq9HW9zimw= 264 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 265 | gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= 266 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 267 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 268 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 269 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 270 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 271 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 272 | gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8= 273 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 274 | gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= 275 | gopkg.in/go-playground/validator.v9 v9.29.1 h1:SvGtYmN60a5CVKTOzMSyfzWDeZRxRuGvRQyEAKbw1xc= 276 | gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= 277 | gopkg.in/go-playground/validator.v9 v9.31.0 h1:bmXmP2RSNtFES+bn4uYuHT7iJFJv7Vj+an+ZQdDaD1M= 278 | gopkg.in/go-playground/validator.v9 v9.31.0/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= 279 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 280 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 281 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 282 | gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 283 | gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= 284 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 285 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 286 | honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 287 | honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 288 | honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM= 289 | honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 290 | -------------------------------------------------------------------------------- /pkg/groups/groups.go: -------------------------------------------------------------------------------- 1 | package groups 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io/ioutil" 8 | "regexp" 9 | "strings" 10 | 11 | "go.uber.org/zap" 12 | "golang.org/x/oauth2/google" 13 | admin "google.golang.org/api/admin/directory/v1" 14 | "google.golang.org/api/option" 15 | ) 16 | 17 | // GroupTree is the service that deals with google groups 18 | type GroupTree struct { 19 | svc *admin.Service 20 | logger *zap.SugaredLogger 21 | domain string 22 | 23 | groups map[string]*Group 24 | users map[string]*User 25 | groupBlacklist []string 26 | } 27 | 28 | // Group is a 'google group', but in a more useful format than the original libarary provides 29 | type Group struct { 30 | Email string 31 | 32 | Groups []*Group 33 | Users []*User 34 | } 35 | 36 | // User is a more useful version of a google user 37 | type User struct { 38 | Email string 39 | // Groups []*Group // Groups a user is in directly 40 | // AllGroups []*Group // Groups + all indirect groups 41 | } 42 | 43 | // AllUsers constructs a slice containing all users of the group (including users of all nested groups recursively) 44 | func (g *Group) AllUsers() []*User { 45 | var result []*User 46 | 47 | openSet := []*Group{g} 48 | 49 | addGroup := func(newGroup *Group) { 50 | for _, gr := range openSet { 51 | if gr.Email == newGroup.Email { 52 | return // already present, don't add 53 | } 54 | } 55 | openSet = append(openSet, newGroup) 56 | } 57 | 58 | addUser := func(newUser *User) { 59 | for _, u := range result { 60 | if u.Email == newUser.Email { 61 | return // already present, don't add 62 | } 63 | } 64 | result = append(result, newUser) 65 | } 66 | 67 | for i := 0; i < len(openSet); i++ { 68 | current := openSet[i] 69 | if current == nil { 70 | continue 71 | } 72 | 73 | // add all users in that group 74 | for _, u := range current.Users { 75 | if u != nil { 76 | addUser(u) 77 | } 78 | } 79 | // and also add all sub-groups to the exploration list 80 | for _, subGroup := range current.Groups { 81 | if subGroup != nil { 82 | addGroup(subGroup) 83 | } 84 | } 85 | } 86 | 87 | return result 88 | } 89 | 90 | // CreateGroupTree - 91 | func CreateGroupTree(logger *zap.SugaredLogger, domain string, userEmail string, serviceAccountFilePath string, groupBlacklist []string, scopes ...string) (*GroupTree, error) { 92 | ctx := context.Background() 93 | log := logger 94 | 95 | log.Infow("loading creds", "path", serviceAccountFilePath) 96 | jsonCredentials, err := ioutil.ReadFile(serviceAccountFilePath) 97 | if err != nil { 98 | return nil, err 99 | } 100 | 101 | config, err := google.JWTConfigFromJSON(jsonCredentials, scopes...) 102 | if err != nil { 103 | return nil, fmt.Errorf("JWTConfigFromJSON: %v", err) 104 | } 105 | config.Subject = userEmail 106 | 107 | ts := config.TokenSource(ctx) 108 | 109 | svc, err := admin.NewService(ctx, option.WithTokenSource(ts)) 110 | if err != nil { 111 | return nil, fmt.Errorf("NewService: %v", err) 112 | } 113 | return &GroupTree{svc, logger, domain, map[string]*Group{}, make(map[string]*User), groupBlacklist}, nil 114 | } 115 | 116 | // Clear removes all groups and users from the cache 117 | func (g *GroupTree) Clear() { 118 | g.groups = make(map[string]*Group) 119 | g.users = make(map[string]*User) 120 | } 121 | 122 | // ListGroupMembersRaw finds all members in a group 123 | func (g *GroupTree) ListGroupMembersRaw(groupKey string) (result []*admin.Member, err error) { 124 | // g.logger.Infof("listing members for group: %v", groupKey) 125 | result = []*admin.Member{} 126 | 127 | err = g.svc.Members.List(groupKey).IncludeDerivedMembership(false).Pages(context.Background(), func(page *admin.Members) error { 128 | for _, member := range page.Members { 129 | result = append(result, member) 130 | } 131 | return nil 132 | }) 133 | 134 | if err != nil { 135 | return nil, err 136 | } 137 | return result, nil 138 | } 139 | 140 | // GetGroup - 141 | func (g *GroupTree) GetGroup(email string) (*Group, error) { 142 | grp, exists := g.groups[email] 143 | if exists { 144 | return grp, nil // return existing 145 | } 146 | 147 | // Check blacklist 148 | isBlacklisted, reason := g.isGroupInBlacklist(email) 149 | if isBlacklisted { 150 | g.logger.Infow("Skipping group because it is blacklisted", "groupEmail", email, "pattern", reason) 151 | return nil, errors.New("group is blacklisted by: '" + reason + "'") 152 | } 153 | 154 | members, err := g.ListGroupMembersRaw(email) 155 | if err != nil { 156 | g.logger.Warnw("error listing group members", "groupEmail", email, "err", err) 157 | return nil, err // error 158 | } 159 | 160 | grp = &Group{Email: email} // create new 161 | g.groups[email] = grp 162 | 163 | for _, m := range members { 164 | if m.Type == "GROUP" { 165 | subGroup, err := g.GetGroup(m.Email) // cache that sub group as well 166 | if err != nil { 167 | continue 168 | } 169 | grp.Groups = append(grp.Groups, subGroup) // add it as a child 170 | } else if m.Type == "USER" { 171 | // cache user 172 | u, exists := g.users[m.Email] 173 | if !exists { 174 | u = &User{m.Email} 175 | g.users[m.Email] = u 176 | } 177 | 178 | grp.Users = append(grp.Users, u) 179 | } else { 180 | g.logger.Fatalw("unknown member type in google group", "group", email, "memberType", m.Type, "memberId", m.Id, "memberEmail", m.Email) 181 | } 182 | } 183 | 184 | return grp, nil 185 | } 186 | 187 | func (g *GroupTree) isGroupInBlacklist(email string) (isBlacklisted bool, reason string) { 188 | for _, item := range g.groupBlacklist { 189 | if strings.HasPrefix(item, "/") && strings.HasSuffix(item, "/") { 190 | // regex match 191 | pattern := item[1 : len(item)-1] 192 | isMatch, err := regexp.MatchString(pattern, email) 193 | if err == nil && isMatch { 194 | return true, item 195 | } 196 | } else { 197 | // regular match 198 | if item == email { 199 | return true, item 200 | } 201 | } 202 | } 203 | 204 | return false, "" 205 | } 206 | -------------------------------------------------------------------------------- /pkg/groups/helpers.go: -------------------------------------------------------------------------------- 1 | package groups 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | admin "google.golang.org/api/admin/directory/v1" 8 | ) 9 | 10 | // ListGroupMembersForDisplay same as ListGroupMembersRaw, but packages them into a better serializable format 11 | func (g *GroupTree) ListGroupMembersForDisplay(groupKey string, includeDerived bool) (result []map[string]interface{}, err error) { 12 | result = make([]map[string]interface{}, 0) 13 | 14 | // We handle lookup of nested groups manually, so we pass 'false' 15 | members, err := g.ListGroupMembersRaw(groupKey) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | // Only take the fields we want, correctly nest sub-groups 21 | for _, member := range members { 22 | element := map[string]interface{}{ 23 | "id": member.Id, 24 | "type": member.Type, 25 | "email": member.Email, 26 | } 27 | 28 | if includeDerived && member.Type == "GROUP" { 29 | subGroup, err := g.ListGroupMembersForDisplay(member.Id, includeDerived) 30 | if err != nil { 31 | element["error"] = fmt.Sprintf("cannot resolve subgroup '%v' (in group '%v'): %v", member.Email, groupKey, err.Error()) 32 | } else { 33 | element["items"] = subGroup 34 | } 35 | } 36 | 37 | result = append(result, element) 38 | } 39 | return result, nil 40 | } 41 | 42 | // ListUserGroupsForDisplay finds all groups a user is a member in. userKey can be primaryEmail, any aliasEmail, or the unique userID 43 | func (g *GroupTree) ListUserGroupsForDisplay(userKey string) (groups []map[string]interface{}, err error) { 44 | groups = make([]map[string]interface{}, 0) 45 | err = g.svc.Groups.List().Domain(g.domain).UserKey(userKey).Pages(context.Background(), func(page *admin.Groups) error { 46 | for _, group := range page.Groups { 47 | groups = append(groups, map[string]interface{}{ 48 | "name": group.Name, 49 | "email": group.Email, 50 | }) 51 | } 52 | return nil 53 | }) 54 | 55 | return groups, err 56 | } 57 | -------------------------------------------------------------------------------- /pkg/watcher/watcher.go: -------------------------------------------------------------------------------- 1 | package watcher 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/bep/debounce" 7 | fsnotify "github.com/fsnotify/fsnotify" 8 | ) 9 | 10 | // Watcher is a wrapper around fsnotify to provide a slightly better API. The intention is to eventually extend it. 11 | type Watcher struct { 12 | watcher *fsnotify.Watcher 13 | 14 | filePath string // path being watched 15 | keepRunning bool // can be set to false (by "Stop()") to stop watching 16 | 17 | changes chan struct{} 18 | errors chan error 19 | 20 | OnChange func(filePath string) // callback a user can set to listen for changes 21 | OnError func(err error) // callback a user can set to listen for errors 22 | } 23 | 24 | // WatchPath creates a "file watcher" that will notify you when the given file has been updated 25 | func WatchPath(pathToFile string) (*Watcher, error) { 26 | 27 | fsWatcher, err := fsnotify.NewWatcher() 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | w := &Watcher{fsWatcher, pathToFile, true, make(chan struct{}), make(chan error), nil, nil} 33 | 34 | // doNotify := createDebounced(1000*time.Millisecond, func() { 35 | // w.changes <- struct{}{} 36 | // }) 37 | 38 | debouncer := debounce.New(500 * time.Millisecond) 39 | doNotify := func(filePath string) { 40 | debouncer(func() { 41 | if !w.keepRunning { 42 | return 43 | } 44 | // w.changes <- struct{}{} // will block, we'd have to ensure it gets drained 45 | callback := w.OnChange 46 | if callback != nil { 47 | callback(filePath) 48 | } 49 | }) 50 | } 51 | 52 | go func() { 53 | for w.keepRunning { 54 | select { 55 | case event := <-fsWatcher.Events: 56 | if !isValidEvent(event) { 57 | continue 58 | } 59 | doNotify(event.Name) 60 | 61 | case err := <-fsWatcher.Errors: 62 | //w.errors <- err 63 | h := w.OnError 64 | if h != nil { 65 | h(err) 66 | } 67 | } 68 | } 69 | }() 70 | 71 | err = fsWatcher.Add(pathToFile) 72 | if err != nil { 73 | w.Stop() 74 | return nil, err 75 | } 76 | 77 | return w, nil 78 | } 79 | 80 | // Stop stops the watcher, releases all resources, ... 81 | func (w *Watcher) Stop() { 82 | if w.keepRunning == false { 83 | return 84 | } 85 | 86 | w.keepRunning = false 87 | w.watcher.Close() 88 | } 89 | 90 | func createDebounced(duration time.Duration, f func()) func() { 91 | debouncer := debounce.New(duration) 92 | debouncedFunc := func() { debouncer(f) } 93 | return debouncedFunc 94 | } 95 | 96 | func isValidEvent(event fsnotify.Event) bool { 97 | if (event.Op&fsnotify.Create == fsnotify.Create) || 98 | (event.Op&fsnotify.Rename == fsnotify.Rename) || 99 | (event.Op&fsnotify.Write == fsnotify.Write) { 100 | return true 101 | } 102 | 103 | return false 104 | } 105 | --------------------------------------------------------------------------------