Skip to content

feat: [Google connector] use Cloud Identity API for fetching groups #4169

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 148 additions & 22 deletions connector/google/google.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
admin "google.golang.org/api/admin/directory/v1"
"google.golang.org/api/cloudidentity/v1"
"google.golang.org/api/impersonate"
"google.golang.org/api/option"

Expand Down Expand Up @@ -53,11 +54,17 @@ type Config struct {
// Deprecated: Use DomainToAdminEmail
AdminEmail string

// Required if ServiceAccountFilePath
// If ServiceAccountFilePath is set, this value is ignored if UseCloudIdentityAPI is set. Otherwise, it's required.
// The map workspace domain to email of a GSuite super user which the service account will impersonate
// when listing groups
DomainToAdminEmail map[string]string

// If set, Cloud Identity API is used to fetch groups for a user. In particular, no user impersonation takes place.
// If ServiceAccountFilePath is not set, Application Default Credentials will be used. Otherwise, credentials will
// be generated from the file placed at the specified path.
// Defaults to false.
UseCloudIdentityAPI bool `json:"useCloudIdentityAPI"`

// If this field is true, fetch direct group membership and transitive group membership
FetchTransitiveGroupMembership bool `json:"fetchTransitiveGroupMembership"`

Expand All @@ -66,6 +73,14 @@ type Config struct {
PromptType *string `json:"promptType"`
}

func validateConfigForCloudIdentity(c *Config, logger *slog.Logger) error {
if len(c.DomainToAdminEmail) > 0 || len(c.AdminEmail) > 0 {
logger.Warn("For cloud identity calls \"DomainToAdminEmail\" and \"AdminEmail\" are ignored. It's safe to remove both configuration options.")
}

return nil
}

// Open returns a connector which can be used to login users through Google.
func (c *Config) Open(id string, logger *slog.Logger) (conn connector.Connector, err error) {
logger = logger.With(slog.Group("connector", "type", "google", "id", id))
Expand Down Expand Up @@ -94,22 +109,38 @@ func (c *Config) Open(id string, logger *slog.Logger) (conn connector.Connector,

adminSrv := make(map[string]*admin.Service)

// We know impersonation is required when using a service account credential
// TODO: or is it?
if len(c.DomainToAdminEmail) == 0 && c.ServiceAccountFilePath != "" {
cancel()
return nil, fmt.Errorf("directory service requires the domainToAdminEmail option to be configured")
}
var groupsMembershipsService *cloudidentity.GroupsMembershipsService

if (len(c.DomainToAdminEmail) > 0) || slices.Contains(scopes, "groups") {
for domain, adminEmail := range c.DomainToAdminEmail {
srv, err := createDirectoryService(c.ServiceAccountFilePath, adminEmail, logger)
if err != nil {
cancel()
return nil, fmt.Errorf("could not create directory service: %v", err)
}
if c.UseCloudIdentityAPI {
err = validateConfigForCloudIdentity(c, logger)
if err != nil {
cancel()
return nil, err
}

adminSrv[domain] = srv
groupsMembershipsService, err = createGroupsMembershipsService(c.ServiceAccountFilePath, logger)
if err != nil {
cancel()
return nil, fmt.Errorf("could not create groups memebership service: %v", err)
}
} else {
// We know impersonation is required when using a service account credential
// TODO: or is it?
if len(c.DomainToAdminEmail) == 0 && c.ServiceAccountFilePath != "" {
cancel()
return nil, fmt.Errorf("directory service requires the domainToAdminEmail option to be configured")
}

if (len(c.DomainToAdminEmail) > 0) || slices.Contains(scopes, "groups") {
for domain, adminEmail := range c.DomainToAdminEmail {
srv, err := createDirectoryService(c.ServiceAccountFilePath, adminEmail, logger)
if err != nil {
cancel()
return nil, fmt.Errorf("could not create directory service: %v", err)
}

adminSrv[domain] = srv
}
}
}

Expand Down Expand Up @@ -137,8 +168,10 @@ func (c *Config) Open(id string, logger *slog.Logger) (conn connector.Connector,
groups: c.Groups,
serviceAccountFilePath: c.ServiceAccountFilePath,
domainToAdminEmail: c.DomainToAdminEmail,
useCloudIdentityAPI: c.UseCloudIdentityAPI,
fetchTransitiveGroupMembership: c.FetchTransitiveGroupMembership,
adminSrv: adminSrv,
groupsMembershipsService: groupsMembershipsService,
promptType: promptType,
}, nil
}
Expand All @@ -158,8 +191,10 @@ type googleConnector struct {
groups []string
serviceAccountFilePath string
domainToAdminEmail map[string]string
useCloudIdentityAPI bool
fetchTransitiveGroupMembership bool
adminSrv map[string]*admin.Service
groupsMembershipsService *cloudidentity.GroupsMembershipsService
promptType string
}

Expand Down Expand Up @@ -262,11 +297,22 @@ func (c *googleConnector) createIdentity(ctx context.Context, identity connector
}

var groups []string
if s.Groups && len(c.adminSrv) > 0 {
if s.Groups {
checkedGroups := make(map[string]struct{})
groups, err = c.getGroups(claims.Email, c.fetchTransitiveGroupMembership, checkedGroups)
if err != nil {
return identity, fmt.Errorf("google: could not retrieve groups: %v", err)
if c.useCloudIdentityAPI {
if c.groupsMembershipsService != nil {
groups, err = c.getGroupsFromCloudIdentityAPI(claims.Email, c.fetchTransitiveGroupMembership, checkedGroups)
if err != nil {
return identity, fmt.Errorf("google: could not retrieve groups from Cloud Identity API: %v", err)
}
}
} else {
if len(c.adminSrv) > 0 {
groups, err = c.getGroupsFromAdminAPI(claims.Email, c.fetchTransitiveGroupMembership, checkedGroups)
if err != nil {
return identity, fmt.Errorf("google: could not retrieve groups form Admin API: %v", err)
}
}
}

if len(c.groups) > 0 {
Expand All @@ -288,9 +334,9 @@ func (c *googleConnector) createIdentity(ctx context.Context, identity connector
return identity, nil
}

// getGroups creates a connection to the admin directory service and lists
// getGroupsFromAdminAPI creates a connection to the admin directory service and lists
// all groups the user is a member of
func (c *googleConnector) getGroups(email string, fetchTransitiveGroupMembership bool, checkedGroups map[string]struct{}) ([]string, error) {
func (c *googleConnector) getGroupsFromAdminAPI(email string, fetchTransitiveGroupMembership bool, checkedGroups map[string]struct{}) ([]string, error) {
var userGroups []string
var err error
groupsList := &admin.Groups{}
Expand Down Expand Up @@ -321,7 +367,53 @@ func (c *googleConnector) getGroups(email string, fetchTransitiveGroupMembership
}

// getGroups takes a user's email/alias as well as a group's email/alias
transitiveGroups, err := c.getGroups(group.Email, fetchTransitiveGroupMembership, checkedGroups)
transitiveGroups, err := c.getGroupsFromAdminAPI(group.Email, fetchTransitiveGroupMembership, checkedGroups)
if err != nil {
return nil, fmt.Errorf("could not list transitive groups: %v", err)
}

userGroups = append(userGroups, transitiveGroups...)
}

if groupsList.NextPageToken == "" {
break
}
}

return userGroups, nil
}

// getGroupsFromCloudIdentityAPI creates a connection to the cloud identity service and lists
// all groups the user is a member of
func (c *googleConnector) getGroupsFromCloudIdentityAPI(email string, fetchTransitiveGroupMembership bool, checkedGroups map[string]struct{}) ([]string, error) {
var userGroups []string
var err error
groupsList := &cloudidentity.SearchDirectGroupsResponse{}
groupsMembershipService := c.groupsMembershipsService

for {
query := fmt.Sprintf("member_key_id=='%s'", email)
groupsList, err = groupsMembershipService.SearchDirectGroups("groups/-").
Query(query).PageToken(groupsList.NextPageToken).Do()
if err != nil {
return nil, fmt.Errorf("could not list groups: %v", err)
}

for _, membership := range groupsList.Memberships {
groupEmail := strings.ToLower(membership.GroupKey.Id)
if _, exists := checkedGroups[groupEmail]; exists {
continue
}

checkedGroups[groupEmail] = struct{}{}
// TODO (joelspeed): Make desired group key configurable
userGroups = append(userGroups, groupEmail)

if !fetchTransitiveGroupMembership {
continue
}

transitiveGroups, err := c.getGroupsFromCloudIdentityAPI(groupEmail, fetchTransitiveGroupMembership, checkedGroups)
if err != nil {
return nil, fmt.Errorf("could not list transitive groups: %v", err)
}
Expand Down Expand Up @@ -455,3 +547,37 @@ func createDirectoryService(serviceAccountFilePath, email string, logger *slog.L

return admin.NewService(ctx, option.WithHTTPClient(config.Client(ctx)))
}

func createGroupsMembershipsService(serviceAccountFilePath string, logger *slog.Logger) (service *cloudidentity.GroupsMembershipsService, err error) {
ctx := context.Background()
var credentials *google.Credentials

var cloudIdentityService *cloudidentity.Service

if serviceAccountFilePath == "" {
logger.Info("Using Application Default Credentials")
cloudIdentityService, err = cloudidentity.NewService(ctx, option.WithScopes(cloudidentity.CloudIdentityGroupsReadonlyScope))
if err != nil {
return nil, fmt.Errorf("error creating cloud identity service: %v", err)
}
} else {
logger.Info("Using credentials file at", "sa_path", serviceAccountFilePath)

jsonCredentials, err := os.ReadFile(serviceAccountFilePath)
if err != nil {
return nil, fmt.Errorf("error reading credentials from file: %v", err)
}

credentials, err = google.CredentialsFromJSON(ctx, jsonCredentials, cloudidentity.CloudIdentityGroupsReadonlyScope)
if err != nil {
return nil, fmt.Errorf("failed creating credentials from file: %w", err)
}

cloudIdentityService, err = cloudidentity.NewService(ctx, option.WithCredentials(credentials))
if err != nil {
return nil, fmt.Errorf("error creating cloud identity service: %v", err)
}
}

return cloudidentity.NewGroupsMembershipsService(cloudIdentityService), nil
}
Loading