@ -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"
@ -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" `
@ -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 ) )
@ -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
}
}
}
@ -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
}
@ -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
}
@ -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 {
@ -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 { }
@ -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 )
}
@ -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
}