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  }