github.com/grailbio/base@v0.0.11/cmd/ticket-server/googlegroups.go (about) 1 // Copyright 2018 GRAIL, Inc. All rights reserved. 2 // Use of this source code is governed by the Apache-2.0 3 // license that can be found in the LICENSE file. 4 5 package main 6 7 import ( 8 "fmt" 9 "regexp" 10 "strings" 11 "time" 12 13 "github.com/grailbio/base/common/log" 14 "github.com/grailbio/base/ttlcache" 15 "golang.org/x/net/context" 16 "golang.org/x/oauth2/jwt" 17 admin "google.golang.org/api/admin/directory/v1" 18 v23context "v.io/v23/context" 19 "v.io/v23/security" 20 "v.io/v23/security/access" 21 "v.io/v23/vdl" 22 ) 23 24 type cacheKey struct { 25 user string 26 group string 27 } 28 29 // cacheTTL is how long the entries in cache will be considered valid. 30 const cacheTTL = time.Minute 31 32 var cache = ttlcache.New(cacheTTL) 33 34 // email returns the user email from a Vanadium blessing that was produced via 35 // a BlessGoogle call. 36 var ( 37 groupRE *regexp.Regexp 38 userRE *regexp.Regexp 39 adminLookupDomain string 40 ) 41 42 func googleGroupsInit(ctx *v23context.T, groupLookupName string) { 43 if hostedDomains == nil || len(hostedDomains) == 0 { 44 log.Error(ctx, "hostedDomains not initialized.") 45 panic("hostedDomains not initialized") 46 } 47 48 // Extract the domain of the admin account to filter users in the same Google Domain 49 adminLookupDomain = emailDomain(groupLookupName) 50 groupRE = regexp.MustCompile(strings.Join([]string{"^" + "googlegroups", fmt.Sprintf("([a-z0-9-_+.]+@[a-z0-9-_+.]+)$")}, security.ChainSeparator)) 51 // NOTE This is a non terminating string, because the user validation can be terminated by the ChainSeparator (`:`) 52 userRE = regexp.MustCompile(strings.Join([]string{"^" + extensionPrefix, fmt.Sprintf("([a-z0-9-_+.]+@[a-z0-9-_+.]+)")}, security.ChainSeparator)) 53 } 54 55 //verifyAndExtractEmailFromBlessing returns the email address defined in a v23 principal/blessing 56 // 57 // For example, for 'v23.grail.com:google:razvanm@grailbio.com' the return 58 // string should be 'razvanm@grailbio.com'. 59 func verifyAndExtractEmailFromBlessing(blessing string, prefix string) string { 60 if strings.HasPrefix(blessing, prefix) && blessing != prefix { 61 m := userRE.FindStringSubmatch(blessing[len(prefix)+1:]) 62 if m != nil && stringInSlice(hostedDomains, emailDomain(m[1])) { 63 return m[1] 64 } 65 } 66 return "" 67 } 68 69 // extractGroupEmailFromBlessing returns the Google Groups name from a Vanadium blessing. 70 // 71 // For example, for 'v23.grail.com:googlegroups:eng@grailbio.com' the return 72 // string should be 'eng@grailbio.com'. 73 func extractGroupEmailFromBlessing(ctx *v23context.T, blessing string, prefix string) string { 74 log.Debug(ctx, "extracting group email from blessing", "blessing", blessing, "prefix", prefix) 75 if strings.HasPrefix(blessing, prefix) { 76 m := groupRE.FindStringSubmatch(blessing[len(prefix)+1:]) 77 78 if m != nil && stringInSlice(hostedDomains, emailDomain(m[1])) { 79 return m[1] 80 } 81 } 82 return "" 83 } 84 85 type authorizer struct { 86 perms access.Permissions 87 tagType *vdl.Type 88 // isMember checks if a user is member of a particular Google Group. 89 isMember func(user, group string) bool 90 } 91 92 func (a *authorizer) String() string { 93 return fmt.Sprintf("%+v", a.perms) 94 } 95 96 func googleGroupsAuthorizer(ctx *v23context.T, perms access.Permissions, jwtConfig *jwt.Config, 97 groupLookupName string) security.Authorizer { 98 googleGroupsInit(ctx, groupLookupName) 99 return &authorizer{ 100 perms: perms, 101 tagType: access.TypicalTagType(), 102 isMember: func(user, group string) bool { 103 key := cacheKey{user, group} 104 if v, ok := cache.Get(key); ok { 105 log.Debug(ctx, "Google groups lookup cache hit", "key", key) 106 return v.(bool) 107 } 108 log.Debug(ctx, "Google groups lookup cache miss", "key", key) 109 110 config := *jwtConfig 111 // This needs to be a Super Admin of the domain. 112 config.Subject = groupLookupName 113 114 service, err := admin.New(config.Client(context.Background())) 115 if err != nil { 116 log.Error(ctx, err.Error()) 117 return false 118 } 119 120 // If the group is in a different domain, perform a user based group membership check 121 // This loses the ability to check for nested groups - see https://phabricator.grailbio.com/D13275 122 // and https://github.com/googleapis/google-api-java-client/issues/1082 123 if adminLookupDomain != emailDomain(user) { 124 member, member_err := admin.NewMembersService(service).Get(group, user).Do() 125 if member_err != nil { 126 log.Error(ctx, member_err.Error()) 127 return false 128 } 129 log.Debug(ctx, "adding member to cache", "member", member, "key", key) 130 isMember := member.Status == "ACTIVE" 131 cache.Set(key, isMember) 132 return isMember 133 } 134 135 result, err := admin.NewMembersService(service).HasMember(group, user).Do() 136 if err != nil { 137 log.Error(ctx, err.Error()) 138 return false 139 } 140 log.Debug(ctx, "adding member to cache", "hasMember", result, "key", key) 141 cache.Set(key, result.IsMember) 142 143 return result.IsMember 144 }, 145 } 146 } 147 148 func (a *authorizer) pruneBlessingslist(ctx *v23context.T, acl access.AccessList, blessings []string, localBlessings string) []string { 149 if len(acl.NotIn) == 0 { 150 return blessings 151 } 152 var filtered []string 153 for _, b := range blessings { 154 inDenyList := false 155 for _, bp := range acl.NotIn { 156 if security.BlessingPattern(bp).MatchedBy(b) { 157 inDenyList = true 158 break 159 } 160 userEmail := verifyAndExtractEmailFromBlessing(b, localBlessings) 161 groupEmail := extractGroupEmailFromBlessing(ctx, bp, localBlessings) 162 log.Debug(ctx, "pruning blessings list", "userEmail", userEmail, "groupEmail", groupEmail) 163 if userEmail != "" && groupEmail != "" { 164 if a.isMember(userEmail, groupEmail) { 165 log.Debug(ctx, "user is a member of group", "userEmail", userEmail, "groupEmail", groupEmail, 166 "blessingPattern", bp) 167 inDenyList = true 168 break 169 } 170 } 171 } 172 if !inDenyList { 173 filtered = append(filtered, b) 174 } 175 } 176 return filtered 177 } 178 179 func (a *authorizer) aclIncludes(ctx *v23context.T, acl access.AccessList, blessings []string, 180 localBlessings string) bool { 181 blessings = a.pruneBlessingslist(ctx, acl, blessings, localBlessings) 182 for _, bp := range acl.In { 183 if bp.MatchedBy(blessings...) { 184 return true 185 } 186 for _, b := range blessings { 187 userEmail := verifyAndExtractEmailFromBlessing(b, localBlessings) 188 groupEmail := extractGroupEmailFromBlessing(ctx, string(bp), localBlessings) 189 log.Debug(ctx, "checking access list", "userEmail", userEmail, "groupEmail", groupEmail) 190 if userEmail != "" && groupEmail != "" { 191 if a.isMember(userEmail, groupEmail) { 192 log.Debug(ctx, "user is a member of group", "userEmail", userEmail, "groupEmail", groupEmail, 193 "blessingPattern", bp) 194 return true 195 } 196 } 197 } 198 } 199 return false 200 } 201 202 func (a *authorizer) Authorize(ctx *v23context.T, call security.Call) error { 203 blessings, invalid := security.RemoteBlessingNames(ctx, call) 204 log.Debug(ctx, "authorizing via Google flow", "blessings", blessings, "tags", call.MethodTags()) 205 206 for _, tag := range call.MethodTags() { 207 if tag.Type() == a.tagType { 208 if acl, exists := a.perms[tag.RawString()]; !exists || !a.aclIncludes(ctx, acl, blessings, 209 call.LocalBlessings().String()) { 210 return access.ErrorfNoPermissions(ctx, "%v %v %v", blessings, invalid, tag.RawString()) 211 } 212 } 213 } 214 return nil 215 }