From 6aad0959fe842b86b68abad267447feeac449c98 Mon Sep 17 00:00:00 2001 From: Christoph Mewes Date: Sat, 9 Jan 2021 23:13:06 +0100 Subject: [PATCH 01/18] orgunit's paths are always derived and equal the unit's name; they cannot be changed --- pkg/config/config.go | 9 --------- pkg/export/export.go | 1 - pkg/glib/glib.go | 20 +++++++++----------- 3 files changed, 9 insertions(+), 21 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index b92d8bc..ace0321 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -73,7 +73,6 @@ type OrgUnitConfig struct { Name string `yaml:"name"` Description string `yaml:"description,omitempty"` ParentOrgUnitPath string `yaml:"parent_org_unit_path,omitempty"` - OrgUnitPath string `yaml:"org_unit_path,omitempty"` BlockInheritance bool `yaml:"block_inheritance,omitempty"` } @@ -294,14 +293,6 @@ func (c *Config) ValidateOrgUnits() []error { allTheErrors = append(allTheErrors, fmt.Errorf("'ParentOrgUnitPath' must start with a slash (org unit %s)", ou.Name)) } } - - if ou.OrgUnitPath == "" { - allTheErrors = append(allTheErrors, fmt.Errorf("'OrgUnitPath' is not specified (org unit %s)", ou.Name)) - } else { - if ou.OrgUnitPath[0] != '/' { - allTheErrors = append(allTheErrors, fmt.Errorf("'OrgUnitPath' must start with a slash (org unit %s)", ou.Name)) - } - } } if allTheErrors != nil { diff --git a/pkg/export/export.go b/pkg/export/export.go index 61df9e8..1c92361 100644 --- a/pkg/export/export.go +++ b/pkg/export/export.go @@ -106,7 +106,6 @@ func ExportOrgUnits(ctx context.Context, clientService *admin.Service, cfg *conf Description: ou.Description, ParentOrgUnitPath: ou.ParentOrgUnitPath, BlockInheritance: ou.BlockInheritance, - OrgUnitPath: ou.OrgUnitPath, }) } diff --git a/pkg/glib/glib.go b/pkg/glib/glib.go index 801246a..19bd72f 100644 --- a/pkg/glib/glib.go +++ b/pkg/glib/glib.go @@ -6,7 +6,6 @@ import ( "fmt" "io/ioutil" "strconv" - "strings" "time" "github.com/kubermatic-labs/gman/pkg/config" @@ -734,8 +733,12 @@ func DeleteOrgUnit(srv admin.Service, ou *admin.OrgUnit) error { func UpdateOrgUnit(srv admin.Service, ou *config.OrgUnitConfig) error { updatedOu := createGSuiteOUFromConfig(ou) - // the Orgunits.Update function takes as an argument the full org unit path, but without first slash... - _, err := srv.Orgunits.Update("my_customer", strings.TrimPrefix(ou.OrgUnitPath, "/"), updatedOu).Do() + // to update, we need the org unit's ID or its path; + // we have neither, but since the path is always just "{parent}/{orgunit-name}", + // we can construct it (there is no encoding/escaping in the paths, amazingly) + path := ou.ParentOrgUnitPath + "/" + ou.Name + + _, err := srv.Orgunits.Update("my_customer", path, updatedOu).Do() if err != nil { return fmt.Errorf("unable to update org unit: %v", err) } @@ -744,16 +747,11 @@ func UpdateOrgUnit(srv admin.Service, ou *config.OrgUnitConfig) error { // createGSuiteOUFromConfig converts a OrgUnitConfig to (GSuite) admin.OrgUnit func createGSuiteOUFromConfig(ou *config.OrgUnitConfig) *admin.OrgUnit { - googleOU := &admin.OrgUnit{ - Name: ou.Name, - //OrgUnitPath: ou.OrgUnitPath, + return &admin.OrgUnit{ + Name: ou.Name, + Description: ou.Description, ParentOrgUnitPath: ou.ParentOrgUnitPath, } - if ou.Description != "" { - googleOU.Description = ou.Description - } - - return googleOU } //----------------------------------------// From edfd5ab7398eb0de6cd84e7dd2149297372413a4 Mon Sep 17 00:00:00 2001 From: Christoph Mewes Date: Sat, 9 Jan 2021 23:24:11 +0100 Subject: [PATCH 02/18] remove redundant ...Config suffix from structs --- pkg/config/config.go | 69 ++++++++++++++++++++++---------------------- pkg/export/export.go | 8 ++--- pkg/glib/glib.go | 46 ++++++++++++++--------------- pkg/sync/sync.go | 23 ++++++++------- 4 files changed, 74 insertions(+), 72 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index ace0321..5dcfbd2 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -9,39 +9,40 @@ import ( "github.com/kubermatic-labs/gman/pkg/data" "github.com/kubermatic-labs/gman/pkg/util" + "gopkg.in/yaml.v3" ) type Config struct { - Organization string `yaml:"organization"` - OrgUnits []OrgUnitConfig `yaml:"org_units,omitempty"` - Users []UserConfig `yaml:"users,omitempty"` - Groups []GroupConfig `yaml:"groups,omitempty"` + Organization string `yaml:"organization"` + OrgUnits []OrgUnit `yaml:"org_units,omitempty"` + Users []User `yaml:"users,omitempty"` + Groups []Group `yaml:"groups,omitempty"` } -type UserConfig struct { - FirstName string `yaml:"given_name"` - LastName string `yaml:"family_name"` - PrimaryEmail string `yaml:"primary_email"` - SecondaryEmail string `yaml:"secondary_email,omitempty"` - Aliases []string `yaml:"aliases,omitempty"` - Phones []string `yaml:"phones,omitempty"` - RecoveryPhone string `yaml:"recovery_phone,omitempty"` - RecoveryEmail string `yaml:"recovery_email,omitempty"` - OrgUnitPath string `yaml:"org_unit_path,omitempty"` - Licenses []string `yaml:"licenses,omitempty"` - Employee EmployeeConfig `yaml:"employee_info,omitempty"` - Location LocationConfig `yaml:"location,omitempty"` - Address string `yaml:"addresses,omitempty"` +type User struct { + FirstName string `yaml:"given_name"` + LastName string `yaml:"family_name"` + PrimaryEmail string `yaml:"primary_email"` + SecondaryEmail string `yaml:"secondary_email,omitempty"` + Aliases []string `yaml:"aliases,omitempty"` + Phones []string `yaml:"phones,omitempty"` + RecoveryPhone string `yaml:"recovery_phone,omitempty"` + RecoveryEmail string `yaml:"recovery_email,omitempty"` + OrgUnitPath string `yaml:"org_unit_path,omitempty"` + Licenses []string `yaml:"licenses,omitempty"` + Employee Employee `yaml:"employee_info,omitempty"` + Location Location `yaml:"location,omitempty"` + Address string `yaml:"addresses,omitempty"` } -type LocationConfig struct { +type Location struct { Building string `yaml:"building,omitempty"` Floor string `yaml:"floor,omitempty"` FloorSection string `yaml:"floor_section,omitempty"` } -type EmployeeConfig struct { +type Employee struct { EmployeeID string `yaml:"employee_ID,omitempty"` Department string `yaml:"department,omitempty"` JobTitle string `yaml:"job_title,omitempty"` @@ -50,26 +51,26 @@ type EmployeeConfig struct { ManagerEmail string `yaml:"manager_email,omitempty"` } -type GroupConfig struct { - Name string `yaml:"name"` - Email string `yaml:"email"` - Description string `yaml:"description,omitempty"` - WhoCanContactOwner string `yaml:"who_can_contact_owner,omitempty"` - WhoCanViewMembership string `yaml:"who_can_view_members,omitempty"` - WhoCanApproveMembers string `yaml:"who_can_approve_members,omitempty"` - WhoCanPostMessage string `yaml:"who_can_post,omitempty"` - WhoCanJoin string `yaml:"who_can_join,omitempty"` - AllowExternalMembers bool `yaml:"allow_external_members"` - IsArchived bool `yaml:"is_archived"` - Members []MemberConfig `yaml:"members,omitempty"` +type Group struct { + Name string `yaml:"name"` + Email string `yaml:"email"` + Description string `yaml:"description,omitempty"` + WhoCanContactOwner string `yaml:"who_can_contact_owner,omitempty"` + WhoCanViewMembership string `yaml:"who_can_view_members,omitempty"` + WhoCanApproveMembers string `yaml:"who_can_approve_members,omitempty"` + WhoCanPostMessage string `yaml:"who_can_post,omitempty"` + WhoCanJoin string `yaml:"who_can_join,omitempty"` + AllowExternalMembers bool `yaml:"allow_external_members"` + IsArchived bool `yaml:"is_archived"` + Members []Member `yaml:"members,omitempty"` } -type MemberConfig struct { +type Member struct { Email string `yaml:"email"` Role string `yaml:"role,omitempty"` } -type OrgUnitConfig struct { +type OrgUnit struct { Name string `yaml:"name"` Description string `yaml:"description,omitempty"` ParentOrgUnitPath string `yaml:"parent_org_unit_path,omitempty"` diff --git a/pkg/export/export.go b/pkg/export/export.go index 1c92361..29d6525 100644 --- a/pkg/export/export.go +++ b/pkg/export/export.go @@ -18,7 +18,7 @@ func ExportUsers(ctx context.Context, clientService *admin.Service, licensingSer return err } - cfg.Users = []config.UserConfig{} + cfg.Users = []config.User{} // save to file if len(users) == 0 { @@ -53,7 +53,7 @@ func ExportGroups(ctx context.Context, clientService *admin.Service, groupServic } var members []*admin.Member - cfg.Groups = []config.GroupConfig{} + cfg.Groups = []config.Group{} // save to file if len(groups) == 0 { @@ -92,7 +92,7 @@ func ExportOrgUnits(ctx context.Context, clientService *admin.Service, cfg *conf return err } - cfg.OrgUnits = []config.OrgUnitConfig{} + cfg.OrgUnits = []config.OrgUnit{} // save to file if len(orgUnits) == 0 { @@ -101,7 +101,7 @@ func ExportOrgUnits(ctx context.Context, clientService *admin.Service, cfg *conf for _, ou := range orgUnits { log.Printf(" %s", ou.Name) - cfg.OrgUnits = append(cfg.OrgUnits, config.OrgUnitConfig{ + cfg.OrgUnits = append(cfg.OrgUnits, config.OrgUnit{ Name: ou.Name, Description: ou.Description, ParentOrgUnitPath: ou.ParentOrgUnitPath, diff --git a/pkg/glib/glib.go b/pkg/glib/glib.go index 19bd72f..c12c38c 100644 --- a/pkg/glib/glib.go +++ b/pkg/glib/glib.go @@ -150,7 +150,7 @@ func GetUserEmails(user *admin.User) (string, string) { } // CreateUser creates a new user in GSuite via their API -func CreateUser(srv admin.Service, licensingSrv *LicensingService, user *config.UserConfig) error { +func CreateUser(srv admin.Service, licensingSrv *LicensingService, user *config.User) error { // generate a rand password pass, err := password.Generate(20, 5, 5, false, false) if err != nil { @@ -188,7 +188,7 @@ func DeleteUser(srv admin.Service, user *admin.User) error { } // UpdateUser updates the remote user with config -func UpdateUser(srv admin.Service, licensingSrv *LicensingService, user *config.UserConfig) error { +func UpdateUser(srv admin.Service, licensingSrv *LicensingService, user *config.User) error { updatedUser := createGSuiteUserFromConfig(srv, user) _, err := srv.Users.Update(user.PrimaryEmail, updatedUser).Do() if err != nil { @@ -267,7 +267,7 @@ func HandleUserAliases(srv admin.Service, googleUser *admin.User, configAliases } // createGSuiteUserFromConfig converts a ConfigUser to (GSuite) admin.User -func createGSuiteUserFromConfig(srv admin.Service, user *config.UserConfig) *admin.User { +func createGSuiteUserFromConfig(srv admin.Service, user *config.User) *admin.User { googleUser := &admin.User{ Name: &admin.UserName{ GivenName: user.FirstName, @@ -317,7 +317,7 @@ func createGSuiteUserFromConfig(srv admin.Service, user *config.UserConfig) *adm googleUser.Emails = workEm } - if user.Employee != (config.EmployeeConfig{}) { + if user.Employee != (config.Employee{}) { uOrg := []admin.UserOrganization{ { Department: user.Employee.Department, @@ -350,7 +350,7 @@ func createGSuiteUserFromConfig(srv admin.Service, user *config.UserConfig) *adm } } - if user.Location != (config.LocationConfig{}) { + if user.Location != (config.Location{}) { loc := []admin.UserLocation{ { Area: "desk", @@ -367,11 +367,11 @@ func createGSuiteUserFromConfig(srv admin.Service, user *config.UserConfig) *adm } // createConfigUserFromGSuite converts a (GSuite) admin.User to ConfigUser -func CreateConfigUserFromGSuite(googleUser *admin.User, userLicenses []data.License) config.UserConfig { +func CreateConfigUserFromGSuite(googleUser *admin.User, userLicenses []data.License) config.User { // get emails primaryEmail, secondaryEmail := GetUserEmails(googleUser) - configUser := config.UserConfig{ + configUser := config.User{ FirstName: googleUser.Name.GivenName, LastName: googleUser.Name.FamilyName, PrimaryEmail: primaryEmail, @@ -516,7 +516,7 @@ func GetSettingOfGroup(srv *groupssettings.Service, groupId string) (*groupssett } // CreateGroup creates a new group in GSuite via their API -func CreateGroup(srv admin.Service, grSrv groupssettings.Service, group *config.GroupConfig) error { +func CreateGroup(srv admin.Service, grSrv groupssettings.Service, group *config.Group) error { newGroup, groupSettings := CreateGSuiteGroupFromConfig(group) _, err := srv.Groups.Insert(newGroup).Do() if err != nil { @@ -546,7 +546,7 @@ func DeleteGroup(srv admin.Service, group *admin.Group) error { } // UpdateGroup updates the remote group with config -func UpdateGroup(srv admin.Service, grSrv groupssettings.Service, group *config.GroupConfig) error { +func UpdateGroup(srv admin.Service, grSrv groupssettings.Service, group *config.Group) error { updatedGroup, groupSettings := CreateGSuiteGroupFromConfig(group) _, err := srv.Groups.Update(group.Email, updatedGroup).Do() if err != nil { @@ -562,7 +562,7 @@ func UpdateGroup(srv admin.Service, grSrv groupssettings.Service, group *config. } // createGSuiteGroupFromConfig converts a ConfigGroup to (GSuite) admin.Group -func CreateGSuiteGroupFromConfig(group *config.GroupConfig) (*admin.Group, *groupssettings.Groups) { +func CreateGSuiteGroupFromConfig(group *config.Group) (*admin.Group, *groupssettings.Groups) { googleGroup := &admin.Group{ Name: group.Name, Email: group.Email, @@ -585,17 +585,17 @@ func CreateGSuiteGroupFromConfig(group *config.GroupConfig) (*admin.Group, *grou return googleGroup, groupSettings } -func CreateConfigGroupFromGSuite(googleGroup *admin.Group, members []*admin.Member, gSettings *groupssettings.Groups) (config.GroupConfig, error) { +func CreateConfigGroupFromGSuite(googleGroup *admin.Group, members []*admin.Member, gSettings *groupssettings.Groups) (config.Group, error) { boolAllowExternalMembers, err := strconv.ParseBool(gSettings.AllowExternalMembers) if err != nil { - return config.GroupConfig{}, fmt.Errorf("could not parse 'AllowExternalMembers' value from string to bool: %v", err) + return config.Group{}, fmt.Errorf("could not parse 'AllowExternalMembers' value from string to bool: %v", err) } boolIsArchived, err := strconv.ParseBool(gSettings.IsArchived) if err != nil { - return config.GroupConfig{}, fmt.Errorf("could not parse 'IsArchived' value from string to bool: %v", err) + return config.Group{}, fmt.Errorf("could not parse 'IsArchived' value from string to bool: %v", err) } - configGroup := config.GroupConfig{ + configGroup := config.Group{ Name: googleGroup.Name, Email: googleGroup.Email, Description: googleGroup.Description, @@ -606,11 +606,11 @@ func CreateConfigGroupFromGSuite(googleGroup *admin.Group, members []*admin.Memb WhoCanJoin: gSettings.WhoCanJoin, AllowExternalMembers: boolAllowExternalMembers, IsArchived: boolIsArchived, - Members: []config.MemberConfig{}, + Members: []config.Member{}, } for _, m := range members { - configGroup.Members = append(configGroup.Members, config.MemberConfig{ + configGroup.Members = append(configGroup.Members, config.Member{ Email: m.Email, Role: m.Role, }) @@ -648,7 +648,7 @@ func GetListOfMembers(srv *admin.Service, group *admin.Group) ([]*admin.Member, } // AddNewMember adds a new member to a group in GSuite -func AddNewMember(srv admin.Service, groupEmail string, member *config.MemberConfig) error { +func AddNewMember(srv admin.Service, groupEmail string, member *config.Member) error { newMember := createGSuiteGroupMemberFromConfig(member) _, err := srv.Members.Insert(groupEmail, newMember).Do() if err != nil { @@ -667,7 +667,7 @@ func RemoveMember(srv admin.Service, groupEmail string, member *admin.Member) er } // MemberExists checks if member exists in group -func MemberExists(srv admin.Service, group *admin.Group, member *config.MemberConfig) (bool, error) { +func MemberExists(srv admin.Service, group *admin.Group, member *config.Member) (bool, error) { exists, err := srv.Members.HasMember(group.Email, member.Email).Do() if err != nil { return false, fmt.Errorf("unable to check if member %s exists in a group %s: %v", member.Email, group.Name, err) @@ -677,7 +677,7 @@ func MemberExists(srv admin.Service, group *admin.Group, member *config.MemberCo // UpdateMembership changes the role of the member // Update(groupKey string, memberKey string, member *Member) -func UpdateMembership(srv admin.Service, groupEmail string, member *config.MemberConfig) error { +func UpdateMembership(srv admin.Service, groupEmail string, member *config.Member) error { newMember := createGSuiteGroupMemberFromConfig(member) _, err := srv.Members.Update(groupEmail, member.Email, newMember).Do() if err != nil { @@ -687,7 +687,7 @@ func UpdateMembership(srv admin.Service, groupEmail string, member *config.Membe } // createGSuiteGroupMemberFromConfig converts a ConfigMember to (GSuite) admin.Member -func createGSuiteGroupMemberFromConfig(member *config.MemberConfig) *admin.Member { +func createGSuiteGroupMemberFromConfig(member *config.Member) *admin.Member { googleMember := &admin.Member{ Email: member.Email, Role: member.Role, @@ -710,7 +710,7 @@ func GetListOfOrgUnits(srv *admin.Service) ([]*admin.OrgUnit, error) { } // CreateOrgUnit creates a new org unit in GSuite via their API -func CreateOrgUnit(srv admin.Service, ou *config.OrgUnitConfig) error { +func CreateOrgUnit(srv admin.Service, ou *config.OrgUnit) error { newOU := createGSuiteOUFromConfig(ou) _, err := srv.Orgunits.Insert("my_customer", newOU).Do() if err != nil { @@ -730,7 +730,7 @@ func DeleteOrgUnit(srv admin.Service, ou *admin.OrgUnit) error { } // UpdateOrgUnit updates the remote org unit with config -func UpdateOrgUnit(srv admin.Service, ou *config.OrgUnitConfig) error { +func UpdateOrgUnit(srv admin.Service, ou *config.OrgUnit) error { updatedOu := createGSuiteOUFromConfig(ou) // to update, we need the org unit's ID or its path; @@ -746,7 +746,7 @@ func UpdateOrgUnit(srv admin.Service, ou *config.OrgUnitConfig) error { } // createGSuiteOUFromConfig converts a OrgUnitConfig to (GSuite) admin.OrgUnit -func createGSuiteOUFromConfig(ou *config.OrgUnitConfig) *admin.OrgUnit { +func createGSuiteOUFromConfig(ou *config.OrgUnit) *admin.OrgUnit { return &admin.OrgUnit{ Name: ou.Name, Description: ou.Description, diff --git a/pkg/sync/sync.go b/pkg/sync/sync.go index 48ea41d..2aa6942 100644 --- a/pkg/sync/sync.go +++ b/pkg/sync/sync.go @@ -8,6 +8,7 @@ import ( "github.com/kubermatic-labs/gman/pkg/config" "github.com/kubermatic-labs/gman/pkg/glib" + admin "google.golang.org/api/admin/directory/v1" "google.golang.org/api/groupssettings/v1" ) @@ -15,8 +16,8 @@ import ( func SyncUsers(ctx context.Context, clientService *admin.Service, licensingService *glib.LicensingService, cfg *config.Config, confirm bool) error { var ( usersToDelete []*admin.User - usersToCreate []config.UserConfig - usersToUpdate []config.UserConfig + usersToCreate []config.User + usersToUpdate []config.User ) log.Println("⇄ Syncing users") @@ -139,17 +140,17 @@ func SyncUsers(ctx context.Context, clientService *admin.Service, licensingServi // (Members array is not bounded to the Group object in the API) // helper to avoid global vars type groupUpdate struct { - groupToUpdate config.GroupConfig - membersToAdd []*config.MemberConfig + groupToUpdate config.Group + membersToAdd []*config.Member membersToRemove []*admin.Member - membersToUpdate []*config.MemberConfig + membersToUpdate []*config.Member } // SyncGroups func SyncGroups(ctx context.Context, clientService *admin.Service, groupService *groupssettings.Service, cfg *config.Config, confirm bool) error { var ( groupsToDelete []*admin.Group - groupsToCreate []config.GroupConfig + groupsToCreate []config.Group groupsToUpdate []groupUpdate ) @@ -315,9 +316,9 @@ func SyncGroups(ctx context.Context, clientService *admin.Service, groupService return nil } -func SyncMembers(ctx context.Context, clientService *admin.Service, cfgGr *config.GroupConfig, curGr *admin.Group) ([]*config.MemberConfig, []*admin.Member, []*config.MemberConfig) { - var memToAdd []*config.MemberConfig - var memToUpdate []*config.MemberConfig +func SyncMembers(ctx context.Context, clientService *admin.Service, cfgGr *config.Group, curGr *admin.Group) ([]*config.Member, []*admin.Member, []*config.Member) { + var memToAdd []*config.Member + var memToUpdate []*config.Member var memToRemove []*admin.Member currentMembers, _ := glib.GetListOfMembers(clientService, curGr) @@ -358,8 +359,8 @@ func SyncMembers(ctx context.Context, clientService *admin.Service, cfgGr *confi func SyncOrgUnits(ctx context.Context, clientService *admin.Service, cfg *config.Config, confirm bool) error { var ( ouToDelete []*admin.OrgUnit - ouToCreate []config.OrgUnitConfig - ouToUpdate []config.OrgUnitConfig + ouToCreate []config.OrgUnit + ouToUpdate []config.OrgUnit ) log.Println("⇄ Syncing organizational units") From c18d87c1b5c89576a8503a2db433de88c5f3379f Mon Sep 17 00:00:00 2001 From: Christoph Mewes Date: Sat, 9 Jan 2021 23:27:31 +0100 Subject: [PATCH 03/18] switch to snakeCase for YAML syntax --- pkg/config/config.go | 46 ++++++++++++++++++++++---------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 5dcfbd2..51ce347 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -15,23 +15,23 @@ import ( type Config struct { Organization string `yaml:"organization"` - OrgUnits []OrgUnit `yaml:"org_units,omitempty"` + OrgUnits []OrgUnit `yaml:"orgUnits,omitempty"` Users []User `yaml:"users,omitempty"` Groups []Group `yaml:"groups,omitempty"` } type User struct { - FirstName string `yaml:"given_name"` - LastName string `yaml:"family_name"` - PrimaryEmail string `yaml:"primary_email"` - SecondaryEmail string `yaml:"secondary_email,omitempty"` + FirstName string `yaml:"givenName"` + LastName string `yaml:"familyName"` + PrimaryEmail string `yaml:"primaryEmail"` + SecondaryEmail string `yaml:"secondaryEmail,omitempty"` Aliases []string `yaml:"aliases,omitempty"` Phones []string `yaml:"phones,omitempty"` - RecoveryPhone string `yaml:"recovery_phone,omitempty"` - RecoveryEmail string `yaml:"recovery_email,omitempty"` - OrgUnitPath string `yaml:"org_unit_path,omitempty"` + RecoveryPhone string `yaml:"recoveryPhone,omitempty"` + RecoveryEmail string `yaml:"recoveryEmail,omitempty"` + OrgUnitPath string `yaml:"orgUnitPath,omitempty"` Licenses []string `yaml:"licenses,omitempty"` - Employee Employee `yaml:"employee_info,omitempty"` + Employee Employee `yaml:"employeeInfo,omitempty"` Location Location `yaml:"location,omitempty"` Address string `yaml:"addresses,omitempty"` } @@ -39,29 +39,29 @@ type User struct { type Location struct { Building string `yaml:"building,omitempty"` Floor string `yaml:"floor,omitempty"` - FloorSection string `yaml:"floor_section,omitempty"` + FloorSection string `yaml:"floorSection,omitempty"` } type Employee struct { - EmployeeID string `yaml:"employee_ID,omitempty"` + EmployeeID string `yaml:"id,omitempty"` Department string `yaml:"department,omitempty"` - JobTitle string `yaml:"job_title,omitempty"` + JobTitle string `yaml:"jobTitle,omitempty"` Type string `yaml:"type,omitempty"` - CostCenter string `yaml:"cost_center,omitempty"` - ManagerEmail string `yaml:"manager_email,omitempty"` + CostCenter string `yaml:"costCenter,omitempty"` + ManagerEmail string `yaml:"managerEmail,omitempty"` } type Group struct { Name string `yaml:"name"` Email string `yaml:"email"` Description string `yaml:"description,omitempty"` - WhoCanContactOwner string `yaml:"who_can_contact_owner,omitempty"` - WhoCanViewMembership string `yaml:"who_can_view_members,omitempty"` - WhoCanApproveMembers string `yaml:"who_can_approve_members,omitempty"` - WhoCanPostMessage string `yaml:"who_can_post,omitempty"` - WhoCanJoin string `yaml:"who_can_join,omitempty"` - AllowExternalMembers bool `yaml:"allow_external_members"` - IsArchived bool `yaml:"is_archived"` + WhoCanContactOwner string `yaml:"whoCanContactOwner,omitempty"` + WhoCanViewMembership string `yaml:"whoCanViewMembers,omitempty"` + WhoCanApproveMembers string `yaml:"whoCanApproveMembers,omitempty"` + WhoCanPostMessage string `yaml:"whoCanPostMessage,omitempty"` + WhoCanJoin string `yaml:"whoCanJoin,omitempty"` + AllowExternalMembers bool `yaml:"allowExternalMembers"` + IsArchived bool `yaml:"isArchived"` Members []Member `yaml:"members,omitempty"` } @@ -73,8 +73,8 @@ type Member struct { type OrgUnit struct { Name string `yaml:"name"` Description string `yaml:"description,omitempty"` - ParentOrgUnitPath string `yaml:"parent_org_unit_path,omitempty"` - BlockInheritance bool `yaml:"block_inheritance,omitempty"` + ParentOrgUnitPath string `yaml:"parentOrgUnitPath,omitempty"` + BlockInheritance bool `yaml:"blockInheritance,omitempty"` } func LoadFromFile(filename string) (*Config, error) { From 7210a0fdeb520af6674444b840fdc216e2e9342d Mon Sep 17 00:00:00 2001 From: Christoph Mewes Date: Mon, 11 Jan 2021 21:29:51 +0100 Subject: [PATCH 04/18] refactored API mostly, still lots of type juggling --- .gitignore | 9 +- CONTRIBUTING.md | 4 +- Configuration.md | 56 +- README.md | 34 +- go.mod | 1 + go.sum | 83 +++ main.go | 435 ++++++------ pkg/config/config.go | 7 +- pkg/{data/data.go => config/licenses.go} | 6 +- pkg/export/export.go | 142 ++-- pkg/glib/directory.go | 48 ++ pkg/glib/directory_groups.go | 204 ++++++ pkg/glib/directory_orgunits.go | 68 ++ pkg/glib/directory_users.go | 364 ++++++++++ pkg/glib/glib.go | 843 ----------------------- pkg/glib/groupssettings.go | 67 ++ pkg/glib/licensing.go | 175 +++++ pkg/glib/util.go | 19 + pkg/sync/compare.go | 41 ++ pkg/sync/groups.go | 172 +++++ pkg/sync/licensing.go | 77 +++ pkg/sync/orgunits.go | 83 +++ pkg/sync/sync.go | 473 ------------- pkg/sync/users.go | 149 ++++ 24 files changed, 1872 insertions(+), 1688 deletions(-) rename pkg/{data/data.go => config/licenses.go} (97%) create mode 100644 pkg/glib/directory.go create mode 100644 pkg/glib/directory_groups.go create mode 100644 pkg/glib/directory_orgunits.go create mode 100644 pkg/glib/directory_users.go delete mode 100644 pkg/glib/glib.go create mode 100644 pkg/glib/groupssettings.go create mode 100644 pkg/glib/licensing.go create mode 100644 pkg/glib/util.go create mode 100644 pkg/sync/compare.go create mode 100644 pkg/sync/groups.go create mode 100644 pkg/sync/licensing.go create mode 100644 pkg/sync/orgunits.go delete mode 100644 pkg/sync/sync.go create mode 100644 pkg/sync/users.go diff --git a/.gitignore b/.gitignore index 66fd13c..281b602 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,8 @@ -# Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib - -# Test binary, built with `go test -c` *.test - -# Output of the go coverage tool, specifically when used with LiteIDE *.out - -# Dependency directories (remove the comment below to include it) -# vendor/ +vendor/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 94f2a20..94c50ef 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,7 @@ Origin (DCO). This document was created by the Linux Kernel community and is a simple statement that you, as a contributor, have the legal right to make the contribution. See the [DCO](DCO) file for details. -Any copyright notices in this repo should specify the authors as "the Kubermatic Gman project contributors". +Any copyright notices in this repo should specify the authors as "the Kubermatic GMan project contributors". To sign your work, just add a line like this at the end of your commit message: @@ -26,7 +26,7 @@ By doing this you state that you can certify the following (from https://develop ## Email and Chat -The Gman project currently uses the general Kubermatic email list and Slack channel: +The GMan project currently uses the general Kubermatic email list and Slack channel: - Email: [Kubermatic-dev](https://groups.google.com/forum/#!forum/kubermatic-dev) - Slack: #[Slack](http://slack.kubermatic.io/) on Slack diff --git a/Configuration.md b/Configuration.md index 1b8b083..d37022c 100644 --- a/Configuration.md +++ b/Configuration.md @@ -1,6 +1,6 @@ -# Configuration +# Configuration -*Here can be found all the configuration details of Gman.* +*Here can be found all the configuration details of GMan.* **Table of contents:** @@ -19,7 +19,7 @@ ## Organizational Units -The organizational units are specified as the entries of the `org_units` collection. +The organizational units are specified as the entries of the `org_units` collection. Each OU contains: @@ -30,11 +30,11 @@ Each OU contains: | parentOrgUnitPath | string | The organizational unit's parent path. If the OU is directly under the parent organization, the entry should contain a single slash `/`. If OU is nested, then, for example, `/students/mathematics` is the parent path for `extended_math` organizational unit with full path `/students/math/extended_math`. | yes | | org_unit_path | string | The full path of the OU. It is derived from parentOrgUnitPath and organizational unit's name. | yes | -## Users +## Users -The users are specified as the entries of the `users` collection. +The users are specified as the entries of the `users` collection. -Each user contains: +Each user contains: | parameter | type | description | required | |------------|--------|--------------|----------| @@ -59,18 +59,18 @@ Each user contains: | location: floor_section | string | floor section | no | | addresses | string | private address of the user | no | -### User Licenses +### User Licenses The user's licenses are the Google products and related Stock Keeping Units (SKUs). -The official list of all the available products can be found in [the official Google documentation](https://developers.google.com/admin-sdk/licensing/v1/how-tos/products). +The official list of all the available products can be found in [the official Google documentation](https://developers.google.com/admin-sdk/licensing/v1/how-tos/products). -Gman supports the following names as the equivalents of the Google SKUs: +GMan supports the following names as the equivalents of the Google SKUs: -| Google SKU Name (License) | Gman value | +| Google SKU Name (License) | GMan value | |---------------------------|------------| | G Suite Enterprise | GSuiteEnterprise | | G Suite Business | GSuiteBusiness | -| G Suite Basic | GSuiteBasic +| G Suite Basic | GSuiteBasic | G Suite Essentials | GSuiteEssentials | | G Suite Lite | GSuiteLite | | Google Apps Message Security | GoogleAppsMessageSecurity | @@ -92,13 +92,13 @@ Gman supports the following names as the equivalents of the Google SKUs: | Google Voice Standard | GoogleVoiceStandard | | Google Voice Premier | GoogleVoicePremier | -Remark: *Cloud Identity Free Edition* is a site-wide SKU (applied at customer level), hence it cannot be managed by Gman as it is not assigned to individual users. +Remark: *Cloud Identity Free Edition* is a site-wide SKU (applied at customer level), hence it cannot be managed by GMan as it is not assigned to individual users. ## Groups -The groups are specified as the entries of the `groups` collection. +The groups are specified as the entries of the `groups` collection. -Each user contains: +Each user contains: | parameter | type | description | required | |-----------|------|-------------|----------| @@ -111,68 +111,68 @@ Each user contains: | who_can_post | string | permissions to post messages; for possible values see below | yes | | who_can_join | string | permissions to join group; for possible values see below | yes | | allow_external_members | bool | identifies whether members external to your organization can join the group | yes | -| is_archived | bool | allows the group content to be archived | yes | +| is_archived | bool | allows the group content to be archived | yes | | members | list of members | each member is specified by the email and the role; for the limits of numebr of users please refer to [the official Google documentation](https://support.google.com/a/answer/6099642?hl=en) | yes | | member: email | string | primary email of the user | yes | | member: role | string | role in the group of the user; possible values are: `MEMBER`, `OWNER` or `MANAGER` | yes | ### Group's Permissions -The group permissions designate who can perform which actions in the group. +The group permissions designate who can perform which actions in the group. -#### Contacting owner +#### Contacting owner -Permission to contact owner of the group via web UI. Field name is `who_can_contact_owner`. The entered values are case sensitive. +Permission to contact owner of the group via web UI. Field name is `who_can_contact_owner`. The entered values are case sensitive. | possible value | description | |----------------|-------------| | ALL_IN_DOMAIN_CAN_CONTACT | all users in the domain | | ALL_MANAGERS_CAN_CONTACT | only managers of the group | | ALL_MEMBERS_CAN_CONTACT | only members of the group | -| ANYONE_CAN_CONTACT | any Internet user | +| ANYONE_CAN_CONTACT | any Internet user | #### Viewing membership -Permissions to view group members. Field name is `who_can_view_members`. The entered values are case sensitive. +Permissions to view group members. Field name is `who_can_view_members`. The entered values are case sensitive. | possible value | description | |----------------|-------------| | ALL_IN_DOMAIN_CAN_VIEW | all users in the domain | | ALL_MANAGERS_CAN_VIEW | only managers of the group | | ALL_MEMBERS_CAN_VIEW | only members of the group | -| ANYONE_CAN_VIEW | anyone in the group | +| ANYONE_CAN_VIEW | anyone in the group | #### Approving membership -Permissions to approve members who ask to join group. Field name is `who_can_approve_members`. The entered values are case sensitive. +Permissions to approve members who ask to join group. Field name is `who_can_approve_members`. The entered values are case sensitive. | possible value | description | |----------------|-------------| | ALL_OWNERS_CAN_APPROVE | only owners of the group | | ALL_MANAGERS_CAN_APPROVE | only managers of the group | | ALL_MEMBERS_CAN_APPROVE | only members of the group | -| NONE_CAN_APPROVE | noone in the group | +| NONE_CAN_APPROVE | noone in the group | #### Posting messages -Permissions to post messages in the group. Field name is `who_can_post`. The entered values are case sensitive. +Permissions to post messages in the group. Field name is `who_can_post`. The entered values are case sensitive. | possible value | description | |----------------|-------------| | NONE_CAN_POST | the group is disabled and archived; 'is_archived' must be set to true, otherwise will result in an error | | ALL_MANAGERS_CAN_POST | only managers and owners of the group | | ALL_MEMBERS_CAN_POST | only members of the group | -| ALL_OWNERS_CAN_POST | only owners of the group | -| ALL_IN_DOMAIN_CAN_POST | anyone in the organization | +| ALL_OWNERS_CAN_POST | only owners of the group | +| ALL_IN_DOMAIN_CAN_POST | anyone in the organization | | ANYONE_CAN_POST | any Internet user who can access your Google Groups service | #### Joining group -Permissions to join the group. Field name is `who_can_join`. The entered values are case sensitive. +Permissions to join the group. Field name is `who_can_join`. The entered values are case sensitive. | possible value | description | |----------------|-------------| | ANYONE_CAN_JOIN | any Internet user who can access your Google Groups service | | ALL_IN_DOMAIN_CAN_JOIN | anyone in the organization | | INVITED_CAN_JOIN | only invited candidates | -| CAN_REQUEST_TO_JOIN | non-members can request an invitation to join | +| CAN_REQUEST_TO_JOIN | non-members can request an invitation to join | diff --git a/README.md b/README.md index 9023b3a..259be1c 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ -# gman +# GMan -*gman links all GSuite accounts with the matching user-list storage in form of a YAML. It is based on [Aquayman](https://github.com/kubermatic-labs/aquayman).* +*GMan links all GSuite accounts with the matching user-list storage in form of a YAML. It is based on [Aquayman](https://github.com/kubermatic-labs/aquayman).* **Features:** -- declare your users, groups and org units in code (IaC) via config YAML, which will then be applied to GSuite organization +- declare your users, groups and org units in code (infrastructure as code) via config YAML, which will then be applied to GSuite organization - export the current state as a starter config file - preview any action taken (validation) **Table of contents:** -- [gman](#gman) +- [GMan](#gman) - [Installation](#installation) - [Configuration & Authentication](#configuration--authentication) - [Basics: Admin & Directory API](#basics-admin--directory-api) @@ -45,7 +45,7 @@ Moreover, to access the extended settings of the groups, the **Groups Settings A ### Service account -To authorize and to perform the operations on behalf of *Gman* a Service Account is required. +To authorize and to perform the operations on behalf of *GMan* a Service Account is required. After creating one, it needs to be registered as an API client and have enabled this OAuth scopes: * https://www.googleapis.com/auth/admin.directory.user @@ -66,7 +66,7 @@ Those scopes can be added in Admin console under *Security -> API Controls -> Do Furthermore, please, generate a Key (save the *.json* config) for this Service Account. For more detailed information, follow [the official instructions](https://developers.google.com/admin-sdk/directory/v1/guides/delegation#create_the_service_account_and_credentials). -The Service Account private key must be provided to *Gman*. There are two ways to do so: +The Service Account private key must be provided to *GMan*. There are two ways to do so: - set up environmental variable: `GMAN_SERVICE_ACCOUNT_KEY=` - start the application with specified flag `-private-key ` @@ -77,7 +77,7 @@ Only users with access to the Admin APIs can access the Admin SDK Directory API, In order to delegate domain-wide authority to your service account follow this [official guide](https://developers.google.com/admin-sdk/directory/v1/guides/delegation#delegate_domain-wide_authority_to_your_service_account). -The impersonated email must be specified in *Gman*. There are two ways to do so: +The impersonated email must be specified in *GMan*. There are two ways to do so: - set up environmental variable: `GMAN_IMPERSONATED_EMAIL=` - start the application with specified flag `-impersonated-email ` @@ -87,7 +87,7 @@ The impersonated email must be specified in *Gman*. There are two ways to do so: All configuration of the users happens in a YAML file. See the [configuration documentation](/Configuration.md) for more information, available parameters and values, or refer to the annotated [config.example.yaml](/config.example.yaml) for the example usage. This file must be created beforehand with the minimal configuration, i.e. organization name specified. -In order to get the initial config of the users that are already in place in your Organizaiton, run *Gman* with `-export` flag specified, so the depicted on your side YAML can be populated. +In order to get the initial config of the users that are already in place in your Organizaiton, run *GMan* with `-export` flag specified, so the depicted on your side YAML can be populated. There are two ways to specify the path to the general configuration YAML file: @@ -100,11 +100,11 @@ The configuration can be splitted as well in different files: - groups config file, specified by flag `-groups-config ` - organizational units config file, specified by flag `-orgunits-config ` -Splitting the configuration allows as well to use *Gman* to manage only users, groups or organizational units, depending on the need. +Splitting the configuration allows as well to use *GMan* to manage only users, groups or organizational units, depending on the need. ## Usage -After the completion of the steps above, *Gman* can perform for you: +After the completion of the steps above, *GMan* can perform for you: 1. [exporting](#exporting) existing users in the domain; 2. [validating](#validating) the config file; @@ -113,7 +113,7 @@ After the completion of the steps above, *Gman* can perform for you: ### Exporting -To get started, *Gman* can export your existing GSuite users into a configuration file. +To get started, *GMan* can export your existing GSuite users into a configuration file. For this to work, prepare a fresh configuration file and put your organisation name in it. You can skip everything else: @@ -121,7 +121,7 @@ You can skip everything else: organization: myorganization ``` -Now run *Gman* with the `-export` flag: +Now run *GMan* with the `-export` flag: ```bash $ gman -config myconfig.yaml -export @@ -153,7 +153,7 @@ users: secondary_email: gregor@privatedomain.com org_unit_path: / groups: - - name: Team Gman + - name: Team GMan email: teamgman@myorganization.com members: - email: josef@myorganization.com @@ -178,7 +178,7 @@ It's possible to validate a configuration file for: - valid group members roles, - valid group members emails. -In order to validate the file, run *Gman* with the `-validate` flag: +In order to validate the file, run *GMan* with the `-validate` flag: ```bash $ gman -config myconfig.yaml -validate @@ -186,7 +186,7 @@ $ gman -config myconfig.yaml -validate ``` If the config is valid, the program exits with code 0, otherwise with a non-zero code. -If this flag is specified, *Gman* performs **only** the config validation. Otherwise, validation takes place before every synchronization. +If this flag is specified, *GMan* performs **only** the config validation. Otherwise, validation takes place before every synchronization. ### Synchronizing @@ -213,7 +213,7 @@ $ gman -config myconfig.yaml ### Confirming synchronization -When running *Gman* with the `-confirm` flag the magic of synchronization happens! +When running *GMan* with the `-confirm` flag the magic of synchronization happens! - The users, groups and org units - that have been depicted to be present in config file, but not in GSuite - are automatically created: @@ -281,7 +281,7 @@ Due to the fact that it is impossible to automate the send out of the login info ### API requests quota -In order to retrieve information about licenses of each user, there are multiple API requests performed. This can result in hitting the maximum limit of allowed calls per 100 seconds. In order to avoid it, `Gman` waits after every Enterprise Licensing API request for 0.5 second. This delay can be changed by starting the application with specified flag `-throttle-requests `, where value designates the waiting time in seconds. +In order to retrieve information about licenses of each user, there are multiple API requests performed. This can result in hitting the maximum limit of allowed calls per 100 seconds. In order to avoid it, *GMan* waits after every Enterprise Licensing API request for 0.5 second. This delay can be changed by starting the application with specified flag `-throttle-requests `, where value designates the waiting time in seconds. ## Changelog diff --git a/go.mod b/go.mod index 40df97c..cf15df5 100644 --- a/go.mod +++ b/go.mod @@ -7,4 +7,5 @@ require ( golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5 google.golang.org/api v0.36.0 gopkg.in/yaml.v3 v3.0.0-20210105161348-2e78108cf5f8 + k8s.io/apimachinery v0.20.1 ) diff --git a/go.sum b/go.sum index 14266c0..bd09685 100644 --- a/go.sum +++ b/go.sum @@ -36,6 +36,10 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= @@ -43,13 +47,32 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM= +github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= +github.com/elazarl/goproxy v0.0.0-20180725130230-947c36da3153/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc= +github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/evanphx/json-patch v4.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= +github.com/go-logr/logr v0.2.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU= +github.com/go-openapi/jsonpointer v0.19.2/go.mod h1:3akKfEdA7DF1sugOqz1dVQHBcuDBPKZGEoHC/NkiQRg= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.19.2/go.mod h1:jMjeRr2HHw6nAVajTXJ4eiUwohSTlpa0o73RUL1owJc= +github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8= +github.com/go-openapi/spec v0.19.3/go.mod h1:FpwSN1ksY1eteniUU7X0N/BgJ7a4WvBFVA8Lj9mJglo= +github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -91,6 +114,8 @@ github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4 h1:L8R9j+yAqZuZjsqh/z+F1NCffTKKLShY6zXTItVIZ8M= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -103,29 +128,58 @@ github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+Tv3SM= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/googleapis/gnostic v0.4.1/go.mod h1:LRhVm6pbyptWbWbuZ38d1eyptfvIytN3ir6b65WBswg= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.11.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/sethvargo/go-password v0.2.0 h1:BTDl4CC/gjf/axHMaDQtw507ogrXLci6XRiLc7i/UHI= github.com/sethvargo/go-password v0.2.0/go.mod h1:Ym4Mr9JXLBycr02MFuVQ/0JHidNetSgbzutTr3zsYXE= +github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -141,6 +195,7 @@ go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -177,6 +232,7 @@ golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -184,9 +240,11 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -205,6 +263,8 @@ golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81R golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201031054903-ff519b6c9102 h1:42cLlJJdEh+ySyeUUbEQ5bsTiq8voBeTuweGVkY6Puw= golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b h1:uwuIcX0g4Yl1NC5XAz37xsr2lTtcqevgzYNVt49waME= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -225,15 +285,18 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -252,6 +315,7 @@ golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201112073958-5cba982894dd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3 h1:kzM6+9dur93BcC2kVlYl34cHU+TYZLanmpSJHVMmL64= golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -266,6 +330,7 @@ golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxb golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= @@ -275,6 +340,7 @@ golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBn golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190614205625-5aca471b1d59/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= @@ -407,8 +473,16 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210105161348-2e78108cf5f8 h1:tH9C0MON9YI3/KuD+u5+tQrQQ8px0MrcJ/avzeALw7o= gopkg.in/yaml.v3 v3.0.0-20210105161348-2e78108cf5f8/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= @@ -418,6 +492,15 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +k8s.io/apimachinery v0.20.1 h1:LAhz8pKbgR8tUwn7boK+b2HZdt7MiTu2mkYtFMUjTRQ= +k8s.io/apimachinery v0.20.1/go.mod h1:WlLqWAHZGg07AeltaI0MV5uk1Omp8xaN0JGLY6gkRpU= +k8s.io/gengo v0.0.0-20200413195148-3a45101e95ac/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= +k8s.io/klog/v2 v2.0.0/go.mod h1:PBfzABfn139FHAV07az/IF9Wp1bkk3vpT2XSJ76fSDE= +k8s.io/klog/v2 v2.4.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y= +k8s.io/kube-openapi v0.0.0-20201113171705-d219536bb9fd/go.mod h1:WOJ3KddDSol4tAGcJo0Tvi+dK12EcqSLqcWsryKMpfM= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= +sigs.k8s.io/structured-merge-diff/v4 v4.0.2/go.mod h1:bJZC9H9iH24zzfZ/41RGcq60oK1F7G282QMXDPYydCw= +sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= +sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= diff --git a/main.go b/main.go index fad84ac..c798ef4 100644 --- a/main.go +++ b/main.go @@ -8,11 +8,13 @@ import ( "os" "time" + directoryv1 "google.golang.org/api/admin/directory/v1" + licensingv1 "google.golang.org/api/licensing/v1" + "github.com/kubermatic-labs/gman/pkg/config" "github.com/kubermatic-labs/gman/pkg/export" "github.com/kubermatic-labs/gman/pkg/glib" "github.com/kubermatic-labs/gman/pkg/sync" - admin "google.golang.org/api/admin/directory/v1" ) // These variables are set by goreleaser during build time. @@ -21,265 +23,244 @@ var ( date = "unknown" ) -func main() { - ctx := context.Background() +type options struct { + usersConfigFile string + groupsConfigFile string + orgUnitsConfigFile string + usersConfig *config.Config + groupsConfig *config.Config + orgUnitsConfig *config.Config + licenseStatus *glib.LicenseStatus + versionAction bool + confirm bool + validateAction bool + exportAction bool + clientSecretFile string + impersonatedUserEmail string + throttleRequests time.Duration +} +func main() { var ( - configFile = "" - usersConfigFile = "" - groupsConfigFile = "" - orgunitsConfigFile = "" - showVersion = false - confirm = false - validate = false - exportMode = false - clientSecretFile = "" - impersonatedUserEmail = "" - throttleRequests = 500 * time.Millisecond - - splitConfig = false - userCfg *config.Config - groupCfg *config.Config - orgunitsCfg *config.Config - err error + opt options + err error ) - flag.StringVar(&configFile, "config", configFile, "path to the config.yaml that manages whole organization; cannot be used together with separated config files for users/groups/organizational units") - flag.StringVar(&usersConfigFile, "users-config", usersConfigFile, "path to the config.yaml that manages only users in GSuite organization") - flag.StringVar(&groupsConfigFile, "groups-config", groupsConfigFile, "path to the config.yaml that manages only groups in GSuite organization") - flag.StringVar(&orgunitsConfigFile, "orgunits-config", orgunitsConfigFile, "path to the config.yaml that manages only organizational units in GSuite organization") - flag.StringVar(&clientSecretFile, "private-key", clientSecretFile, "path to the Service Account secret file (.json) coontaining Keys used for authorization") - flag.StringVar(&impersonatedUserEmail, "impersonated-email", impersonatedUserEmail, "Admin email used to impersonate Service Account") - flag.BoolVar(&showVersion, "version", showVersion, "show the Gman version and exit; does not need config file, API key and impersonated email") - flag.BoolVar(&confirm, "confirm", confirm, "must be set to actually perform any changes") - flag.BoolVar(&validate, "validate", validate, "validate the given configuration and then exit; does not need API key and impersonated email") - flag.BoolVar(&exportMode, "export", exportMode, "export the state and update the config file (-config flag)") - flag.DurationVar(&throttleRequests, "throttle-requests", throttleRequests, "the delay between Enterprise Licensing API requests") - + flag.StringVar(&opt.usersConfigFile, "users-config", "", "path to the config.yaml that contains all users") + flag.StringVar(&opt.groupsConfigFile, "groups-config", "", "path to the config.yaml that contains all groups") + flag.StringVar(&opt.orgUnitsConfigFile, "orgunits-config", "", "path to the config.yaml that contains all organization units") + flag.StringVar(&opt.clientSecretFile, "private-key", "", "path to the Service Account secret file (.json) coontaining Keys used for authorization") + flag.StringVar(&opt.impersonatedUserEmail, "impersonated-email", "", "Admin email used to impersonate Service Account") + flag.BoolVar(&opt.versionAction, "version", false, "show the GMan version and exit") + flag.BoolVar(&opt.validateAction, "validate", false, "validate the given configuration and then exit") + flag.BoolVar(&opt.exportAction, "export", false, "export the state and update the config files (-[user|groups|orgunits]-config flags)") + flag.BoolVar(&opt.confirm, "confirm", false, "must be set to actually perform any changes") + flag.DurationVar(&opt.throttleRequests, "throttle-requests", 500*time.Millisecond, "the delay between Enterprise Licensing API requests") flag.Parse() - if showVersion { - fmt.Printf("Gman %s (built at %s)\n", version, date) + if opt.versionAction { + fmt.Printf("GMan %s (built at %s)\n", version, date) return } - splitConfig = usersConfigFile != "" || groupsConfigFile != "" || orgunitsConfigFile != "" - - if splitConfig == true && configFile != "" { - log.Print("⚠ General configuration file specified (-config); cannot manage resources in separated files as well (-users-config/-groups-config/-orgunits-config).\n\n") - flag.Usage() - os.Exit(1) - } else if splitConfig == false && configFile == "" { - // general config file must be present if no splitted ones are specified - configFile = os.Getenv("GMAN_CONFIG_FILE") - if configFile == "" { - log.Print("⚠ No configuration file(s) specified.\n\n") - flag.Usage() - os.Exit(1) - } - } else if splitConfig == false && configFile != "" { - // open one config file - cfg, err := config.LoadFromFile(configFile) - if err != nil { - log.Fatalf("⚠ Failed to load config %q: %v.", configFile, err) - } - userCfg = cfg - groupCfg = cfg - orgunitsCfg = cfg - usersConfigFile = configFile - groupsConfigFile = configFile - orgunitsConfigFile = configFile - } else { - // open the files - if usersConfigFile != "" { - userCfg, err = config.LoadFromFile(usersConfigFile) - if err != nil { - log.Fatalf("⚠ Failed to load config %q: %v.", usersConfigFile, err) - } - } - if groupsConfigFile != "" { - groupCfg, err = config.LoadFromFile(groupsConfigFile) - if err != nil { - log.Fatalf("⚠ Failed to load config %q: %v.", groupsConfigFile, err) - } - } - if orgunitsConfigFile != "" { - orgunitsCfg, err = config.LoadFromFile(orgunitsConfigFile) - if err != nil { - log.Fatalf("⚠ Failed to load config %q: %v.", orgunitsConfigFile, err) - } - } + // open the files + opt.usersConfig, err = config.LoadFromFile(opt.usersConfigFile) + if err != nil { + log.Fatalf("⚠ Failed to load user config from %q: %v.", opt.usersConfigFile, err) + } + + opt.groupsConfig, err = config.LoadFromFile(opt.groupsConfigFile) + if err != nil { + log.Fatalf("⚠ Failed to load group config from %q: %v.", opt.groupsConfigFile, err) + } + + opt.orgUnitsConfig, err = config.LoadFromFile(opt.orgUnitsConfigFile) + if err != nil { + log.Fatalf("⚠ Failed to load org unit config from %q: %v.", opt.orgUnitsConfigFile, err) } // validate config unless in export mode, where an incomplete configuration is expected - if !exportMode { - valid := true - if usersConfigFile != "" || configFile != "" { - if errs := userCfg.ValidateUsers(); errs != nil { - log.Println("Users configuration is invalid:") - for _, e := range errs { - log.Printf(" ⚠ %v\n", e) - } - valid = false - } - } - if groupsConfigFile != "" || configFile != "" { - if errs := groupCfg.ValidateGroups(); errs != nil { - log.Println("Groups configuration is invalid:") - for _, e := range errs { - log.Printf(" ⚠ %v\n", e) - } - valid = false - } - } - if orgunitsConfigFile != "" || configFile != "" { - if errs := orgunitsCfg.ValidateOrgUnits(); errs != nil { - log.Println("Organizational units configuration is invalid:") - for _, e := range errs { - log.Printf(" ⚠ %v\n", e) - } - valid = false - } - } - if valid { - log.Println("✓ Configuration is valid.") - } else { + if !opt.exportAction { + valid := validateAction(&opt) + if !valid { os.Exit(1) } - // return if in validate mode - if validate { + + log.Println("✓ Configuration is valid.") + if opt.validateAction { return } } - if clientSecretFile == "" { - clientSecretFile = os.Getenv("GMAN_SERVICE_ACCOUNT_KEY") - if clientSecretFile == "" { - log.Print("⚠ No authorization .json file (-private-key) specified.\n\n") - flag.Usage() - os.Exit(1) - } + // create glib services + ctx := context.Background() + readonly := opt.exportAction || !opt.confirm + scopes := getScopes(readonly) + + directorySrv, err := glib.NewDirectoryService(ctx, opt.clientSecretFile, opt.impersonatedUserEmail, opt.throttleRequests, scopes...) + if err != nil { + log.Fatalf("⚠ Failed to create GSuite Directory API client: %v", err) } - if impersonatedUserEmail == "" { - impersonatedUserEmail = os.Getenv("GMAN_IMPERSONATED_EMAIL") - if impersonatedUserEmail == "" { - log.Print("⚠ No impersonated user email (-impersonated-email) specified.\n\n") - flag.Usage() - os.Exit(1) - } + licensingSrv, err := glib.NewLicensingService(ctx, opt.clientSecretFile, opt.impersonatedUserEmail, opt.throttleRequests, config.AllLicenses) + if err != nil { + log.Fatalf("⚠ Failed to create GSuite Licensing API client: %v", err) } - // open GSuite API Admin service - var srv *admin.Service - if exportMode || !confirm { - srv, err = glib.NewDirectoryService(clientSecretFile, impersonatedUserEmail, admin.AdminDirectoryUserReadonlyScope, admin.AdminDirectoryGroupReadonlyScope, admin.AdminDirectoryOrgunitReadonlyScope, admin.AdminDirectoryGroupMemberReadonlyScope, admin.AdminDirectoryResourceCalendarReadonlyScope) - if err != nil { - log.Fatalf("⚠ Failed to create GSuite Directory API client: %v", err) - } + groupsSettingsSrv, err := glib.NewGroupsSettingsService(ctx, opt.clientSecretFile, opt.impersonatedUserEmail, opt.throttleRequests) + if err != nil { + log.Fatalf("⚠ Failed to create GSuite GroupsSettings API client: %v", err) + } + + // begin actual work + log.Printf("Working with organization %q…", opt.groupsConfig.Organization) + + log.Println("► Fetching license status…") + opt.licenseStatus, err = licensingSrv.GetLicenseStatus(ctx) + if err != nil { + log.Fatalf("⚠ Failed to fetch: %v.", err) + } + + if opt.exportAction { + exportAction(ctx, &opt, directorySrv, licensingSrv, groupsSettingsSrv) } else { - srv, err = glib.NewDirectoryService(clientSecretFile, impersonatedUserEmail, admin.AdminDirectoryUserScope, admin.AdminDirectoryGroupScope, admin.AdminDirectoryGroupMemberScope, admin.AdminDirectoryOrgunitScope, admin.AdminDirectoryResourceCalendarScope) - if err != nil { - log.Fatalf("⚠ Failed to create GSuite Directory API client: %v", err) - } + syncAction(ctx, &opt, directorySrv, licensingSrv, groupsSettingsSrv) } +} - // handle export/sync for org units - if orgunitsConfigFile != "" || configFile != "" { - if exportMode { - log.Printf("► Exporting organizational units in organization %s…", groupCfg.Organization) - - err = export.ExportOrgUnits(ctx, srv, orgunitsCfg) - if err != nil { - log.Fatalf("⚠ Failed to export organizational units: %v.", err) - } - if err := config.SaveToFile(orgunitsCfg, orgunitsConfigFile); err != nil { - log.Fatalf("⚠ Failed to update config file: %v.", err) - } - - log.Println("✓ Export successful.") - } else { - log.Printf("► Updating organizational units in organization %s…", userCfg.Organization) - - err = sync.SyncOrgUnits(ctx, srv, orgunitsCfg, confirm) - if err != nil { - log.Fatalf("⚠ Failed to sync state: %v.", err) - } - - if confirm { - log.Println("✓ Organizational units successfully synchronized.") - } else { - log.Println("⚠ Run again with -confirm to apply the changes above.") - } - } +func syncAction( + ctx context.Context, + opt *options, + directorySrv *glib.DirectoryService, + licensingSrv *glib.LicensingService, + groupsSettingsSrv *glib.GroupsSettingsService, +) { + log.Println("► Updating org units…") + if err := sync.SyncOrgUnits(ctx, directorySrv, opt.orgUnitsConfig, opt.confirm); err != nil { + log.Fatalf("⚠ Failed to sync: %v.", err) } - // handle export/sync for users - if usersConfigFile != "" || configFile != "" { - licSrv, err := glib.NewLicensingService(clientSecretFile, impersonatedUserEmail, throttleRequests) - if err != nil { - log.Fatalf("⚠ Failed to create GSuite Licensing API client: %v", err) + log.Println("► Updating users…") + if err := sync.SyncUsers(ctx, directorySrv, licensingSrv, opt.usersConfig, opt.licenseStatus, opt.confirm); err != nil { + log.Fatalf("⚠ Failed to sync: %v.", err) + } + + log.Println("► Updating groups…") + if err := sync.SyncGroups(ctx, directorySrv, groupsSettingsSrv, opt.groupsConfig, opt.confirm); err != nil { + log.Fatalf("⚠ Failed to sync: %v.", err) + } + + if opt.confirm { + log.Println("✓ Organization successfully synchronized.") + } else { + log.Println("⚠ Run again with -confirm to apply the changes above.") + } +} + +func exportAction( + ctx context.Context, + opt *options, + directorySrv *glib.DirectoryService, + licensingSrv *glib.LicensingService, + groupsSettingsSrv *glib.GroupsSettingsService, +) { + log.Println("► Exporting organizational units…") + orgUnits, err := export.ExportOrgUnits(ctx, directorySrv) + if err != nil { + log.Fatalf("⚠ Failed to export: %v.", err) + } + + log.Println("► Exporting users…") + users, err := export.ExportUsers(ctx, directorySrv, licensingSrv, opt.licenseStatus) + if err != nil { + log.Fatalf("⚠ Failed to export: %v.", err) + } + + log.Println("► Exporting groups…") + groups, err := export.ExportGroups(ctx, directorySrv, groupsSettingsSrv) + if err != nil { + log.Fatalf("⚠ Failed to export: %v.", err) + } + + log.Println("► Updating config files…") + + // read&write the files individually, so that if the user specifies the same + // file for all three configurations, the file gets incrementally updated + + if err := saveExport(opt.orgUnitsConfigFile, func(cfg *config.Config) { cfg.OrgUnits = orgUnits }); err != nil { + log.Fatalf("⚠ Failed to update org unit config file: %v.", err) + } + + if err := saveExport(opt.usersConfigFile, func(cfg *config.Config) { cfg.Users = users }); err != nil { + log.Fatalf("⚠ Failed to update user config file: %v.", err) + } + + if err := saveExport(opt.groupsConfigFile, func(cfg *config.Config) { cfg.Groups = groups }); err != nil { + log.Fatalf("⚠ Failed to update group config file: %v.", err) + } + + log.Println("✓ Export successful.") +} + +func saveExport(filename string, patch func(*config.Config)) error { + cfg, err := config.LoadFromFile(filename) + if err != nil { + return err + } + + patch(cfg) + + return config.SaveToFile(cfg, filename) +} + +func validateAction(opt *options) bool { + valid := true + + if errs := opt.orgUnitsConfig.ValidateOrgUnits(); errs != nil { + log.Println("⚠ Org unit configuration is invalid:") + for _, e := range errs { + log.Printf(" - %v", e) } + valid = false + } - if exportMode { - log.Printf("► Exporting users in organization %s…", userCfg.Organization) - - err := export.ExportUsers(ctx, srv, licSrv, userCfg) - if err != nil { - log.Fatalf("⚠ Failed to export users: %v.", err) - } - - if err := config.SaveToFile(userCfg, usersConfigFile); err != nil { - log.Fatalf("⚠ Failed to update config file: %v.", err) - } - log.Println("✓ Export of users successful.") - } else { - log.Printf("► Updating users in organization %s…", userCfg.Organization) - - err = sync.SyncUsers(ctx, srv, licSrv, userCfg, confirm) - if err != nil { - log.Fatalf("⚠ Failed to sync state: %v.", err) - } - - if confirm { - log.Println("✓ Users successfully synchronized.") - } else { - log.Println("⚠ Run again with -confirm to apply the changes above.") - } + if errs := opt.usersConfig.ValidateUsers(); errs != nil { + log.Println("⚠ User configuration is invalid:") + for _, e := range errs { + log.Printf(" - %v", e) } + valid = false } - // handle export/sync for groups - if groupsConfigFile != "" || configFile != "" { - grSrv, err := glib.NewGroupsService(clientSecretFile, impersonatedUserEmail) - if err != nil { - log.Fatalf("⚠ Failed to create GSuite Groupssettings API client: %v", err) + + if errs := opt.groupsConfig.ValidateGroups(); errs != nil { + log.Println("⚠ Group configuration is invalid:") + for _, e := range errs { + log.Printf(" - %v", e) } + valid = false + } - if exportMode { - log.Printf("► Exporting groups in organization %s…", groupCfg.Organization) - - err = export.ExportGroups(ctx, srv, grSrv, groupCfg) - if err != nil { - log.Fatalf("⚠ Failed to export groups: %v.", err) - } - if err := config.SaveToFile(groupCfg, groupsConfigFile); err != nil { - log.Fatalf("⚠ Failed to update config file: %v.", err) - } - - log.Println("✓ Export successful.") - } else { - log.Printf("► Updating groups in organization %s…", userCfg.Organization) - - err = sync.SyncGroups(ctx, srv, grSrv, groupCfg, confirm) - if err != nil { - log.Fatalf("⚠ Failed to sync state: %v.", err) - } - - if confirm { - log.Println("✓ Groups successfully synchronized.") - } else { - log.Println("⚠ Run again with -confirm to apply the changes above.") - } + return valid +} + +func getScopes(readonly bool) []string { + if readonly { + return []string{ + directoryv1.AdminDirectoryUserReadonlyScope, + directoryv1.AdminDirectoryGroupReadonlyScope, + directoryv1.AdminDirectoryOrgunitReadonlyScope, + directoryv1.AdminDirectoryGroupMemberReadonlyScope, + directoryv1.AdminDirectoryResourceCalendarReadonlyScope, + licensingv1.AppsLicensingScope, } } + + return []string{ + directoryv1.AdminDirectoryUserScope, + directoryv1.AdminDirectoryGroupScope, + directoryv1.AdminDirectoryOrgunitScope, + directoryv1.AdminDirectoryGroupMemberScope, + directoryv1.AdminDirectoryResourceCalendarScope, + licensingv1.AppsLicensingScope, + } } diff --git a/pkg/config/config.go b/pkg/config/config.go index 51ce347..d67f268 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -7,10 +7,9 @@ import ( "regexp" "strings" - "github.com/kubermatic-labs/gman/pkg/data" - "github.com/kubermatic-labs/gman/pkg/util" - "gopkg.in/yaml.v3" + + "github.com/kubermatic-labs/gman/pkg/util" ) type Config struct { @@ -181,7 +180,7 @@ func (c *Config) ValidateUsers() []error { if len(user.Licenses) > 0 { for _, license := range user.Licenses { found := false - for _, permLicense := range data.GoogleLicenses { + for _, permLicense := range AllLicenses { if license == permLicense.Name { found = true } diff --git a/pkg/data/data.go b/pkg/config/licenses.go similarity index 97% rename from pkg/data/data.go rename to pkg/config/licenses.go index 12efd00..1a89f6d 100644 --- a/pkg/data/data.go +++ b/pkg/config/licenses.go @@ -1,4 +1,4 @@ -package data +package config type License struct { ProductId string @@ -6,8 +6,8 @@ type License struct { Name string // used in yaml } -// list of available Gsuite Licenses -var GoogleLicenses = []License{ +// list of available GSuite Licenses +var AllLicenses = []License{ { ProductId: "Google-Apps", SkuId: "1010020020", // G Suite Enterprise diff --git a/pkg/export/export.go b/pkg/export/export.go index 29d6525..6678f78 100644 --- a/pkg/export/export.go +++ b/pkg/export/export.go @@ -2,117 +2,93 @@ package export import ( "context" + "fmt" "log" "sort" "github.com/kubermatic-labs/gman/pkg/config" "github.com/kubermatic-labs/gman/pkg/glib" - admin "google.golang.org/api/admin/directory/v1" - groupssettings "google.golang.org/api/groupssettings/v1" ) -func ExportUsers(ctx context.Context, clientService *admin.Service, licensingService *glib.LicensingService, cfg *config.Config) error { - // get the users array - users, err := glib.GetListOfUsers(*clientService) +func ExportOrgUnits(ctx context.Context, directorySrv *glib.DirectoryService) ([]config.OrgUnit, error) { + orgUnits, err := directorySrv.ListOrgUnits(ctx) if err != nil { - return err + return nil, fmt.Errorf("failed to list org units: %v", err) } - cfg.Users = []config.User{} + result := []config.OrgUnit{} + for _, ou := range orgUnits { + log.Printf(" %s", ou.Name) - // save to file - if len(users) == 0 { - log.Println("⚠ No users found.") - } else { - for _, u := range users { - log.Printf(" %s", u.PrimaryEmail) - - // get user licenses - userLicenses, err := glib.GetUserLicenses(licensingService, u.PrimaryEmail) - if err != nil { - return err - } - - usr := glib.CreateConfigUserFromGSuite(u, userLicenses) - cfg.Users = append(cfg.Users, usr) - } - - sort.Slice(cfg.Users, func(i, j int) bool { - return cfg.Users[i].PrimaryEmail < cfg.Users[j].PrimaryEmail + result = append(result, config.OrgUnit{ + Name: ou.Name, + Description: ou.Description, + ParentOrgUnitPath: ou.ParentOrgUnitPath, + BlockInheritance: ou.BlockInheritance, }) } - return nil + sort.Slice(result, func(i, j int) bool { + return result[i].Name < result[j].Name + }) + + return result, nil } -func ExportGroups(ctx context.Context, clientService *admin.Service, groupService *groupssettings.Service, cfg *config.Config) error { - // get the groups array - groups, err := glib.GetListOfGroups(clientService) +func ExportUsers(ctx context.Context, directorySrv *glib.DirectoryService, licensingSrv *glib.LicensingService, licenseStatus *glib.LicenseStatus) ([]config.User, error) { + users, err := directorySrv.ListUsers(ctx) if err != nil { - return err + return nil, fmt.Errorf("failed to list users: %v", err) } - var members []*admin.Member - - cfg.Groups = []config.Group{} - - // save to file - if len(groups) == 0 { - log.Println("⚠ No groups found.") - } else { - for _, g := range groups { - log.Printf(" %s", g.Name) - - members, err = glib.GetListOfMembers(clientService, g) - if err != nil { - return err - } - gSettings, err := glib.GetSettingOfGroup(groupService, g.Email) - if err != nil { - return err - } - thisGroup, err := glib.CreateConfigGroupFromGSuite(g, members, gSettings) - if err != nil { - return err - } - cfg.Groups = append(cfg.Groups, thisGroup) - } - sort.Slice(cfg.Groups, func(i, j int) bool { - return cfg.Groups[i].Name < cfg.Groups[j].Name - }) + result := []config.User{} + for _, user := range users { + log.Printf(" %s", user.PrimaryEmail) + + userLicenses := licenseStatus.GetLicensesForUser(user) + configUser := glib.CreateConfigUserFromGSuite(user, userLicenses) + + result = append(result, configUser) } - return nil + sort.Slice(result, func(i, j int) bool { + return result[i].PrimaryEmail < result[j].PrimaryEmail + }) + + return result, nil } -func ExportOrgUnits(ctx context.Context, clientService *admin.Service, cfg *config.Config) error { - // get the users array - orgUnits, err := glib.GetListOfOrgUnits(clientService) +func ExportGroups(ctx context.Context, directorySrv *glib.DirectoryService, groupsSettingsSrv *glib.GroupsSettingsService) ([]config.Group, error) { + groups, err := directorySrv.ListGroups(ctx) if err != nil { - return err + return nil, fmt.Errorf("failed to list groups: %v", err) } - cfg.OrgUnits = []config.OrgUnit{} - - // save to file - if len(orgUnits) == 0 { - log.Println("⚠ No OrgUnits found.") - } else { - for _, ou := range orgUnits { - log.Printf(" %s", ou.Name) - - cfg.OrgUnits = append(cfg.OrgUnits, config.OrgUnit{ - Name: ou.Name, - Description: ou.Description, - ParentOrgUnitPath: ou.ParentOrgUnitPath, - BlockInheritance: ou.BlockInheritance, - }) + result := []config.Group{} + for _, group := range groups { + log.Printf(" %s", group.Name) + + settings, err := groupsSettingsSrv.GetSettings(ctx, group.Email) + if err != nil { + return nil, fmt.Errorf("failed to get group settings: %v", err) } - sort.Slice(cfg.OrgUnits, func(i, j int) bool { - return cfg.OrgUnits[i].Name < cfg.OrgUnits[j].Name - }) + members, err := directorySrv.ListMembers(ctx, group) + if err != nil { + return nil, fmt.Errorf("failed to list members: %v", err) + } + + configGroup, err := glib.CreateConfigGroupFromGSuite(group, members, settings) + if err != nil { + return nil, fmt.Errorf("failed to create config group: %v", err) + } + + result = append(result, configGroup) } - return nil + sort.Slice(result, func(i, j int) bool { + return result[i].Name < result[j].Name + }) + + return result, nil } diff --git a/pkg/glib/directory.go b/pkg/glib/directory.go new file mode 100644 index 0000000..df801c8 --- /dev/null +++ b/pkg/glib/directory.go @@ -0,0 +1,48 @@ +// Package glib contains methods for interactions with GSuite API +package glib + +import ( + "context" + "fmt" + "io/ioutil" + "time" + + "golang.org/x/oauth2/google" + directoryv1 "google.golang.org/api/admin/directory/v1" + "google.golang.org/api/option" +) + +type DirectoryService struct { + *directoryv1.Service + + delay time.Duration +} + +// NewDirectoryService() creates a client for communicating with Google Directory API, +// returns a service object authorized to perform actions in Gsuite. +func NewDirectoryService(ctx context.Context, clientSecretFile string, impersonatedUserEmail string, delay time.Duration, scopes ...string) (*DirectoryService, error) { + jsonCredentials, err := ioutil.ReadFile(clientSecretFile) + if err != nil { + return nil, fmt.Errorf("unable to read json credentials: %v", err) + } + + config, err := google.JWTConfigFromJSON(jsonCredentials, scopes...) + if err != nil { + return nil, fmt.Errorf("unable to process credentials: %v", err) + } + config.Subject = impersonatedUserEmail + + ts := config.TokenSource(ctx) + + srv, err := directoryv1.NewService(ctx, option.WithTokenSource(ts)) + if err != nil { + return nil, fmt.Errorf("unable to create a new directory service: %v", err) + } + + dirService := &DirectoryService{ + Service: srv, + delay: delay, + } + + return dirService, nil +} diff --git a/pkg/glib/directory_groups.go b/pkg/glib/directory_groups.go new file mode 100644 index 0000000..2bdc1c8 --- /dev/null +++ b/pkg/glib/directory_groups.go @@ -0,0 +1,204 @@ +// Package glib contains methods for interactions with GSuite API +package glib + +import ( + "context" + "fmt" + "strconv" + + directoryv1 "google.golang.org/api/admin/directory/v1" + groupssettingsv1 "google.golang.org/api/groupssettings/v1" + + "github.com/kubermatic-labs/gman/pkg/config" +) + +// ListGroups returns a list of all current groups from the API +func (ds *DirectoryService) ListGroups(ctx context.Context) ([]*directoryv1.Group, error) { + groups := []*directoryv1.Group{} + token := "" + + for { + request := ds.Groups.List().Customer("my_customer").OrderBy("email").Context(ctx).PageToken(token) + + response, err := request.Do() + if err != nil { + return nil, fmt.Errorf("unable to retrieve list of groups in domain: %v", err) + } + + groups = append(groups, response.Groups...) + + token = response.NextPageToken + if token == "" { + break + } + } + + return groups, nil +} + +// CreateGroup creates a new group in GSuite via their API +func (ds *DirectoryService) CreateGroup(ctx context.Context, group *directoryv1.Group) (*directoryv1.Group, error) { + updatedGroup, err := ds.Groups.Insert(group).Context(ctx).Do() + if err != nil { + return nil, fmt.Errorf("unable to create group: %v", err) + } + + return updatedGroup, nil +} + +// DeleteGroup deletes a group in GSuite via their API +func (ds *DirectoryService) DeleteGroup(ctx context.Context, group *directoryv1.Group) error { + if err := ds.Groups.Delete(group.Email).Context(ctx).Do(); err != nil { + return fmt.Errorf("unable to delete group: %v", err) + } + + return nil +} + +// UpdateGroup updates the remote group with config +func (ds *DirectoryService) UpdateGroup(ctx context.Context, group *directoryv1.Group) (*directoryv1.Group, error) { + updatedGroup, err := ds.Groups.Update(group.Email, group).Context(ctx).Do() + if err != nil { + return nil, fmt.Errorf("unable to update a group: %v", err) + } + + return updatedGroup, nil +} + +// CreateGSuiteGroupFromConfig converts a ConfigGroup to (GSuite) directoryv1.Group +func CreateGSuiteGroupFromConfig(group *config.Group) (*directoryv1.Group, *groupssettingsv1.Groups) { + googleGroup := &directoryv1.Group{ + Name: group.Name, + Email: group.Email, + } + if group.Description != "" { + googleGroup.Description = group.Description + } + + groupSettings := &groupssettingsv1.Groups{ + WhoCanContactOwner: group.WhoCanContactOwner, + WhoCanViewMembership: group.WhoCanViewMembership, + WhoCanApproveMembers: group.WhoCanApproveMembers, + WhoCanPostMessage: group.WhoCanPostMessage, + WhoCanJoin: group.WhoCanJoin, + IsArchived: strconv.FormatBool(group.IsArchived), + ArchiveOnly: strconv.FormatBool(group.IsArchived), + AllowExternalMembers: strconv.FormatBool(group.AllowExternalMembers), + } + + return googleGroup, groupSettings +} + +func CreateConfigGroupFromGSuite(googleGroup *directoryv1.Group, members []*directoryv1.Member, gSettings *groupssettingsv1.Groups) (config.Group, error) { + boolAllowExternalMembers, err := strconv.ParseBool(gSettings.AllowExternalMembers) + if err != nil { + return config.Group{}, fmt.Errorf("could not parse 'AllowExternalMembers' value from string to bool: %v", err) + } + + boolIsArchived, err := strconv.ParseBool(gSettings.IsArchived) + if err != nil { + return config.Group{}, fmt.Errorf("could not parse 'IsArchived' value from string to bool: %v", err) + } + + configGroup := config.Group{ + Name: googleGroup.Name, + Email: googleGroup.Email, + Description: googleGroup.Description, + WhoCanContactOwner: gSettings.WhoCanContactOwner, + WhoCanViewMembership: gSettings.WhoCanViewMembership, + WhoCanApproveMembers: gSettings.WhoCanApproveMembers, + WhoCanPostMessage: gSettings.WhoCanPostMessage, + WhoCanJoin: gSettings.WhoCanJoin, + AllowExternalMembers: boolAllowExternalMembers, + IsArchived: boolIsArchived, + Members: []config.Member{}, + } + + for _, m := range members { + configGroup.Members = append(configGroup.Members, config.Member{ + Email: m.Email, + Role: m.Role, + }) + } + + return configGroup, nil +} + +//----------------------------------------// +// Group Member handling // +//----------------------------------------// + +// ListMembers returns a list of all current group members form the API +func (ds *DirectoryService) ListMembers(ctx context.Context, group *directoryv1.Group) ([]*directoryv1.Member, error) { + members := []*directoryv1.Member{} + token := "" + + for { + request := ds.Members.List(group.Email).PageToken(token).Context(ctx) + + response, err := request.Do() + if err != nil { + return nil, fmt.Errorf("unable to retrieve list of members in group %s: %v", group.Name, err) + } + + members = append(members, response.Members...) + + token = response.NextPageToken + if token == "" { + break + } + } + + return members, nil +} + +// AddNewMember adds a new member to a group in GSuite +func (ds *DirectoryService) AddNewMember(ctx context.Context, groupEmail string, member *config.Member) error { + newMember := createGSuiteGroupMemberFromConfig(member) + + if _, err := ds.Members.Insert(groupEmail, newMember).Context(ctx).Do(); err != nil { + return fmt.Errorf("unable to add member to group: %v", err) + } + + return nil +} + +// RemoveMember removes a member from a group in Gsuite +func (ds *DirectoryService) RemoveMember(ctx context.Context, groupEmail string, member *directoryv1.Member) error { + if err := ds.Members.Delete(groupEmail, member.Email).Context(ctx).Do(); err != nil { + return fmt.Errorf("unable to delete member from group: %v", err) + } + + return nil +} + +// MemberExists checks if member exists in group +func (ds *DirectoryService) MemberExists(ctx context.Context, group *directoryv1.Group, member *config.Member) (bool, error) { + exists, err := ds.Members.HasMember(group.Email, member.Email).Context(ctx).Do() + if err != nil { + return false, fmt.Errorf("unable to check if member exists in group: %v", err) + } + + return exists.IsMember, nil +} + +// UpdateMembership changes the role of the member +func (ds *DirectoryService) UpdateMembership(ctx context.Context, groupEmail string, member *config.Member) error { + newMember := createGSuiteGroupMemberFromConfig(member) + + if _, err := ds.Members.Update(groupEmail, member.Email, newMember).Context(ctx).Do(); err != nil { + return fmt.Errorf("unable to update member in group: %v", err) + } + + return nil +} + +// createGSuiteGroupMemberFromConfig converts a ConfigMember to (GSuite) admin.Member +func createGSuiteGroupMemberFromConfig(member *config.Member) *directoryv1.Member { + googleMember := &directoryv1.Member{ + Email: member.Email, + Role: member.Role, + } + + return googleMember +} diff --git a/pkg/glib/directory_orgunits.go b/pkg/glib/directory_orgunits.go new file mode 100644 index 0000000..64a2c6a --- /dev/null +++ b/pkg/glib/directory_orgunits.go @@ -0,0 +1,68 @@ +// Package glib contains methods for interactions with GSuite API +package glib + +import ( + "context" + "fmt" + + directoryv1 "google.golang.org/api/admin/directory/v1" + + "github.com/kubermatic-labs/gman/pkg/config" +) + +// ListOrgUnits returns a list of all current organizational units from the API +func (ds *DirectoryService) ListOrgUnits(ctx context.Context) ([]*directoryv1.OrgUnit, error) { + // OrgUnits do not use pagination and always return all units in a single API call. + request, err := ds.Orgunits.List("my_customer").Type("all").Context(ctx).Do() + if err != nil { + return nil, fmt.Errorf("unable to list OrgUnits in domain: %v", err) + } + + return request.OrganizationUnits, nil +} + +// CreateOrgUnit creates a new org unit +func (ds *DirectoryService) CreateOrgUnit(ctx context.Context, ou *config.OrgUnit) error { + newOU := createGSuiteOUFromConfig(ou) + + if _, err := ds.Orgunits.Insert("my_customer", newOU).Context(ctx).Do(); err != nil { + return fmt.Errorf("unable to create org unit: %v", err) + } + + return nil +} + +// DeleteOrgUnit deletes an org group +func (ds *DirectoryService) DeleteOrgUnit(ctx context.Context, ou *directoryv1.OrgUnit) error { + // deletion can happen with the full orgunit's path *OR* it's unique ID + if err := ds.Orgunits.Delete("my_customer", ou.OrgUnitId).Context(ctx).Do(); err != nil { + return fmt.Errorf("unable to delete org unit: %v", err) + } + + return nil +} + +// UpdateOrgUnit updates the remote org unit with config +func (ds *DirectoryService) UpdateOrgUnit(ctx context.Context, ou *config.OrgUnit) error { + updatedOu := createGSuiteOUFromConfig(ou) + + // to update, we need the org unit's ID or its path; + // we have neither, but since the path is always just "{parent}/{orgunit-name}", + // we can construct it (there is no encoding/escaping in the paths, amazingly) + path := ou.ParentOrgUnitPath + "/" + ou.Name + + if _, err := ds.Orgunits.Update("my_customer", path, updatedOu).Context(ctx).Do(); err != nil { + return fmt.Errorf("unable to update org unit: %v", err) + } + + return nil +} + +// createGSuiteOUFromConfig converts a OrgUnitConfig to (GSuite) admin.OrgUnit +func createGSuiteOUFromConfig(ou *config.OrgUnit) *directoryv1.OrgUnit { + return &directoryv1.OrgUnit{ + Name: ou.Name, + Description: ou.Description, + ParentOrgUnitPath: ou.ParentOrgUnitPath, + } +} diff --git a/pkg/glib/directory_users.go b/pkg/glib/directory_users.go new file mode 100644 index 0000000..cc5b07e --- /dev/null +++ b/pkg/glib/directory_users.go @@ -0,0 +1,364 @@ +// Package glib contains methods for interactions with GSuite API +package glib + +import ( + "context" + "fmt" + "sort" + + password "github.com/sethvargo/go-password/password" + directoryv1 "google.golang.org/api/admin/directory/v1" + + "github.com/kubermatic-labs/gman/pkg/config" +) + +// ListUsers returns a list of all current users from the API +func (ds *DirectoryService) ListUsers(ctx context.Context) ([]*directoryv1.User, error) { + users := []*directoryv1.User{} + token := "" + + for { + request := ds.Users.List().Customer("my_customer").OrderBy("email").PageToken(token).Context(ctx) + + response, err := request.Do() + if err != nil { + return nil, fmt.Errorf("unable to retrieve list of users in domain: %v", err) + } + + users = append(users, response.Users...) + + token = response.NextPageToken + if token == "" { + break + } + } + + return users, nil +} + +// GetUserEmails retrieves primary and secondary (type: work) user email addresses +func GetUserEmails(user *directoryv1.User) (string, string) { + var primEmail string + var secEmail string + + for _, email := range user.Emails.([]interface{}) { + if email.(map[string]interface{})["primary"] == true { + primEmail = fmt.Sprint(email.(map[string]interface{})["address"]) + } + if email.(map[string]interface{})["type"] == "work" { + secEmail = fmt.Sprint(email.(map[string]interface{})["address"]) + } + } + + return primEmail, secEmail +} + +func (ds *DirectoryService) CreateUser(ctx context.Context, user *config.User) (*directoryv1.User, error) { + // generate a rand password + pass, err := password.Generate(20, 5, 5, false, false) + if err != nil { + return nil, fmt.Errorf("unable to generate password: %v", err) + } + + newUser := createGSuiteUserFromConfig(user) + newUser.Password = pass + newUser.ChangePasswordAtNextLogin = true + + createdUser, err := ds.Users.Insert(newUser).Context(ctx).Do() + if err != nil { + return nil, fmt.Errorf("unable to create user: %v", err) + } + + // err = HandleUserAliases(ds, newUser, user.Aliases) + // if err != nil { + // return err + // } + + return createdUser, nil +} + +func (ds *DirectoryService) DeleteUser(ctx context.Context, user *directoryv1.User) error { + err := ds.Users.Delete(user.PrimaryEmail).Context(ctx).Do() + if err != nil { + return fmt.Errorf("unable to delete user: %v", err) + } + + return nil +} + +// UpdateUser updates the remote user with config +func (ds *DirectoryService) UpdateUser(ctx context.Context, user *config.User) (*directoryv1.User, error) { + apiUser := createGSuiteUserFromConfig(user) + + updatedUser, err := ds.Users.Update(user.PrimaryEmail, apiUser).Context(ctx).Do() + if err != nil { + return nil, fmt.Errorf("unable to update user: %v", err) + } + + // err = HandleUserAliases(srv, updatedUser, user.Aliases) + // if err != nil { + // return err + // } + + return updatedUser, nil +} + +// createGSuiteUserFromConfig converts a ConfigUser to (GSuite) directoryv1.User +func createGSuiteUserFromConfig(user *config.User) *directoryv1.User { + googleUser := &directoryv1.User{ + Name: &directoryv1.UserName{ + GivenName: user.FirstName, + FamilyName: user.LastName, + }, + PrimaryEmail: user.PrimaryEmail, + OrgUnitPath: user.OrgUnitPath, + } + + if len(user.Phones) > 0 { + phNums := []directoryv1.UserPhone{} + for _, phone := range user.Phones { + phNum := directoryv1.UserPhone{ + Value: phone, + Type: "home", + } + phNums = append(phNums, phNum) + } + googleUser.Phones = phNums + } + + if user.Address != "" { + addr := []directoryv1.UserAddress{ + { + Formatted: user.Address, + Type: "home", + }, + } + googleUser.Addresses = addr + } + + if user.RecoveryEmail != "" { + googleUser.RecoveryEmail = user.RecoveryEmail + } + + if user.RecoveryPhone != "" { + googleUser.RecoveryPhone = user.RecoveryPhone + } + + if user.SecondaryEmail != "" { + workEm := []directoryv1.UserEmail{ + { + Address: user.SecondaryEmail, + Type: "work", + }, + } + googleUser.Emails = workEm + } + + if user.Employee != (config.Employee{}) { + uOrg := []directoryv1.UserOrganization{ + { + Department: user.Employee.Department, + Title: user.Employee.JobTitle, + CostCenter: user.Employee.CostCenter, + Description: user.Employee.Type, + }, + } + + googleUser.Organizations = uOrg + + if user.Employee.ManagerEmail != "" { + rel := []directoryv1.UserRelation{ + { + Value: user.Employee.ManagerEmail, + Type: "manager", + }, + } + googleUser.Relations = rel + } + + if user.Employee.EmployeeID != "" { + ids := []directoryv1.UserExternalId{ + { + Value: user.Employee.EmployeeID, + Type: "organization", + }, + } + googleUser.ExternalIds = ids + } + } + + if user.Location != (config.Location{}) { + loc := []directoryv1.UserLocation{ + { + Area: "desk", + BuildingId: user.Location.Building, + FloorName: user.Location.Floor, + FloorSection: user.Location.FloorSection, + Type: "desk", + }, + } + googleUser.Locations = loc + } + + return googleUser +} + +// CreateConfigUserFromGSuite converts a (GSuite) admin.User to ConfigUser +func CreateConfigUserFromGSuite(googleUser *directoryv1.User, userLicenses []config.License) config.User { + // get emails + primaryEmail, secondaryEmail := GetUserEmails(googleUser) + + configUser := config.User{ + FirstName: googleUser.Name.GivenName, + LastName: googleUser.Name.FamilyName, + PrimaryEmail: primaryEmail, + SecondaryEmail: secondaryEmail, + OrgUnitPath: googleUser.OrgUnitPath, + RecoveryPhone: googleUser.RecoveryPhone, + RecoveryEmail: googleUser.RecoveryEmail, + } + + if len(googleUser.Aliases) > 0 { + for _, alias := range googleUser.Aliases { + configUser.Aliases = append(configUser.Aliases, string(alias)) + } + } + + if googleUser.Phones != nil { + for _, phone := range googleUser.Phones.([]interface{}) { + if phoneMap, ok := phone.(map[string]interface{}); ok { + if phoneVal, exists := phoneMap["value"]; exists { + configUser.Phones = append(configUser.Phones, fmt.Sprint(phoneVal)) + } + } + } + } + + if googleUser.ExternalIds != nil { + for _, id := range googleUser.ExternalIds.([]interface{}) { + if idMap, ok := id.(map[string]interface{}); ok { + if idType := idMap["type"]; idType == "organization" { + if orgId, exists := idMap["value"]; exists { + configUser.Employee.EmployeeID = fmt.Sprint(orgId) + } + } + } + } + } + + if googleUser.Organizations != nil { + for _, org := range googleUser.Organizations.([]interface{}) { + if orgMap, ok := org.(map[string]interface{}); ok { + if department, exists := orgMap["department"]; exists { + configUser.Employee.JobTitle = fmt.Sprint(department) + } + if title, exists := orgMap["title"]; exists { + configUser.Employee.JobTitle = fmt.Sprint(title) + } + if description, exists := orgMap["description"]; exists { + configUser.Employee.Type = fmt.Sprint(description) + } + if costCenter, exists := orgMap["costCenter"]; exists { + configUser.Employee.CostCenter = fmt.Sprint(costCenter) + } + } + } + } + + if googleUser.Relations != nil { + for _, rel := range googleUser.Relations.([]interface{}) { + if relMap, ok := rel.(map[string]interface{}); ok { + if relType := relMap["type"]; relType == "manager" { + if managerEmail, exists := relMap["value"]; exists { + configUser.Employee.ManagerEmail = fmt.Sprint(managerEmail) + } + } + } + } + } + + if googleUser.Locations != nil { + for _, loc := range googleUser.Locations.([]interface{}) { + if locMap, ok := loc.(map[string]interface{}); ok { + if buildingId, exists := locMap["buildingId"]; exists { + configUser.Location.Building = fmt.Sprint(buildingId) + } + if floorName, exists := locMap["floorName"]; exists { + configUser.Location.Floor = fmt.Sprint(floorName) + } + if floorSection, exists := locMap["floorSection"]; exists { + configUser.Location.FloorSection = fmt.Sprint(floorSection) + } + } + } + } + + if googleUser.Addresses != nil { + for _, addr := range googleUser.Addresses.([]interface{}) { + + if addrMap, ok := addr.(map[string]interface{}); ok { + if addrType := addrMap["type"]; addrType == "home" { + if address, exists := addrMap["formatted"]; exists { + configUser.Address = fmt.Sprint(address) + } + } + } + } + } + + if len(userLicenses) > 0 { + for _, userLicense := range userLicenses { + configUser.Licenses = append(configUser.Licenses, userLicense.Name) + } + } + + return configUser +} + +type aliases struct { + Aliases []struct { + Alias string `json:"alias"` + PrimaryEmail string `json:"primaryEmail"` + } `json:"aliases"` +} + +func (ds *DirectoryService) GetUserAliases(ctx context.Context, user *config.User) ([]string, error) { + data, err := ds.Users.Aliases.List(user.PrimaryEmail).Context(ctx).Do() + if err != nil { + return nil, fmt.Errorf("unable to list user aliases: %v", err) + } + + aliases := aliases{} + if err := convertToStruct(data, &aliases); err != nil { + return nil, fmt.Errorf("failed to parse user aliases: %v", err) + } + + result := []string{} + for _, alias := range aliases.Aliases { + result = append(result, alias.Alias) + } + + sort.Strings(result) + + return result, nil +} + +func (ds *DirectoryService) CreateUserAlias(ctx context.Context, user *config.User, alias string) error { + newAlias := &directoryv1.Alias{ + Alias: alias, + } + + if _, err := ds.Users.Aliases.Insert(user.PrimaryEmail, newAlias).Context(ctx).Do(); err != nil { + return fmt.Errorf("unable to create user alias: %v", err) + } + + return nil +} + +func (ds *DirectoryService) DeleteUserAlias(ctx context.Context, user *config.User, alias string) error { + if err := ds.Users.Aliases.Delete(user.PrimaryEmail, alias).Context(ctx).Do(); err != nil { + return fmt.Errorf("unable to delete user alias: %v", err) + } + + return nil +} diff --git a/pkg/glib/glib.go b/pkg/glib/glib.go deleted file mode 100644 index c12c38c..0000000 --- a/pkg/glib/glib.go +++ /dev/null @@ -1,843 +0,0 @@ -// Package glib contains methods for interactions with GSuite API -package glib - -import ( - "context" - "fmt" - "io/ioutil" - "strconv" - "time" - - "github.com/kubermatic-labs/gman/pkg/config" - "github.com/kubermatic-labs/gman/pkg/data" - password "github.com/sethvargo/go-password/password" - "golang.org/x/oauth2/google" - admin "google.golang.org/api/admin/directory/v1" - "google.golang.org/api/googleapi" - groupssettings "google.golang.org/api/groupssettings/v1" - "google.golang.org/api/licensing/v1" - "google.golang.org/api/option" -) - -// embedded structure for holding Google API Licensing Service and the delay between its requests -type LicensingService struct { - *licensing.Service - ThrottleRequests time.Duration -} - -// NewDirectoryService() creates a client for communicating with Google Directory API, -// returns a service object authorized to perform actions in Gsuite. -func NewDirectoryService(clientSecretFile string, impersonatedUserEmail string, scopes ...string) (*admin.Service, error) { - ctx := context.Background() - - jsonCredentials, err := ioutil.ReadFile(clientSecretFile) - if err != nil { - return nil, fmt.Errorf("unable to read json credentials (clientSecretFile): %v", err) - } - - config, err := google.JWTConfigFromJSON(jsonCredentials, scopes...) - if err != nil { - return nil, fmt.Errorf("unable to process credentials: %v", err) - } - config.Subject = impersonatedUserEmail - - ts := config.TokenSource(ctx) - - srv, err := admin.NewService(ctx, option.WithTokenSource(ts)) - if err != nil { - return nil, fmt.Errorf("unable to create a new Admin Service: %v", err) - } - return srv, nil -} - -// NewGroupsService() creates a client for communicating with Google Groupssettings API, -// returns a service object authorized to perform actions in Gsuite. -func NewGroupsService(clientSecretFile string, impersonatedUserEmail string) (*groupssettings.Service, error) { - ctx := context.Background() - - jsonCredentials, err := ioutil.ReadFile(clientSecretFile) - if err != nil { - return nil, fmt.Errorf("unable to read json credentials (clientSecretFile): %v", err) - } - - config, err := google.JWTConfigFromJSON(jsonCredentials, groupssettings.AppsGroupsSettingsScope) - if err != nil { - return nil, fmt.Errorf("unable to process credentials: %v", err) - } - config.Subject = impersonatedUserEmail - - ts := config.TokenSource(ctx) - - srv, err := groupssettings.NewService(ctx, option.WithTokenSource(ts)) - if err != nil { - return nil, fmt.Errorf("unable to create a new Groupssettings Service: %v", err) - } - return srv, nil -} - -// NewLicensingService() creates a client for communicating with Google Licensing API, -// returns a service object authorized to perform actions in Gsuite. -func NewLicensingService(clientSecretFile string, impersonatedUserEmail string, throttleRequests time.Duration) (*LicensingService, error) { - ctx := context.Background() - - jsonCredentials, err := ioutil.ReadFile(clientSecretFile) - if err != nil { - return nil, fmt.Errorf("unable to read json credentials (clientSecretFile): %v", err) - } - - config, err := google.JWTConfigFromJSON(jsonCredentials, licensing.AppsLicensingScope) - if err != nil { - return nil, fmt.Errorf("unable to process credentials: %v", err) - } - config.Subject = impersonatedUserEmail - - ts := config.TokenSource(ctx) - - srv, err := licensing.NewService(ctx, option.WithTokenSource(ts)) - if err != nil { - return nil, fmt.Errorf("unable to create a new Licensing Service: %v", err) - } - - newLicSrv := &LicensingService{ - Service: srv, - ThrottleRequests: throttleRequests, - } - - return newLicSrv, nil -} - -//----------------------------------------// -// User handling // -//----------------------------------------// - -// GetListOfUsers returns a list of all current users form the API -func GetListOfUsers(srv admin.Service) ([]*admin.User, error) { - users := []*admin.User{} - token := "" - - for { - request := srv.Users.List().Customer("my_customer").OrderBy("email").PageToken(token) - - response, err := request.Do() - if err != nil { - return nil, fmt.Errorf("unable to retrieve list of users in domain: %v", err) - } - - users = append(users, response.Users...) - - token = response.NextPageToken - if token == "" { - break - } - } - - return users, nil -} - -// GetUserEmails retrieves primary and secondary (type: work) user email addresses -func GetUserEmails(user *admin.User) (string, string) { - var primEmail string - var secEmail string - for _, email := range user.Emails.([]interface{}) { - if email.(map[string]interface{})["primary"] == true { - primEmail = fmt.Sprint(email.(map[string]interface{})["address"]) - } - if email.(map[string]interface{})["type"] == "work" { - secEmail = fmt.Sprint(email.(map[string]interface{})["address"]) - } - } - return primEmail, secEmail -} - -// CreateUser creates a new user in GSuite via their API -func CreateUser(srv admin.Service, licensingSrv *LicensingService, user *config.User) error { - // generate a rand password - pass, err := password.Generate(20, 5, 5, false, false) - if err != nil { - return fmt.Errorf("unable to generate password: %v", err) - } - newUser := createGSuiteUserFromConfig(srv, user) - newUser.Password = pass - newUser.ChangePasswordAtNextLogin = true - - _, err = srv.Users.Insert(newUser).Do() - if err != nil { - return fmt.Errorf("unable to insert a user: %v", err) - } - - err = HandleUserAliases(srv, newUser, user.Aliases) - if err != nil { - return err - } - - err = HandleUserLicenses(licensingSrv, newUser, user.Licenses) - if err != nil { - return err - } - - return nil -} - -// DeleteUser deletes a user in GSuite via their API -func DeleteUser(srv admin.Service, user *admin.User) error { - err := srv.Users.Delete(user.PrimaryEmail).Do() - if err != nil { - return fmt.Errorf("unable to delete a user %s: %v", user.PrimaryEmail, err) - } - return nil -} - -// UpdateUser updates the remote user with config -func UpdateUser(srv admin.Service, licensingSrv *LicensingService, user *config.User) error { - updatedUser := createGSuiteUserFromConfig(srv, user) - _, err := srv.Users.Update(user.PrimaryEmail, updatedUser).Do() - if err != nil { - return fmt.Errorf("unable to update a user %s: %v", user.PrimaryEmail, err) - } - - err = HandleUserAliases(srv, updatedUser, user.Aliases) - if err != nil { - return err - } - - err = HandleUserLicenses(licensingSrv, updatedUser, user.Licenses) - if err != nil { - return err - } - - return nil -} - -// HandleUserAliases provides logic for creating/deleting/updating aiases -func HandleUserAliases(srv admin.Service, googleUser *admin.User, configAliases []string) error { - request, err := srv.Users.Aliases.List(googleUser.PrimaryEmail).Do() - if err != nil { - return fmt.Errorf("unable to list user aliases in GSuite: %v", err) - } - - if len(configAliases) == 0 { - for _, alias := range request.Aliases { - err = srv.Users.Aliases.Delete(googleUser.PrimaryEmail, fmt.Sprint(alias.(map[string]interface{})["alias"])).Do() - if err != nil { - return fmt.Errorf("unable to delete user alias: %v", err) - } - } - } else { - // check aliases to delete - for _, alias := range request.Aliases { - found := false - for _, configAlias := range configAliases { - if alias.(map[string]interface{})["alias"] == configAlias { - found = true - break - } - } - if !found { - // delete - err = srv.Users.Aliases.Delete(googleUser.PrimaryEmail, fmt.Sprint(alias.(map[string]interface{})["alias"])).Do() - if err != nil { - return fmt.Errorf("unable to delete user alias: %v", err) - } - } - } - } - - // check aliases to add - for _, configAlias := range configAliases { - found := false - for _, alias := range request.Aliases { - if alias.(map[string]interface{})["alias"] == configAlias { - found = true - break - } - } - if !found { - // add - newAlias := &admin.Alias{ - Alias: configAlias, - } - _, err = srv.Users.Aliases.Insert(googleUser.PrimaryEmail, newAlias).Do() - if err != nil { - return fmt.Errorf("unable to add user alias: %v", err) - } - } - } - - return nil -} - -// createGSuiteUserFromConfig converts a ConfigUser to (GSuite) admin.User -func createGSuiteUserFromConfig(srv admin.Service, user *config.User) *admin.User { - googleUser := &admin.User{ - Name: &admin.UserName{ - GivenName: user.FirstName, - FamilyName: user.LastName, - }, - PrimaryEmail: user.PrimaryEmail, - OrgUnitPath: user.OrgUnitPath, - } - - if len(user.Phones) > 0 { - phNums := []admin.UserPhone{} - for _, phone := range user.Phones { - phNum := admin.UserPhone{ - Value: phone, - Type: "home", - } - phNums = append(phNums, phNum) - } - googleUser.Phones = phNums - } - - if user.Address != "" { - addr := []admin.UserAddress{ - { - Formatted: user.Address, - Type: "home", - }, - } - googleUser.Addresses = addr - } - - if user.RecoveryEmail != "" { - googleUser.RecoveryEmail = user.RecoveryEmail - } - - if user.RecoveryPhone != "" { - googleUser.RecoveryPhone = user.RecoveryPhone - } - - if user.SecondaryEmail != "" { - workEm := []admin.UserEmail{ - { - Address: user.SecondaryEmail, - Type: "work", - }, - } - googleUser.Emails = workEm - } - - if user.Employee != (config.Employee{}) { - uOrg := []admin.UserOrganization{ - { - Department: user.Employee.Department, - Title: user.Employee.JobTitle, - CostCenter: user.Employee.CostCenter, - Description: user.Employee.Type, - }, - } - - googleUser.Organizations = uOrg - - if user.Employee.ManagerEmail != "" { - rel := []admin.UserRelation{ - { - Value: user.Employee.ManagerEmail, - Type: "manager", - }, - } - googleUser.Relations = rel - } - - if user.Employee.EmployeeID != "" { - ids := []admin.UserExternalId{ - { - Value: user.Employee.EmployeeID, - Type: "organization", - }, - } - googleUser.ExternalIds = ids - } - } - - if user.Location != (config.Location{}) { - loc := []admin.UserLocation{ - { - Area: "desk", - BuildingId: user.Location.Building, - FloorName: user.Location.Floor, - FloorSection: user.Location.FloorSection, - Type: "desk", - }, - } - googleUser.Locations = loc - } - - return googleUser -} - -// createConfigUserFromGSuite converts a (GSuite) admin.User to ConfigUser -func CreateConfigUserFromGSuite(googleUser *admin.User, userLicenses []data.License) config.User { - // get emails - primaryEmail, secondaryEmail := GetUserEmails(googleUser) - - configUser := config.User{ - FirstName: googleUser.Name.GivenName, - LastName: googleUser.Name.FamilyName, - PrimaryEmail: primaryEmail, - SecondaryEmail: secondaryEmail, - OrgUnitPath: googleUser.OrgUnitPath, - RecoveryPhone: googleUser.RecoveryPhone, - RecoveryEmail: googleUser.RecoveryEmail, - } - - if len(googleUser.Aliases) > 0 { - for _, alias := range googleUser.Aliases { - configUser.Aliases = append(configUser.Aliases, string(alias)) - } - } - - if googleUser.Phones != nil { - for _, phone := range googleUser.Phones.([]interface{}) { - if phoneMap, ok := phone.(map[string]interface{}); ok { - if phoneVal, exists := phoneMap["value"]; exists { - configUser.Phones = append(configUser.Phones, fmt.Sprint(phoneVal)) - } - } - } - } - - if googleUser.ExternalIds != nil { - for _, id := range googleUser.ExternalIds.([]interface{}) { - if idMap, ok := id.(map[string]interface{}); ok { - if idType := idMap["type"]; idType == "organization" { - if orgId, exists := idMap["value"]; exists { - configUser.Employee.EmployeeID = fmt.Sprint(orgId) - } - } - } - } - } - - if googleUser.Organizations != nil { - for _, org := range googleUser.Organizations.([]interface{}) { - if orgMap, ok := org.(map[string]interface{}); ok { - if department, exists := orgMap["department"]; exists { - configUser.Employee.JobTitle = fmt.Sprint(department) - } - if title, exists := orgMap["title"]; exists { - configUser.Employee.JobTitle = fmt.Sprint(title) - } - if description, exists := orgMap["description"]; exists { - configUser.Employee.Type = fmt.Sprint(description) - } - if costCenter, exists := orgMap["costCenter"]; exists { - configUser.Employee.CostCenter = fmt.Sprint(costCenter) - } - } - } - } - - if googleUser.Relations != nil { - for _, rel := range googleUser.Relations.([]interface{}) { - if relMap, ok := rel.(map[string]interface{}); ok { - if relType := relMap["type"]; relType == "manager" { - if managerEmail, exists := relMap["value"]; exists { - configUser.Employee.ManagerEmail = fmt.Sprint(managerEmail) - } - } - } - } - } - - if googleUser.Locations != nil { - for _, loc := range googleUser.Locations.([]interface{}) { - if locMap, ok := loc.(map[string]interface{}); ok { - if buildingId, exists := locMap["buildingId"]; exists { - configUser.Location.Building = fmt.Sprint(buildingId) - } - if floorName, exists := locMap["floorName"]; exists { - configUser.Location.Floor = fmt.Sprint(floorName) - } - if floorSection, exists := locMap["floorSection"]; exists { - configUser.Location.FloorSection = fmt.Sprint(floorSection) - } - } - } - } - - if googleUser.Addresses != nil { - for _, addr := range googleUser.Addresses.([]interface{}) { - - if addrMap, ok := addr.(map[string]interface{}); ok { - if addrType := addrMap["type"]; addrType == "home" { - if address, exists := addrMap["formatted"]; exists { - configUser.Address = fmt.Sprint(address) - } - } - } - } - } - - if len(userLicenses) > 0 { - for _, userLicense := range userLicenses { - configUser.Licenses = append(configUser.Licenses, userLicense.Name) - } - } - - return configUser -} - -//----------------------------------------// -// Group handling // -//----------------------------------------// - -// GetListOfGroups returns a list of all current groups from the API -func GetListOfGroups(srv *admin.Service) ([]*admin.Group, error) { - groups := []*admin.Group{} - token := "" - - for { - request := srv.Groups.List().Customer("my_customer").OrderBy("email").PageToken(token) - - response, err := request.Do() - if err != nil { - return nil, fmt.Errorf("unable to retrieve list of groups in domain: %v", err) - } - - groups = append(groups, response.Groups...) - - token = response.NextPageToken - if token == "" { - break - } - } - - return groups, nil -} - -// GetSettingOfGroup returns a group settings object from the API -func GetSettingOfGroup(srv *groupssettings.Service, groupId string) (*groupssettings.Groups, error) { - request, err := srv.Groups.Get(groupId).Do() - if err != nil { - return nil, fmt.Errorf("unable to retrieve group's (%s) settings: %v", groupId, err) - } - return request, nil -} - -// CreateGroup creates a new group in GSuite via their API -func CreateGroup(srv admin.Service, grSrv groupssettings.Service, group *config.Group) error { - newGroup, groupSettings := CreateGSuiteGroupFromConfig(group) - _, err := srv.Groups.Insert(newGroup).Do() - if err != nil { - return fmt.Errorf("unable to insert a group: %v", err) - } - // add the members - for _, member := range group.Members { - if err := AddNewMember(srv, newGroup.Email, &member); err != nil { - return fmt.Errorf("failed to add %s to group: %v", member.Email, err) - } - } - // add the group's settings - _, err = grSrv.Groups.Update(newGroup.Email, groupSettings).Do() - if err != nil { - return fmt.Errorf("unable to set the group settings: %v", err) - } - return nil -} - -// DeleteGroup deletes a group in GSuite via their API -func DeleteGroup(srv admin.Service, group *admin.Group) error { - err := srv.Groups.Delete(group.Email).Do() - if err != nil { - return fmt.Errorf("unable to delete a group: %v", err) - } - return nil -} - -// UpdateGroup updates the remote group with config -func UpdateGroup(srv admin.Service, grSrv groupssettings.Service, group *config.Group) error { - updatedGroup, groupSettings := CreateGSuiteGroupFromConfig(group) - _, err := srv.Groups.Update(group.Email, updatedGroup).Do() - if err != nil { - return fmt.Errorf("unable to update a group: %v", err) - } - // update group's settings - _, err = grSrv.Groups.Update(group.Email, groupSettings).Do() - if err != nil { - return fmt.Errorf("unable to update group settings: %v", err) - } - - return nil -} - -// createGSuiteGroupFromConfig converts a ConfigGroup to (GSuite) admin.Group -func CreateGSuiteGroupFromConfig(group *config.Group) (*admin.Group, *groupssettings.Groups) { - googleGroup := &admin.Group{ - Name: group.Name, - Email: group.Email, - } - if group.Description != "" { - googleGroup.Description = group.Description - } - - groupSettings := &groupssettings.Groups{ - WhoCanContactOwner: group.WhoCanContactOwner, - WhoCanViewMembership: group.WhoCanViewMembership, - WhoCanApproveMembers: group.WhoCanApproveMembers, - WhoCanPostMessage: group.WhoCanPostMessage, - WhoCanJoin: group.WhoCanJoin, - IsArchived: strconv.FormatBool(group.IsArchived), - ArchiveOnly: strconv.FormatBool(group.IsArchived), - AllowExternalMembers: strconv.FormatBool(group.AllowExternalMembers), - } - - return googleGroup, groupSettings -} - -func CreateConfigGroupFromGSuite(googleGroup *admin.Group, members []*admin.Member, gSettings *groupssettings.Groups) (config.Group, error) { - boolAllowExternalMembers, err := strconv.ParseBool(gSettings.AllowExternalMembers) - if err != nil { - return config.Group{}, fmt.Errorf("could not parse 'AllowExternalMembers' value from string to bool: %v", err) - } - boolIsArchived, err := strconv.ParseBool(gSettings.IsArchived) - if err != nil { - return config.Group{}, fmt.Errorf("could not parse 'IsArchived' value from string to bool: %v", err) - } - - configGroup := config.Group{ - Name: googleGroup.Name, - Email: googleGroup.Email, - Description: googleGroup.Description, - WhoCanContactOwner: gSettings.WhoCanContactOwner, - WhoCanViewMembership: gSettings.WhoCanViewMembership, - WhoCanApproveMembers: gSettings.WhoCanApproveMembers, - WhoCanPostMessage: gSettings.WhoCanPostMessage, - WhoCanJoin: gSettings.WhoCanJoin, - AllowExternalMembers: boolAllowExternalMembers, - IsArchived: boolIsArchived, - Members: []config.Member{}, - } - - for _, m := range members { - configGroup.Members = append(configGroup.Members, config.Member{ - Email: m.Email, - Role: m.Role, - }) - } - - return configGroup, nil -} - -//----------------------------------------// -// Group Member handling // -//----------------------------------------// - -// GetListOfMembers returns a list of all current group members form the API -func GetListOfMembers(srv *admin.Service, group *admin.Group) ([]*admin.Member, error) { - members := []*admin.Member{} - token := "" - - for { - request := srv.Members.List(group.Email).PageToken(token) - - response, err := request.Do() - if err != nil { - return nil, fmt.Errorf("unable to retrieve list of members in group %s: %v", group.Name, err) - } - - members = append(members, response.Members...) - - token = response.NextPageToken - if token == "" { - break - } - } - - return members, nil -} - -// AddNewMember adds a new member to a group in GSuite -func AddNewMember(srv admin.Service, groupEmail string, member *config.Member) error { - newMember := createGSuiteGroupMemberFromConfig(member) - _, err := srv.Members.Insert(groupEmail, newMember).Do() - if err != nil { - return fmt.Errorf("unable to add a member to a group: %v", err) - } - return nil -} - -// RemoveMember removes a member from a group in Gsuite -func RemoveMember(srv admin.Service, groupEmail string, member *admin.Member) error { - err := srv.Members.Delete(groupEmail, member.Email).Do() - if err != nil { - return fmt.Errorf("unable to delete a member from a group: %v", err) - } - return nil -} - -// MemberExists checks if member exists in group -func MemberExists(srv admin.Service, group *admin.Group, member *config.Member) (bool, error) { - exists, err := srv.Members.HasMember(group.Email, member.Email).Do() - if err != nil { - return false, fmt.Errorf("unable to check if member %s exists in a group %s: %v", member.Email, group.Name, err) - } - return exists.IsMember, nil -} - -// UpdateMembership changes the role of the member -// Update(groupKey string, memberKey string, member *Member) -func UpdateMembership(srv admin.Service, groupEmail string, member *config.Member) error { - newMember := createGSuiteGroupMemberFromConfig(member) - _, err := srv.Members.Update(groupEmail, member.Email, newMember).Do() - if err != nil { - return fmt.Errorf("unable to update a member in a group: %v", err) - } - return nil -} - -// createGSuiteGroupMemberFromConfig converts a ConfigMember to (GSuite) admin.Member -func createGSuiteGroupMemberFromConfig(member *config.Member) *admin.Member { - googleMember := &admin.Member{ - Email: member.Email, - Role: member.Role, - } - return googleMember -} - -//----------------------------------------// -// OrgUnit handling // -//----------------------------------------// - -// GetListOfOrgUnits returns a list of all current organizational units form the API -func GetListOfOrgUnits(srv *admin.Service) ([]*admin.OrgUnit, error) { - // OrgUnits do not use pagination and always return all units in a single API call. - request, err := srv.Orgunits.List("my_customer").Type("all").Do() - if err != nil { - return nil, fmt.Errorf("unable to list OrgUnits in domain: %v", err) - } - return request.OrganizationUnits, nil -} - -// CreateOrgUnit creates a new org unit in GSuite via their API -func CreateOrgUnit(srv admin.Service, ou *config.OrgUnit) error { - newOU := createGSuiteOUFromConfig(ou) - _, err := srv.Orgunits.Insert("my_customer", newOU).Do() - if err != nil { - return fmt.Errorf("unable to create org unit: %v", err) - } - return nil -} - -// DeleteOrgUnit deletes a group in GSuite via their API -func DeleteOrgUnit(srv admin.Service, ou *admin.OrgUnit) error { - // deletion can happen with the full orgunit's path *OR* it's unique ID - err := srv.Orgunits.Delete("my_customer", ou.OrgUnitId).Do() - if err != nil { - return fmt.Errorf("unable to delete org unit: %v", err) - } - return nil -} - -// UpdateOrgUnit updates the remote org unit with config -func UpdateOrgUnit(srv admin.Service, ou *config.OrgUnit) error { - updatedOu := createGSuiteOUFromConfig(ou) - - // to update, we need the org unit's ID or its path; - // we have neither, but since the path is always just "{parent}/{orgunit-name}", - // we can construct it (there is no encoding/escaping in the paths, amazingly) - path := ou.ParentOrgUnitPath + "/" + ou.Name - - _, err := srv.Orgunits.Update("my_customer", path, updatedOu).Do() - if err != nil { - return fmt.Errorf("unable to update org unit: %v", err) - } - return nil -} - -// createGSuiteOUFromConfig converts a OrgUnitConfig to (GSuite) admin.OrgUnit -func createGSuiteOUFromConfig(ou *config.OrgUnit) *admin.OrgUnit { - return &admin.OrgUnit{ - Name: ou.Name, - Description: ou.Description, - ParentOrgUnitPath: ou.ParentOrgUnitPath, - } -} - -//----------------------------------------// -// Licenses handling // -//----------------------------------------// - -// GetUserLicense returns a list of licenses of a user -func GetUserLicenses(srv *LicensingService, user string) ([]data.License, error) { - var userLicenses []data.License - for _, license := range data.GoogleLicenses { - _, err := srv.LicenseAssignments.Get(license.ProductId, license.SkuId, user).Do() - // delay API requests - time.Sleep(srv.ThrottleRequests) - if err != nil { - if err.(*googleapi.Error).Code == 404 { - // license doesnt exists - continue - } else { - return nil, fmt.Errorf("unable to retrieve license in domain: %v", err) - } - } else { - userLicenses = append(userLicenses, license) - } - } - return userLicenses, nil -} - -// HandleUserLicenses provides logic for creating/deleting/updating licenses according to config file -func HandleUserLicenses(srv *LicensingService, googleUser *admin.User, configLicenses []string) error { - var userLicenses []data.License - // request the list of user licenses - for _, license := range data.GoogleLicenses { - _, err := srv.LicenseAssignments.Get(license.ProductId, license.SkuId, googleUser.PrimaryEmail).Do() - // delay API requests - time.Sleep(srv.ThrottleRequests) - if err != nil { - // error code 404 - if the user does not have this license, the response has a 'not found' error - if err.(*googleapi.Error).Code == 404 { - // check if config includes given google license - found := false - for _, configLicense := range configLicenses { - if configLicense == license.Name { - found = true - break - } - } - // if config includes it (found in config), add it - if found { - _, err := srv.LicenseAssignments.Insert(license.ProductId, license.SkuId, &licensing.LicenseAssignmentInsert{UserId: googleUser.PrimaryEmail}).Do() - if err != nil { - return fmt.Errorf("unable to insert user license: %v", err) - } - } - } else { - // license exists in gsuite - return fmt.Errorf("unable to retrieve user license: %v", err) - } - } else { - userLicenses = append(userLicenses, license) - } - } - - // check licenses to delete - if len(configLicenses) == 0 { - for _, license := range userLicenses { - if _, err := srv.LicenseAssignments.Delete(license.ProductId, license.SkuId, googleUser.PrimaryEmail).Do(); err != nil { - return fmt.Errorf("unable to delete user license: %v", err) - } - } - } else { - for _, license := range userLicenses { - found := false - for _, configLicense := range configLicenses { - if license.Name == configLicense { - found = true - break - } - } - if !found { - // delete - if _, err := srv.LicenseAssignments.Delete(license.ProductId, license.SkuId, googleUser.PrimaryEmail).Do(); err != nil { - return fmt.Errorf("unable to delete user license: %v", err) - } - } - } - } - - return nil -} diff --git a/pkg/glib/groupssettings.go b/pkg/glib/groupssettings.go new file mode 100644 index 0000000..5cebbb1 --- /dev/null +++ b/pkg/glib/groupssettings.go @@ -0,0 +1,67 @@ +// Package glib contains methods for interactions with GSuite API +package glib + +import ( + "context" + "fmt" + "io/ioutil" + "time" + + "golang.org/x/oauth2/google" + directoryv1 "google.golang.org/api/admin/directory/v1" + groupssettingsv1 "google.golang.org/api/groupssettings/v1" + "google.golang.org/api/option" +) + +type GroupsSettingsService struct { + *groupssettingsv1.Service + + delay time.Duration +} + +// NewGroupsSettingsService() creates a client for communicating with Google Groupssettings API, +// returns a service object authorized to perform actions in Gsuite. +func NewGroupsSettingsService(ctx context.Context, clientSecretFile string, impersonatedUserEmail string, delay time.Duration) (*GroupsSettingsService, error) { + jsonCredentials, err := ioutil.ReadFile(clientSecretFile) + if err != nil { + return nil, fmt.Errorf("unable to read json credentials (clientSecretFile): %v", err) + } + + config, err := google.JWTConfigFromJSON(jsonCredentials, groupssettingsv1.AppsGroupsSettingsScope) + if err != nil { + return nil, fmt.Errorf("unable to process credentials: %v", err) + } + config.Subject = impersonatedUserEmail + + ts := config.TokenSource(ctx) + + srv, err := groupssettingsv1.NewService(ctx, option.WithTokenSource(ts)) + if err != nil { + return nil, fmt.Errorf("unable to create a new Groupssettings Service: %v", err) + } + + groupsService := &GroupsSettingsService{ + Service: srv, + delay: delay, + } + + return groupsService, nil +} + +func (gs *GroupsSettingsService) GetSettings(ctx context.Context, groupId string) (*groupssettingsv1.Groups, error) { + request, err := gs.Groups.Get(groupId).Context(ctx).Do() + if err != nil { + return nil, fmt.Errorf("unable to retrieve group settings: %v", err) + } + + return request, nil +} + +func (gs *GroupsSettingsService) UpdateSettings(ctx context.Context, group *directoryv1.Group, settings *groupssettingsv1.Groups) (*groupssettingsv1.Groups, error) { + updatedSettings, err := gs.Groups.Update(group.Email, settings).Context(ctx).Do() + if err != nil { + return nil, fmt.Errorf("unable to update a group settings: %v", err) + } + + return updatedSettings, nil +} diff --git a/pkg/glib/licensing.go b/pkg/glib/licensing.go new file mode 100644 index 0000000..0aee71d --- /dev/null +++ b/pkg/glib/licensing.go @@ -0,0 +1,175 @@ +// Package glib contains methods for interactions with GSuite API +package glib + +import ( + "context" + "fmt" + "io/ioutil" + "log" + "time" + + "golang.org/x/oauth2/google" + directoryv1 "google.golang.org/api/admin/directory/v1" + "google.golang.org/api/licensing/v1" + "google.golang.org/api/option" + + "github.com/kubermatic-labs/gman/pkg/config" +) + +type LicensingService struct { + *licensing.Service + + licenses []config.License + delay time.Duration +} + +// NewLicensingService() creates a client for communicating with Google Licensing API, +// returns a service object authorized to perform actions in Gsuite. +func NewLicensingService(ctx context.Context, clientSecretFile string, impersonatedUserEmail string, delay time.Duration, licenses []config.License) (*LicensingService, error) { + jsonCredentials, err := ioutil.ReadFile(clientSecretFile) + if err != nil { + return nil, fmt.Errorf("unable to read json credentials: %v", err) + } + + config, err := google.JWTConfigFromJSON(jsonCredentials, licensing.AppsLicensingScope) + if err != nil { + return nil, fmt.Errorf("unable to process credentials: %v", err) + } + config.Subject = impersonatedUserEmail + + ts := config.TokenSource(ctx) + + srv, err := licensing.NewService(ctx, option.WithTokenSource(ts)) + if err != nil { + return nil, fmt.Errorf("unable to create a new licensing service: %v", err) + } + + licenseService := &LicensingService{ + Service: srv, + licenses: licenses, + delay: delay, + } + + return licenseService, nil +} + +func (ls *LicensingService) GetLicenses() ([]config.License, error) { + // in the future, this might be available via the API itself + return ls.licenses, nil +} + +// GetUserLicense returns a list of licenses of a user; +// note that this is extremely slow due to API limitations, consider +// listing all usages per license instead. +func (ls *LicensingService) GetUserLicenses(ctx context.Context, user string) ([]config.License, error) { + var result []config.License + + for _, license := range ls.licenses { + _, err := ls.LicenseAssignments.Get(license.ProductId, license.SkuId, user).Context(ctx).Do() + + log.Println("TODO: delay next request") + + if err != nil { + return nil, fmt.Errorf("unable to retrieve license in domain: %v", err) + } + + result = append(result, license) + } + + return result, nil +} + +// LicenseUsages lists all user IDs assigned licenses for a specific +// product SKU. +func (ls *LicensingService) LicenseUsages(ctx context.Context, license config.License) ([]string, error) { + userIDs := []string{} + token := "" + + for { + request := ls.LicenseAssignments.ListForProduct(license.ProductId, "my_customer").PageToken(token).Context(ctx) + + response, err := request.Do() + if err != nil { + return nil, fmt.Errorf("unable to retrieve list of users: %v", err) + } + + for _, assignment := range response.Items { + userIDs = append(userIDs, assignment.UserId) + } + + token = response.NextPageToken + if token == "" { + break + } + } + + return userIDs, nil +} + +func (ls *LicensingService) AssignLicense(ctx context.Context, user *directoryv1.User, license config.License) error { + op := licensing.LicenseAssignmentInsert{UserId: user.PrimaryEmail} + + if _, err := ls.LicenseAssignments.Insert(license.ProductId, license.SkuId, &op).Context(ctx).Do(); err != nil { + return fmt.Errorf("unable to assign license: %v", err) + } + + return nil +} + +func (ls *LicensingService) UnassignLicense(ctx context.Context, user *directoryv1.User, license config.License) error { + if _, err := ls.LicenseAssignments.Delete(license.ProductId, license.SkuId, user.PrimaryEmail).Context(ctx).Do(); err != nil { + return fmt.Errorf("unable to unassign license: %v", err) + } + + return nil +} + +type LicenseStatus struct { + Assignments map[string][]string + Licenses map[string]config.License +} + +func (ls *LicensingService) GetLicenseStatus(ctx context.Context) (*LicenseStatus, error) { + licenses, err := ls.GetLicenses() + if err != nil { + return nil, fmt.Errorf("failed to determine list of all available licenses: %v", err) + } + + status := &LicenseStatus{ + Assignments: make(map[string][]string), + Licenses: make(map[string]config.License), + } + + for _, license := range licenses { + log.Printf(" %s", license.Name) + + assignments, err := ls.LicenseUsages(ctx, license) + if err != nil { + return nil, fmt.Errorf("failed to fetch license usages: %v", err) + } + + status.Assignments[license.SkuId] = assignments + status.Licenses[license.SkuId] = license + } + + return status, nil +} + +func (ls *LicenseStatus) GetLicensesForUser(user *directoryv1.User) []config.License { + result := []config.License{} + + for _, skuId := range ls.Assignments[user.Id] { + result = append(result, ls.Licenses[skuId]) + } + + return result +} + +func (ls *LicenseStatus) GetLicense(identifier string) *config.License { + license, ok := ls.Licenses[identifier] + if !ok { + return nil + } + + return &license +} diff --git a/pkg/glib/util.go b/pkg/glib/util.go new file mode 100644 index 0000000..e240e6d --- /dev/null +++ b/pkg/glib/util.go @@ -0,0 +1,19 @@ +package glib + +import ( + "encoding/json" + "fmt" +) + +func convertToStruct(data json.Marshaler, dst interface{}) error { + encoded, err := data.MarshalJSON() + if err != nil { + return fmt.Errorf("failed to encode as JSON: %v", err) + } + + if err := json.Unmarshal(encoded, dst); err != nil { + return fmt.Errorf("failed to decode as JSON: %v", err) + } + + return nil +} diff --git a/pkg/sync/compare.go b/pkg/sync/compare.go new file mode 100644 index 0000000..df6eb12 --- /dev/null +++ b/pkg/sync/compare.go @@ -0,0 +1,41 @@ +package sync + +import ( + directoryv1 "google.golang.org/api/admin/directory/v1" + "google.golang.org/api/groupssettings/v1" + + "github.com/kubermatic-labs/gman/pkg/config" +) + +func orgUnitUpToDate(configured config.OrgUnit, live *directoryv1.OrgUnit) bool { + return configured.Description == live.Description && + configured.ParentOrgUnitPath != live.ParentOrgUnitPath && + configured.BlockInheritance != live.BlockInheritance +} + +func userUpToDate(configured config.User, live *directoryv1.User, liveLicenses []config.License, liveAliases []string) bool { + // currentUserConfig := glib.CreateConfigUserFromGSuite(currentUser, currentUserLicenses) + // if !reflect.DeepEqual(currentUserConfig, configured) { + // usersToUpdate = append(usersToUpdate, configured) + // } + + return configured.PrimaryEmail == live.PrimaryEmail +} + +func groupUpToDate(configured config.Group, live *directoryv1.Group, liveMembers []*directoryv1.Member, settings *groupssettings.Groups) bool { + // currentUserConfig := glib.CreateConfigUserFromGSuite(currentUser, currentUserLicenses) + // if !reflect.DeepEqual(currentUserConfig, configured) { + // usersToUpdate = append(usersToUpdate, configured) + // } + + return configured.Email == live.Email +} + +func memberUpToDate(configured config.Member, live *directoryv1.Member) bool { + // currentUserConfig := glib.CreateConfigUserFromGSuite(currentUser, currentUserLicenses) + // if !reflect.DeepEqual(currentUserConfig, configured) { + // usersToUpdate = append(usersToUpdate, configured) + // } + + return configured.Email == live.Email +} diff --git a/pkg/sync/groups.go b/pkg/sync/groups.go new file mode 100644 index 0000000..baa7cdd --- /dev/null +++ b/pkg/sync/groups.go @@ -0,0 +1,172 @@ +package sync + +import ( + "context" + "fmt" + "log" + + directoryv1 "google.golang.org/api/admin/directory/v1" + "k8s.io/apimachinery/pkg/util/sets" + + "github.com/kubermatic-labs/gman/pkg/config" + "github.com/kubermatic-labs/gman/pkg/glib" +) + +func SyncGroups( + ctx context.Context, + directorySrv *glib.DirectoryService, + groupsSettingsSrv *glib.GroupsSettingsService, + cfg *config.Config, + confirm bool, +) error { + log.Println("⇄ Syncing groups…") + + currentGroups, err := directorySrv.ListGroups(ctx) + if err != nil { + return err + } + + currentGroupEmails := sets.NewString() + + for _, current := range currentGroups { + currentGroupEmails.Insert(current.Email) + + found := false + + for _, configured := range cfg.Groups { + if configured.Email == current.Email { + found = true + + currentMembers, err := directorySrv.ListMembers(ctx, current) + if err != nil { + return fmt.Errorf("failed to fetch members: %v", err) + } + + currentSettings, err := groupsSettingsSrv.GetSettings(ctx, current.Email) + if err != nil { + return fmt.Errorf("failed to fetch group settings: %v", err) + } + + if groupUpToDate(configured, current, currentMembers, currentSettings) { + // no update needed + log.Printf(" ✓ %s", configured.Email) + } else { + // update it + log.Printf(" ✎ %s", configured.Email) + + group, settings := glib.CreateGSuiteGroupFromConfig(&configured) + + if confirm { + group, err = directorySrv.UpdateGroup(ctx, group) + if err != nil { + return fmt.Errorf("failed to update group: %v", err) + } + + if _, err := groupsSettingsSrv.UpdateSettings(ctx, group, settings); err != nil { + return fmt.Errorf("failed to update group settings: %v", err) + } + } + + if err := syncGroupMembers(ctx, directorySrv, &configured, currentMembers, confirm); err != nil { + return fmt.Errorf("failed to sync members: %v", err) + } + } + + break + } + } + + if !found { + log.Printf(" ✁ %s", current.Email) + + if confirm { + if err := directorySrv.DeleteGroup(ctx, current); err != nil { + return fmt.Errorf("failed to delete group: %v", err) + } + } + } + } + + for _, configured := range cfg.Groups { + if !currentGroupEmails.Has(configured.Email) { + group, settings := glib.CreateGSuiteGroupFromConfig(&configured) + + log.Printf(" + %s", configured.Email) + + if confirm { + group, err = directorySrv.CreateGroup(ctx, group) + if err != nil { + return fmt.Errorf("failed to create group: %v", err) + } + + if _, err := groupsSettingsSrv.UpdateSettings(ctx, group, settings); err != nil { + return fmt.Errorf("failed to update group settings: %v", err) + } + } + + if err := syncGroupMembers(ctx, directorySrv, &configured, nil, confirm); err != nil { + return fmt.Errorf("failed to sync members: %v", err) + } + } + } + + return nil +} + +func getConfiguredMember(group *config.Group, member *directoryv1.Member) *config.Member { + for _, m := range group.Members { + if m.Email == member.Email { + return &m + } + } + + return nil +} + +func syncGroupMembers( + ctx context.Context, + directorySrv *glib.DirectoryService, + configuredGroup *config.Group, + liveMembers []*directoryv1.Member, + confirm bool, +) error { + liveMemberEmails := sets.NewString() + + for _, liveMember := range liveMembers { + liveMemberEmails.Insert(liveMember.Email) + + expectedMember := getConfiguredMember(configuredGroup, liveMember) + + if expectedMember == nil { + log.Printf(" - %s", liveMember.Email) + + if confirm { + if err := directorySrv.RemoveMember(ctx, configuredGroup.Email, liveMember); err != nil { + return fmt.Errorf("unable to remove member: %v", err) + } + } + } else if !memberUpToDate(*expectedMember, liveMember) { + log.Printf(" ✎ %s", liveMember.Email) + + if confirm { + if err := directorySrv.UpdateMembership(ctx, configuredGroup.Email, expectedMember); err != nil { + return fmt.Errorf("unable to update membership: %v", err) + } + } + } + } + + for _, configuredMember := range configuredGroup.Members { + if !liveMemberEmails.Has(configuredMember.Email) { + log.Printf(" + %s", configuredMember.Email) + + if confirm { + if err := directorySrv.AddNewMember(ctx, configuredGroup.Email, &configuredMember); err != nil { + return fmt.Errorf("unable to add member: %v", err) + } + } + } + } + + return nil +} diff --git a/pkg/sync/licensing.go b/pkg/sync/licensing.go new file mode 100644 index 0000000..b50fb67 --- /dev/null +++ b/pkg/sync/licensing.go @@ -0,0 +1,77 @@ +package sync + +import ( + "context" + "fmt" + "log" + + directoryv1 "google.golang.org/api/admin/directory/v1" + + "github.com/kubermatic-labs/gman/pkg/config" + "github.com/kubermatic-labs/gman/pkg/glib" +) + +func userHasLicense(u *config.User, l config.License) bool { + for _, assigned := range u.Licenses { + if assigned == l.SkuId { + return true + } + } + + return false +} + +func sliceContainsLicense(licenses []config.License, identifier string) bool { + for _, license := range licenses { + if license.SkuId == identifier { + return true + } + } + + return false +} + +// syncUserLicenses provides logic for creating/deleting/updating licenses according to config file +func syncUserLicenses( + ctx context.Context, + licenseSrv *glib.LicensingService, + configuredUser *config.User, + liveUser *directoryv1.User, + licenseStatus *glib.LicenseStatus, + confirm bool, +) error { + expectedLicenses := configuredUser.Licenses + liveLicenses := []config.License{} + + // in dry-run mode, there can be cases where there is no live user yet + if liveUser != nil { + liveLicenses = licenseStatus.GetLicensesForUser(liveUser) + } + + for _, license := range liveLicenses { + if !userHasLicense(configuredUser, license) { + log.Printf(" - license %s", license.Name) + + if confirm { + if err := licenseSrv.UnassignLicense(ctx, liveUser, license); err != nil { + return fmt.Errorf("unable to assign license: %v", err) + } + } + } + } + + for _, identifier := range expectedLicenses { + if !sliceContainsLicense(liveLicenses, identifier) { + license := licenseStatus.GetLicense(identifier) + log.Printf(" + license %s", license.Name) + + if confirm { + if err := licenseSrv.AssignLicense(ctx, liveUser, *license); err != nil { + return fmt.Errorf("unable to assign license: %v", err) + } + } + } + } + + return nil +} diff --git a/pkg/sync/orgunits.go b/pkg/sync/orgunits.go new file mode 100644 index 0000000..e4e7b38 --- /dev/null +++ b/pkg/sync/orgunits.go @@ -0,0 +1,83 @@ +package sync + +import ( + "context" + "fmt" + "log" + + "k8s.io/apimachinery/pkg/util/sets" + + "github.com/kubermatic-labs/gman/pkg/config" + "github.com/kubermatic-labs/gman/pkg/glib" +) + +func SyncOrgUnits( + ctx context.Context, + directorySrv *glib.DirectoryService, + cfg *config.Config, + confirm bool, +) error { + log.Println("⇄ Syncing organizational units…") + + currentUnits, err := directorySrv.ListOrgUnits(ctx) + if err != nil { + return err + } + + currentNames := sets.NewString() + + for _, current := range currentUnits { + currentNames.Insert(current.Name) + + found := false + + for _, configured := range cfg.OrgUnits { + if configured.Name == current.Name { + found = true + + if orgUnitUpToDate(configured, current) { + // no update needed + log.Printf(" ✓ %s", configured.Name) + } else { + // update it + log.Printf(" ✎ %s", configured.Name) + + if confirm { + err := directorySrv.UpdateOrgUnit(ctx, &configured) + if err != nil { + return fmt.Errorf("failed to update org unit: %v", err) + } + } + } + + break + } + } + + if !found { + log.Printf(" ✁ %s", current.Name) + + if confirm { + err := directorySrv.DeleteOrgUnit(ctx, current) + if err != nil { + return fmt.Errorf("failed to delete org unit: %v", err) + } + } + } + } + + for _, configured := range cfg.OrgUnits { + if !currentNames.Has(configured.Name) { + log.Printf(" + %s", configured.Name) + + if confirm { + err := directorySrv.CreateOrgUnit(ctx, &configured) + if err != nil { + return fmt.Errorf("failed to create org unit: %v", err) + } + } + } + } + + return nil +} diff --git a/pkg/sync/sync.go b/pkg/sync/sync.go deleted file mode 100644 index 2aa6942..0000000 --- a/pkg/sync/sync.go +++ /dev/null @@ -1,473 +0,0 @@ -package sync - -import ( - "context" - "fmt" - "log" - "reflect" - - "github.com/kubermatic-labs/gman/pkg/config" - "github.com/kubermatic-labs/gman/pkg/glib" - - admin "google.golang.org/api/admin/directory/v1" - "google.golang.org/api/groupssettings/v1" -) - -func SyncUsers(ctx context.Context, clientService *admin.Service, licensingService *glib.LicensingService, cfg *config.Config, confirm bool) error { - var ( - usersToDelete []*admin.User - usersToCreate []config.User - usersToUpdate []config.User - ) - - log.Println("⇄ Syncing users") - // get the current users array - currentUsers, err := glib.GetListOfUsers(*clientService) - if err != nil { - return err - } - // config defined users - configUsers := cfg.Users - - if len(currentUsers) == 0 { - log.Println("⚠ No users found.") - } else { - // GET USERS TO DELETE & UPDATE - for _, currentUser := range currentUsers { - found := false - for _, configUser := range configUsers { - if configUser.PrimaryEmail == currentUser.PrimaryEmail { - found = true - // user is existing & should exist, so check if needs an update - // get user licenses - currentUserLicenses, err := glib.GetUserLicenses(licensingService, currentUser.PrimaryEmail) - if err != nil { - return err - } - currentUserConfig := glib.CreateConfigUserFromGSuite(currentUser, currentUserLicenses) - if !reflect.DeepEqual(currentUserConfig, configUser) { - usersToUpdate = append(usersToUpdate, configUser) - } - break - } - } - if !found { - usersToDelete = append(usersToDelete, currentUser) - } - } - } - // GET USERS TO CREATE - for _, configUser := range configUsers { - found := false - for _, currentUser := range currentUsers { - if currentUser.PrimaryEmail == configUser.PrimaryEmail { - found = true - break - } - } - if !found { - usersToCreate = append(usersToCreate, configUser) - } - } - - if confirm { - if usersToCreate != nil { - log.Println("Creating...") - for _, user := range usersToCreate { - err := glib.CreateUser(*clientService, licensingService, &user) - if err != nil { - return fmt.Errorf("⚠ Failed to create user %s: %v.", user.PrimaryEmail, err) - } else { - log.Printf(" ✎ user: %s\n", user.PrimaryEmail) - } - } - } - if usersToDelete != nil { - log.Println("Deleting...") - for _, user := range usersToDelete { - err := glib.DeleteUser(*clientService, user) - if err != nil { - return fmt.Errorf("⚠ Failed to delete user %s: %v.", user.PrimaryEmail, err) - } else { - log.Printf(" ✁ user: %s\n", user.PrimaryEmail) - } - } - } - if usersToUpdate != nil { - log.Println("Updating...") - for _, user := range usersToUpdate { - err := glib.UpdateUser(*clientService, licensingService, &user) - if err != nil { - return fmt.Errorf("⚠ Failed to update user %s: %v.", user.PrimaryEmail, err) - } else { - log.Printf(" ✎ user: %s\n", user.PrimaryEmail) - } - } - } - } else { - if usersToDelete == nil { - log.Println("There is no users to delete.") - } else { - log.Println("Found users to delete: ") - for _, u := range usersToDelete { - log.Printf(" ✁ %s\n", u.PrimaryEmail) - } - } - - if usersToCreate == nil { - log.Println("There is no users to create.") - } else { - log.Println("Found users to create: ") - for _, u := range usersToCreate { - log.Printf(" ✎ %s\n", u.PrimaryEmail) - } - } - - if usersToUpdate == nil { - log.Println("There is no users to update.") - } else { - log.Println("Found users to update: ") - for _, u := range usersToUpdate { - log.Printf(" ✎ %s\n", u.PrimaryEmail) - } - } - } - - return nil -} - -// groupUpdate holds a group config to update -// (Members array is not bounded to the Group object in the API) -// helper to avoid global vars -type groupUpdate struct { - groupToUpdate config.Group - membersToAdd []*config.Member - membersToRemove []*admin.Member - membersToUpdate []*config.Member -} - -// SyncGroups -func SyncGroups(ctx context.Context, clientService *admin.Service, groupService *groupssettings.Service, cfg *config.Config, confirm bool) error { - var ( - groupsToDelete []*admin.Group - groupsToCreate []config.Group - groupsToUpdate []groupUpdate - ) - - log.Println("⇄ Syncing groups") - // get the current groups array - currentGroups, err := glib.GetListOfGroups(clientService) - if err != nil { - return err - } - // config defined groups - configGroups := cfg.Groups - - if len(currentGroups) == 0 { - log.Println("⚠ No groups found.") - } else { - // GET GROUPS TO DELETE & UPDATE - for _, currGroup := range currentGroups { - found := false - for _, cfgGroup := range configGroups { - if cfgGroup.Email == currGroup.Email { - found = true - // group is existing & should exist, so check if needs an update - var upGroup groupUpdate - upGroup.membersToAdd, upGroup.membersToRemove, upGroup.membersToUpdate = SyncMembers(ctx, clientService, &cfgGroup, currGroup) - currentMembers, err := glib.GetListOfMembers(clientService, currGroup) - if err != nil { - return err - } - currentSettings, err := glib.GetSettingOfGroup(groupService, currGroup.Email) - if err != nil { - return err - } - currentGroupConfig, err := glib.CreateConfigGroupFromGSuite(currGroup, currentMembers, currentSettings) - if err != nil { - return err - } - if !reflect.DeepEqual(currentGroupConfig, cfgGroup) { - upGroup.groupToUpdate = cfgGroup - groupsToUpdate = append(groupsToUpdate, upGroup) - } - break - } - } - if !found { - groupsToDelete = append(groupsToDelete, currGroup) - } - } - - } - // GET GROUPS TO CREATE - for _, cfgGroup := range configGroups { - found := false - for _, currGroup := range currentGroups { - if currGroup.Email == cfgGroup.Email { - found = true - break - } - } - if !found { - groupsToCreate = append(groupsToCreate, cfgGroup) - } - - } - - if confirm { - if groupsToCreate != nil { - log.Println("Creating...") - for _, gr := range groupsToCreate { - err := glib.CreateGroup(*clientService, *groupService, &gr) - if err != nil { - return fmt.Errorf("⚠ Failed to create a group %s: %v.", gr.Name, err) - } else { - log.Printf(" ✎ group: %s\n", gr.Name) - } - } - } - if groupsToDelete != nil { - log.Println("Deleting...") - for _, gr := range groupsToDelete { - err := glib.DeleteGroup(*clientService, gr) - if err != nil { - return fmt.Errorf("⚠ Failed to delete a group %s: %v.", gr.Name, err) - } else { - log.Printf(" ✁ group: %s\n", gr.Name) - } - } - } - if groupsToUpdate != nil { - log.Println("Updating...") - for _, gr := range groupsToUpdate { - err := glib.UpdateGroup(*clientService, *groupService, &gr.groupToUpdate) - if err != nil { - return fmt.Errorf("⚠ Failed to update a group: %v.", err) - } else { - log.Printf(" ✎ group: %s\n", gr.groupToUpdate.Name) - } - - for _, mem := range gr.membersToAdd { - err := glib.AddNewMember(*clientService, gr.groupToUpdate.Email, mem) - if err != nil { - return fmt.Errorf("⚠ Failed to add a member to a group: %v.", err) - } else { - log.Printf(" ✎ adding member: %s \n", mem.Email) - } - } - for _, mem := range gr.membersToRemove { - err := glib.RemoveMember(*clientService, gr.groupToUpdate.Email, mem) - if err != nil { - return fmt.Errorf("⚠ Failed to add a member to a group: %v.", err) - } else { - log.Printf(" ✁ removing member: %s \n", mem.Email) - } - } - for _, mem := range gr.membersToUpdate { - err := glib.UpdateMembership(*clientService, gr.groupToUpdate.Email, mem) - if err != nil { - return fmt.Errorf("⚠ Failed to update membership in a group: %v.", err) - } else { - log.Printf(" ✎ updating membership: %s \n", mem.Email) - } - } - } - } - } else { - if groupsToDelete == nil { - log.Println("There is no groups to delete.") - } else { - log.Println("Found groups to delete: ") - for _, g := range groupsToDelete { - log.Printf(" ✁ %s \n", g.Name) - } - } - - if groupsToCreate == nil { - log.Println("There is no groups to create.") - } else { - log.Println("Found groups to create: ") - for _, g := range groupsToCreate { - log.Printf(" ✎ %s\n", g.Name) - } - } - - if groupsToUpdate == nil { - log.Println("There is no groups to update.") - } else { - log.Println("Found groups to update: ") - for _, g := range groupsToUpdate { - log.Printf(" ✎ %s \n", g.groupToUpdate.Name) - for _, mem := range g.membersToAdd { - log.Printf(" ✎ member to add: %s \n", mem.Email) - } - for _, mem := range g.membersToRemove { - log.Printf(" ✁ member to remove: %s \n", mem.Email) - } - for _, mem := range g.membersToUpdate { - log.Printf(" ✎ member to update: %s \n", mem.Email) - } - } - } - - } - - return nil -} - -func SyncMembers(ctx context.Context, clientService *admin.Service, cfgGr *config.Group, curGr *admin.Group) ([]*config.Member, []*admin.Member, []*config.Member) { - var memToAdd []*config.Member - var memToUpdate []*config.Member - var memToRemove []*admin.Member - currentMembers, _ := glib.GetListOfMembers(clientService, curGr) - - // check members to add - for _, member := range cfgGr.Members { - foundMem := false - for _, currMember := range currentMembers { - if currMember.Email == member.Email { - foundMem = true - // check for update - if currMember.Role != member.Role { - memToUpdate = append(memToUpdate, &member) - } - break - } - } - if !foundMem { - memToAdd = append(memToAdd, &member) - } - } - - // check members to remove - for _, currMember := range currentMembers { - foundMem := false - for _, member := range cfgGr.Members { - if currMember.Email == member.Email { - foundMem = true - } - } - if !foundMem { - memToRemove = append(memToRemove, currMember) - } - } - - return memToAdd, memToRemove, memToUpdate -} - -func SyncOrgUnits(ctx context.Context, clientService *admin.Service, cfg *config.Config, confirm bool) error { - var ( - ouToDelete []*admin.OrgUnit - ouToCreate []config.OrgUnit - ouToUpdate []config.OrgUnit - ) - - log.Println("⇄ Syncing organizational units") - // get the current users array - currentOus, err := glib.GetListOfOrgUnits(clientService) - if err != nil { - return err - } - // config defined users - configOus := cfg.OrgUnits - - if len(currentOus) == 0 { - log.Println("⚠ No organizational units found.") - } else { - // GET ORG UNITS TO DELETE & UPDATE - for _, currentOu := range currentOus { - found := false - for _, configOu := range configOus { - if configOu.Name == currentOu.Name { - found = true - // OU is existing & should exist, so check if needs an update - if configOu.Description != currentOu.Description || - configOu.ParentOrgUnitPath != currentOu.ParentOrgUnitPath || - configOu.BlockInheritance != currentOu.BlockInheritance { - ouToUpdate = append(ouToUpdate, configOu) - } - break - } - } - if !found { - ouToDelete = append(ouToDelete, currentOu) - } - } - } - // GET ORG UNITS TO CREATE - for _, configOu := range configOus { - found := false - for _, currentOu := range currentOus { - if currentOu.Name == configOu.Name { - found = true - break - } - } - if !found { - ouToCreate = append(ouToCreate, configOu) - } - } - - if confirm { - if ouToCreate != nil { - log.Println("Creating...") - for _, ou := range ouToCreate { - err := glib.CreateOrgUnit(*clientService, &ou) - if err != nil { - return err - } - log.Printf(" ✎ org unit: %s\n", ou.Name) - } - } - if ouToDelete != nil { - log.Println("Deleting...") - for _, ou := range ouToDelete { - err := glib.DeleteOrgUnit(*clientService, ou) - if err != nil { - return err - } - log.Printf(" ✁ org unit: %s\n", ou.Name) - } - } - if ouToUpdate != nil { - log.Println("Updating...") - for _, ou := range ouToUpdate { - err := glib.UpdateOrgUnit(*clientService, &ou) - if err != nil { - return err - } - log.Printf(" ✎ org unit: %s \n", ou.Name) - } - } - } else { - if ouToDelete == nil { - log.Println("There is no org units to delete.") - } else { - log.Println("Found org units to delete: ") - for _, ou := range ouToDelete { - log.Printf(" ✁ %s \n", ou.Name) - } - } - - if ouToCreate == nil { - log.Println("There is no org units to create.") - } else { - log.Println("Found org units to create: ") - for _, ou := range ouToCreate { - log.Printf(" ✎ %s \n", ou.Name) - } - } - - if ouToUpdate == nil { - log.Println("There is no org units to update.") - } else { - log.Println("Found org units to update: ") - for _, ou := range ouToUpdate { - log.Printf(" ✎ %s\n", ou.Name) - } - } - } - return nil - -} diff --git a/pkg/sync/users.go b/pkg/sync/users.go new file mode 100644 index 0000000..1d40958 --- /dev/null +++ b/pkg/sync/users.go @@ -0,0 +1,149 @@ +package sync + +import ( + "context" + "fmt" + "log" + + directoryv1 "google.golang.org/api/admin/directory/v1" + "k8s.io/apimachinery/pkg/util/sets" + + "github.com/kubermatic-labs/gman/pkg/config" + "github.com/kubermatic-labs/gman/pkg/glib" +) + +func SyncUsers( + ctx context.Context, + directorySrv *glib.DirectoryService, + licensingSrv *glib.LicensingService, + cfg *config.Config, + licenseStatus *glib.LicenseStatus, + confirm bool, +) error { + log.Println("⇄ Syncing users…") + + currentUsers, err := directorySrv.ListUsers(ctx) + if err != nil { + return err + } + + currentEmails := sets.NewString() + + for _, current := range currentUsers { + currentEmails.Insert(current.PrimaryEmail) + + found := false + + for _, configured := range cfg.Users { + if configured.PrimaryEmail == current.PrimaryEmail { + found = true + + currentUserLicenses := licenseStatus.GetLicensesForUser(current) + + currentAliases, err := directorySrv.GetUserAliases(ctx, &configured) + if err != nil { + return fmt.Errorf("failed to fetch aliases: %v", err) + } + + if userUpToDate(configured, current, currentUserLicenses, currentAliases) { + // no update needed + log.Printf(" ✓ %s", configured.PrimaryEmail) + } else { + // update it + log.Printf(" ✎ %s", configured.PrimaryEmail) + + updatedUser := current + if confirm { + updatedUser, err = directorySrv.UpdateUser(ctx, &configured) + if err != nil { + return fmt.Errorf("failed to update user: %v", err) + } + } + + if err := syncUserAliases(ctx, directorySrv, &configured, updatedUser, currentAliases, confirm); err != nil { + return fmt.Errorf("failed to sync aliases: %v", err) + } + + if err := syncUserLicenses(ctx, licensingSrv, &configured, updatedUser, licenseStatus, confirm); err != nil { + return fmt.Errorf("failed to sync licenses: %v", err) + } + } + + break + } + } + + if !found { + log.Printf(" ✁ %s", current.PrimaryEmail) + + if confirm { + if err := directorySrv.DeleteUser(ctx, current); err != nil { + return fmt.Errorf("failed to delete user: %v", err) + } + } + } + } + + for _, configured := range cfg.Users { + if !currentEmails.Has(configured.PrimaryEmail) { + log.Printf(" + %s", configured.PrimaryEmail) + + var createdUser *directoryv1.User + + if confirm { + createdUser, err = directorySrv.CreateUser(ctx, &configured) + if err != nil { + return fmt.Errorf("failed to create user: %v", err) + } + } + + if err := syncUserAliases(ctx, directorySrv, &configured, createdUser, nil, confirm); err != nil { + return fmt.Errorf("failed to sync aliases: %v", err) + } + + if err := syncUserLicenses(ctx, licensingSrv, &configured, createdUser, licenseStatus, confirm); err != nil { + return fmt.Errorf("failed to sync licenses: %v", err) + } + } + } + + return nil +} + +func syncUserAliases( + ctx context.Context, + directorySrv *glib.DirectoryService, + configuredUser *config.User, + liveUser *directoryv1.User, + liveAliases []string, + confirm bool, +) error { + configuredAliases := sets.NewString(configuredUser.Aliases...) + liveAliasesSet := sets.NewString(liveAliases...) + + for _, liveAlias := range liveAliases { + if !configuredAliases.Has(liveAlias) { + log.Printf(" - alias %s", liveAlias) + + if confirm { + if err := directorySrv.DeleteUserAlias(ctx, configuredUser, liveAlias); err != nil { + return fmt.Errorf("unable to delete alias: %v", err) + } + } + } + } + + for _, configuredAlias := range configuredAliases.List() { + if !liveAliasesSet.Has(configuredAlias) { + log.Printf(" + alias %s", configuredAlias) + + if confirm { + if err := directorySrv.CreateUserAlias(ctx, configuredUser, configuredAlias); err != nil { + return fmt.Errorf("unable to create alias: %v", err) + } + } + } + } + + return nil +} From e0c57a6d31fc5c57b7504a90e8664f3fc2521c59 Mon Sep 17 00:00:00 2001 From: Christoph Mewes Date: Mon, 11 Jan 2021 21:56:49 +0100 Subject: [PATCH 05/18] update licenses --- main.go | 12 ++--- pkg/config/licenses.go | 118 ++++++++++++++++++++++++++++++----------- pkg/glib/directory.go | 10 ++-- pkg/glib/licensing.go | 19 ++++--- 4 files changed, 111 insertions(+), 48 deletions(-) diff --git a/main.go b/main.go index c798ef4..3a3edd1 100644 --- a/main.go +++ b/main.go @@ -9,7 +9,6 @@ import ( "time" directoryv1 "google.golang.org/api/admin/directory/v1" - licensingv1 "google.golang.org/api/licensing/v1" "github.com/kubermatic-labs/gman/pkg/config" "github.com/kubermatic-labs/gman/pkg/export" @@ -92,17 +91,20 @@ func main() { } } + orgName := opt.groupsConfig.Organization + log.Printf("Working with organization %q…", orgName) + // create glib services ctx := context.Background() readonly := opt.exportAction || !opt.confirm scopes := getScopes(readonly) - directorySrv, err := glib.NewDirectoryService(ctx, opt.clientSecretFile, opt.impersonatedUserEmail, opt.throttleRequests, scopes...) + directorySrv, err := glib.NewDirectoryService(ctx, orgName, opt.clientSecretFile, opt.impersonatedUserEmail, opt.throttleRequests, scopes...) if err != nil { log.Fatalf("⚠ Failed to create GSuite Directory API client: %v", err) } - licensingSrv, err := glib.NewLicensingService(ctx, opt.clientSecretFile, opt.impersonatedUserEmail, opt.throttleRequests, config.AllLicenses) + licensingSrv, err := glib.NewLicensingService(ctx, orgName, opt.clientSecretFile, opt.impersonatedUserEmail, opt.throttleRequests, config.AllLicenses) if err != nil { log.Fatalf("⚠ Failed to create GSuite Licensing API client: %v", err) } @@ -113,8 +115,6 @@ func main() { } // begin actual work - log.Printf("Working with organization %q…", opt.groupsConfig.Organization) - log.Println("► Fetching license status…") opt.licenseStatus, err = licensingSrv.GetLicenseStatus(ctx) if err != nil { @@ -251,7 +251,6 @@ func getScopes(readonly bool) []string { directoryv1.AdminDirectoryOrgunitReadonlyScope, directoryv1.AdminDirectoryGroupMemberReadonlyScope, directoryv1.AdminDirectoryResourceCalendarReadonlyScope, - licensingv1.AppsLicensingScope, } } @@ -261,6 +260,5 @@ func getScopes(readonly bool) []string { directoryv1.AdminDirectoryOrgunitScope, directoryv1.AdminDirectoryGroupMemberScope, directoryv1.AdminDirectoryResourceCalendarScope, - licensingv1.AppsLicensingScope, } } diff --git a/pkg/config/licenses.go b/pkg/config/licenses.go index 1a89f6d..5d06919 100644 --- a/pkg/config/licenses.go +++ b/pkg/config/licenses.go @@ -10,116 +10,174 @@ type License struct { var AllLicenses = []License{ { ProductId: "Google-Apps", - SkuId: "1010020020", // G Suite Enterprise - Name: "GSuiteEnterprise", + SkuId: "1010020027", + Name: "GoogleWorkspaceBusinessStarter", }, + { ProductId: "Google-Apps", - SkuId: "Google-Apps-Unlimited", // G Suite Business - Name: "GSuiteBusiness", + SkuId: "1010020028", + Name: "GoogleWorkspaceBusinessStandard", }, + { ProductId: "Google-Apps", - SkuId: "Google-Apps-For-Business", // G Suite Basic - Name: "GSuiteBasic", + SkuId: "1010020025", + Name: "GoogleWorkspaceBusinessPlus", + }, + + { + ProductId: "Google-Apps", + SkuId: "1010060003", + Name: "GoogleWorkspaceEnterpriseEssentials", + }, + + { + ProductId: "Google-Apps", + SkuId: "1010020026", + Name: "GoogleWorkspaceEnterpriseStandard", + }, + + { + ProductId: "Google-Apps", + SkuId: "1010020020", + Name: "GoogleWorkspaceEnterprisePlus", + }, + + { + ProductId: "Google-Apps", + SkuId: "1010060001", + Name: "GoogleWorkspaceEssentials", + }, + + { + ProductId: "Google-Apps", + SkuId: "Google-Apps-Unlimited", + Name: "GSuiteBusiness", }, + { - ProductId: "101006", - SkuId: "1010060001", // G Suite Essentials - Name: "GSuiteEssentials", + ProductId: "Google-Apps", + SkuId: "Google-Apps-For-Business", + Name: "GSuiteBasic", }, + { ProductId: "Google-Apps", - SkuId: "Google-Apps-Lite", // G Suite Lite + SkuId: "Google-Apps-Lite", Name: "GSuiteLite", }, + { ProductId: "Google-Apps", - SkuId: "Google-Apps-For-Postini", // Google Apps Message Security + SkuId: "Google-Apps-For-Postini", Name: "GoogleAppsMessageSecurity", }, + { - ProductId: "101031", // G Suite Enterprise for Education - SkuId: "1010310002", // G Suite Enterprise for Education - Name: "GSuiteEducation", + ProductId: "101031", + SkuId: "1010310002", + Name: "GSuiteEnterpriseForEducation", }, + { - ProductId: "101031", // G Suite Enterprise for Education - SkuId: "1010310003", // G Suite Enterprise for Education (Student) - Name: "GSuiteEducationStudent", + ProductId: "101031", + SkuId: "1010310003", + Name: "GSuiteEnterpriseForEducationStudent", }, + { ProductId: "Google-Drive-storage", SkuId: "Google-Drive-storage-20GB", - Name: "GoogleDrive20GB", + Name: "GoogleDriveStorage20GB", }, + { ProductId: "Google-Drive-storage", SkuId: "Google-Drive-storage-50GB", - Name: "GoogleDrive50GB", + Name: "GoogleDriveStorage50GB", }, + { ProductId: "Google-Drive-storage", SkuId: "Google-Drive-storage-200GB", - Name: "GoogleDrive200GB", + Name: "GoogleDriveStorage200GB", }, + { ProductId: "Google-Drive-storage", SkuId: "Google-Drive-storage-400GB", - Name: "GoogleDrive400GB", + Name: "GoogleDriveStorage400GB", }, + { ProductId: "Google-Drive-storage", SkuId: "Google-Drive-storage-1TB", - Name: "GoogleDrive1TB", + Name: "GoogleDriveStorage1TB", }, + { ProductId: "Google-Drive-storage", SkuId: "Google-Drive-storage-2TB", - Name: "GoogleDrive2TB", + Name: "GoogleDriveStorage2TB", }, + { ProductId: "Google-Drive-storage", SkuId: "Google-Drive-storage-4TB", - Name: "GoogleDrive4TB", + Name: "GoogleDriveStorage4TB", }, + { ProductId: "Google-Drive-storage", SkuId: "Google-Drive-storage-8TB", - Name: "GoogleDrive8TB", + Name: "GoogleDriveStorage8TB", }, + { ProductId: "Google-Drive-storage", SkuId: "Google-Drive-storage-16TB", - Name: "GoogleDrive16TB", + Name: "GoogleDriveStorage16TB", }, + { ProductId: "Google-Vault", SkuId: "Google-Vault", Name: "GoogleVault", }, + { ProductId: "Google-Vault", SkuId: "Google-Vault-Former-Employee", Name: "GoogleVaultFormerEmployee", }, + + { + ProductId: "101001", + SkuId: "1010010001", + Name: "CloudIdentity", + }, + { - ProductId: "101005", // Cloud Identity Premium + ProductId: "101005", SkuId: "1010050001", Name: "CloudIdentityPremium", }, + { - ProductId: "101033", // Google Voice + ProductId: "101033", SkuId: "1010330003", Name: "GoogleVoiceStarter", }, + { - ProductId: "101033", // Google Voice + ProductId: "101033", SkuId: "1010330004", Name: "GoogleVoiceStandard", }, + { - ProductId: "101033", // Google Voice + ProductId: "101033", SkuId: "1010330002", Name: "GoogleVoicePremier", }, diff --git a/pkg/glib/directory.go b/pkg/glib/directory.go index df801c8..97f6078 100644 --- a/pkg/glib/directory.go +++ b/pkg/glib/directory.go @@ -15,12 +15,13 @@ import ( type DirectoryService struct { *directoryv1.Service - delay time.Duration + organization string + delay time.Duration } // NewDirectoryService() creates a client for communicating with Google Directory API, // returns a service object authorized to perform actions in Gsuite. -func NewDirectoryService(ctx context.Context, clientSecretFile string, impersonatedUserEmail string, delay time.Duration, scopes ...string) (*DirectoryService, error) { +func NewDirectoryService(ctx context.Context, organization string, clientSecretFile string, impersonatedUserEmail string, delay time.Duration, scopes ...string) (*DirectoryService, error) { jsonCredentials, err := ioutil.ReadFile(clientSecretFile) if err != nil { return nil, fmt.Errorf("unable to read json credentials: %v", err) @@ -40,8 +41,9 @@ func NewDirectoryService(ctx context.Context, clientSecretFile string, impersona } dirService := &DirectoryService{ - Service: srv, - delay: delay, + Service: srv, + organization: organization, + delay: delay, } return dirService, nil diff --git a/pkg/glib/licensing.go b/pkg/glib/licensing.go index 0aee71d..996d583 100644 --- a/pkg/glib/licensing.go +++ b/pkg/glib/licensing.go @@ -19,13 +19,14 @@ import ( type LicensingService struct { *licensing.Service - licenses []config.License - delay time.Duration + organization string + licenses []config.License + delay time.Duration } // NewLicensingService() creates a client for communicating with Google Licensing API, // returns a service object authorized to perform actions in Gsuite. -func NewLicensingService(ctx context.Context, clientSecretFile string, impersonatedUserEmail string, delay time.Duration, licenses []config.License) (*LicensingService, error) { +func NewLicensingService(ctx context.Context, organization string, clientSecretFile string, impersonatedUserEmail string, delay time.Duration, licenses []config.License) (*LicensingService, error) { jsonCredentials, err := ioutil.ReadFile(clientSecretFile) if err != nil { return nil, fmt.Errorf("unable to read json credentials: %v", err) @@ -45,9 +46,10 @@ func NewLicensingService(ctx context.Context, clientSecretFile string, impersona } licenseService := &LicensingService{ - Service: srv, - licenses: licenses, - delay: delay, + Service: srv, + organization: organization, + licenses: licenses, + delay: delay, } return licenseService, nil @@ -86,7 +88,10 @@ func (ls *LicensingService) LicenseUsages(ctx context.Context, license config.Li token := "" for { - request := ls.LicenseAssignments.ListForProduct(license.ProductId, "my_customer").PageToken(token).Context(ctx) + // This is the only request in this entire package that actually needs a concrete + // organization name instead of "my_customer"; on the other hand, using a concrete + // name anywhere else leads to HTTP 401 errors. Go figure. + request := ls.LicenseAssignments.ListForProduct(license.ProductId, ls.organization).PageToken(token).Context(ctx) response, err := request.Do() if err != nil { From e22ac4d5bd85dd5eac4cac99e983b4bdcaa751c4 Mon Sep 17 00:00:00 2001 From: Christoph Mewes Date: Tue, 12 Jan 2021 17:10:46 +0100 Subject: [PATCH 06/18] consistently deal with upstream types --- pkg/config/config.go | 8 + pkg/config/conversion.go | 305 +++++++++++++++++++++++++++++++++ pkg/export/export.go | 16 +- pkg/glib/directory.go | 5 +- pkg/glib/directory_groups.go | 103 +---------- pkg/glib/directory_orgunits.go | 38 ++-- pkg/glib/directory_users.go | 266 ++-------------------------- pkg/glib/groupssettings.go | 5 +- pkg/glib/licensing.go | 29 +--- pkg/glib/util.go | 19 -- pkg/sync/groups.go | 60 +++---- pkg/sync/licensing.go | 18 +- pkg/sync/orgunits.go | 36 ++-- pkg/sync/users.go | 64 +++---- pkg/util/util.go | 18 ++ 15 files changed, 464 insertions(+), 526 deletions(-) create mode 100644 pkg/config/conversion.go delete mode 100644 pkg/glib/util.go diff --git a/pkg/config/config.go b/pkg/config/config.go index d67f268..8043a3f 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -41,6 +41,10 @@ type Location struct { FloorSection string `yaml:"floorSection,omitempty"` } +func (l *Location) Empty() bool { + return l.Building == "" && l.Floor == "" && l.FloorSection == "" +} + type Employee struct { EmployeeID string `yaml:"id,omitempty"` Department string `yaml:"department,omitempty"` @@ -50,6 +54,10 @@ type Employee struct { ManagerEmail string `yaml:"managerEmail,omitempty"` } +func (e *Employee) Empty() bool { + return e.EmployeeID == "" && e.Department == "" && e.JobTitle == "" && e.Type == "" && e.CostCenter == "" && e.ManagerEmail == "" +} + type Group struct { Name string `yaml:"name"` Email string `yaml:"email"` diff --git a/pkg/config/conversion.go b/pkg/config/conversion.go new file mode 100644 index 0000000..c3296f4 --- /dev/null +++ b/pkg/config/conversion.go @@ -0,0 +1,305 @@ +package config + +import ( + "fmt" + "strconv" + + "github.com/kubermatic-labs/gman/pkg/util" + directoryv1 "google.golang.org/api/admin/directory/v1" + groupssettingsv1 "google.golang.org/api/groupssettings/v1" +) + +func ToGSuiteUser(user *User) *directoryv1.User { + gsuiteUser := &directoryv1.User{ + Name: &directoryv1.UserName{ + GivenName: user.FirstName, + FamilyName: user.LastName, + }, + PrimaryEmail: user.PrimaryEmail, + RecoveryEmail: user.RecoveryEmail, + RecoveryPhone: user.RecoveryPhone, + OrgUnitPath: user.OrgUnitPath, + } + + if len(user.Phones) > 0 { + phNums := []directoryv1.UserPhone{} + for _, phone := range user.Phones { + phNums = append(phNums, directoryv1.UserPhone{ + Value: phone, + Type: "home", + }) + } + + gsuiteUser.Phones = phNums + } + + if user.Address != "" { + gsuiteUser.Addresses = []directoryv1.UserAddress{ + { + Formatted: user.Address, + Type: "home", + }, + } + } + + if user.SecondaryEmail != "" { + gsuiteUser.Emails = []directoryv1.UserEmail{ + { + Address: user.SecondaryEmail, + Type: "work", + }, + } + } + + if !user.Employee.Empty() { + userOrg := []directoryv1.UserOrganization{ + { + Department: user.Employee.Department, + Title: user.Employee.JobTitle, + CostCenter: user.Employee.CostCenter, + Description: user.Employee.Type, + }, + } + + gsuiteUser.Organizations = userOrg + + if user.Employee.ManagerEmail != "" { + gsuiteUser.Relations = []directoryv1.UserRelation{ + { + Value: user.Employee.ManagerEmail, + Type: "manager", + }, + } + } + + if user.Employee.EmployeeID != "" { + gsuiteUser.ExternalIds = []directoryv1.UserExternalId{ + { + Value: user.Employee.EmployeeID, + Type: "organization", + }, + } + } + } + + if !user.Location.Empty() { + gsuiteUser.Locations = []directoryv1.UserLocation{ + { + Area: "desk", + BuildingId: user.Location.Building, + FloorName: user.Location.Floor, + FloorSection: user.Location.FloorSection, + Type: "desk", + }, + } + } + + return gsuiteUser +} + +// apiUser represents those fields that are not explicitly spec'ed +// out in the GSuite API, but whose we still have to access. +// Re-marshaling into this struct is easier than tons of type assertions +// througout the codebase. +type apiUser struct { + Emails []struct { + Address string `json:"address"` + Primary bool `json:"primary"` + } `json:"emails"` + + Phones []struct { + Value string `json:"value"` + } `json:"phones"` + + ExternalIds []struct { + Value string `json:"value"` + Type string `json:"type"` + } `json:"externalIds"` + + Organizations []struct { + Department string `json:"department"` + Title string `json:"title"` + Description string `json:"description"` + CostCenter string `json:"costCenter"` + } `json:"organizations"` + + Relations []struct { + Value string `json:"value"` + Type string `json:"type"` + } `json:"relations"` + + Locations []struct { + BuildingId string `json:"buildingId"` + FloorName string `json:"floorName"` + FloorSection string `json:"floorSection"` + } `json:"locations"` + + Addresses []struct { + Formatted string `json:"formatted"` + Type string `json:"type"` + } `json:"addresses"` +} + +func ToConfigUser(gsuiteUser *directoryv1.User, userLicenses []License) (User, error) { + apiUser := apiUser{} + if err := util.ConvertToStruct(gsuiteUser, &apiUser); err != nil { + return User{}, fmt.Errorf("failed to decode user: %v", err) + } + + primaryEmail, secondaryEmail := "", "" + for _, email := range apiUser.Emails { + if email.Primary { + primaryEmail = email.Address + } else { + secondaryEmail = email.Address + } + } + + user := User{ + FirstName: gsuiteUser.Name.GivenName, + LastName: gsuiteUser.Name.FamilyName, + PrimaryEmail: primaryEmail, + SecondaryEmail: secondaryEmail, + OrgUnitPath: gsuiteUser.OrgUnitPath, + RecoveryPhone: gsuiteUser.RecoveryPhone, + RecoveryEmail: gsuiteUser.RecoveryEmail, + } + + if len(gsuiteUser.Aliases) > 0 { + for _, alias := range gsuiteUser.Aliases { + user.Aliases = append(user.Aliases, string(alias)) + } + } + + for _, phone := range apiUser.Phones { + user.Phones = append(user.Phones, phone.Value) + } + + for _, extId := range apiUser.ExternalIds { + if extId.Type == "organization" { + user.Employee.EmployeeID = extId.Value + } + } + + for _, org := range apiUser.Organizations { + title := org.Title + if title == "" { + title = org.Department + } + + user.Employee.JobTitle = title + user.Employee.Type = org.Description + user.Employee.CostCenter = org.CostCenter + } + + for _, relation := range apiUser.Relations { + if relation.Type == "manager" { + user.Employee.ManagerEmail = relation.Value + } + } + + for _, location := range apiUser.Locations { + user.Location.Building = location.BuildingId + user.Location.Floor = location.FloorName + user.Location.FloorSection = location.FloorSection + } + + for _, address := range apiUser.Addresses { + if address.Type == "home" { + user.Address = address.Formatted + } + } + + if len(userLicenses) > 0 { + for _, userLicense := range userLicenses { + user.Licenses = append(user.Licenses, userLicense.Name) + } + } + + return user, nil +} + +func ToGSuiteGroup(group *Group) (*directoryv1.Group, *groupssettingsv1.Groups) { + gsuiteGroup := &directoryv1.Group{ + Name: group.Name, + Email: group.Email, + Description: group.Description, + } + + groupSettings := &groupssettingsv1.Groups{ + WhoCanContactOwner: group.WhoCanContactOwner, + WhoCanViewMembership: group.WhoCanViewMembership, + WhoCanApproveMembers: group.WhoCanApproveMembers, + WhoCanPostMessage: group.WhoCanPostMessage, + WhoCanJoin: group.WhoCanJoin, + IsArchived: strconv.FormatBool(group.IsArchived), + ArchiveOnly: strconv.FormatBool(group.IsArchived), + AllowExternalMembers: strconv.FormatBool(group.AllowExternalMembers), + } + + return gsuiteGroup, groupSettings +} + +func ToConfigGroup(gsuiteGroup *directoryv1.Group, settings *groupssettingsv1.Groups, members []*directoryv1.Member) (Group, error) { + allowExternalMembers, err := strconv.ParseBool(settings.AllowExternalMembers) + if err != nil { + return Group{}, fmt.Errorf("invalid 'AllowExternalMembers' value: %v", err) + } + + isArchived, err := strconv.ParseBool(settings.IsArchived) + if err != nil { + return Group{}, fmt.Errorf("invalid 'IsArchived' value: %v", err) + } + + group := Group{ + Name: gsuiteGroup.Name, + Email: gsuiteGroup.Email, + Description: gsuiteGroup.Description, + WhoCanContactOwner: settings.WhoCanContactOwner, + WhoCanViewMembership: settings.WhoCanViewMembership, + WhoCanApproveMembers: settings.WhoCanApproveMembers, + WhoCanPostMessage: settings.WhoCanPostMessage, + WhoCanJoin: settings.WhoCanJoin, + AllowExternalMembers: allowExternalMembers, + IsArchived: isArchived, + Members: []Member{}, + } + + for _, m := range members { + group.Members = append(group.Members, ToConfigGroupMember(m)) + } + + return group, nil +} + +func ToGSuiteGroupMember(member *Member) *directoryv1.Member { + return &directoryv1.Member{ + Email: member.Email, + Role: member.Role, + } +} + +func ToConfigGroupMember(gsuiteMember *directoryv1.Member) Member { + return Member{ + Email: gsuiteMember.Email, + Role: gsuiteMember.Role, + } +} + +func ToGSuiteOrgUnit(orgUnit *OrgUnit) *directoryv1.OrgUnit { + return &directoryv1.OrgUnit{ + Name: orgUnit.Name, + Description: orgUnit.Description, + ParentOrgUnitPath: orgUnit.ParentOrgUnitPath, + BlockInheritance: orgUnit.BlockInheritance, + } +} + +func ToConfigOrgUnit(orgUnit *directoryv1.OrgUnit) OrgUnit { + return OrgUnit{ + Name: orgUnit.Name, + Description: orgUnit.Description, + ParentOrgUnitPath: orgUnit.ParentOrgUnitPath, + BlockInheritance: orgUnit.BlockInheritance, + } +} diff --git a/pkg/export/export.go b/pkg/export/export.go index 6678f78..3960e85 100644 --- a/pkg/export/export.go +++ b/pkg/export/export.go @@ -19,13 +19,7 @@ func ExportOrgUnits(ctx context.Context, directorySrv *glib.DirectoryService) ([ result := []config.OrgUnit{} for _, ou := range orgUnits { log.Printf(" %s", ou.Name) - - result = append(result, config.OrgUnit{ - Name: ou.Name, - Description: ou.Description, - ParentOrgUnitPath: ou.ParentOrgUnitPath, - BlockInheritance: ou.BlockInheritance, - }) + result = append(result, config.ToConfigOrgUnit(ou)) } sort.Slice(result, func(i, j int) bool { @@ -46,7 +40,11 @@ func ExportUsers(ctx context.Context, directorySrv *glib.DirectoryService, licen log.Printf(" %s", user.PrimaryEmail) userLicenses := licenseStatus.GetLicensesForUser(user) - configUser := glib.CreateConfigUserFromGSuite(user, userLicenses) + + configUser, err := config.ToConfigUser(user, userLicenses) + if err != nil { + return nil, fmt.Errorf("failed to convert user: %v", err) + } result = append(result, configUser) } @@ -78,7 +76,7 @@ func ExportGroups(ctx context.Context, directorySrv *glib.DirectoryService, grou return nil, fmt.Errorf("failed to list members: %v", err) } - configGroup, err := glib.CreateConfigGroupFromGSuite(group, members, settings) + configGroup, err := config.ToConfigGroup(group, settings, members) if err != nil { return nil, fmt.Errorf("failed to create config group: %v", err) } diff --git a/pkg/glib/directory.go b/pkg/glib/directory.go index 97f6078..a6d7dc4 100644 --- a/pkg/glib/directory.go +++ b/pkg/glib/directory.go @@ -19,12 +19,11 @@ type DirectoryService struct { delay time.Duration } -// NewDirectoryService() creates a client for communicating with Google Directory API, -// returns a service object authorized to perform actions in Gsuite. +// NewDirectoryService() creates a client for communicating with Google Directory API. func NewDirectoryService(ctx context.Context, organization string, clientSecretFile string, impersonatedUserEmail string, delay time.Duration, scopes ...string) (*DirectoryService, error) { jsonCredentials, err := ioutil.ReadFile(clientSecretFile) if err != nil { - return nil, fmt.Errorf("unable to read json credentials: %v", err) + return nil, fmt.Errorf("unable to read JSON credentials: %v", err) } config, err := google.JWTConfigFromJSON(jsonCredentials, scopes...) diff --git a/pkg/glib/directory_groups.go b/pkg/glib/directory_groups.go index 2bdc1c8..1782803 100644 --- a/pkg/glib/directory_groups.go +++ b/pkg/glib/directory_groups.go @@ -4,12 +4,8 @@ package glib import ( "context" "fmt" - "strconv" directoryv1 "google.golang.org/api/admin/directory/v1" - groupssettingsv1 "google.golang.org/api/groupssettings/v1" - - "github.com/kubermatic-labs/gman/pkg/config" ) // ListGroups returns a list of all current groups from the API @@ -65,69 +61,6 @@ func (ds *DirectoryService) UpdateGroup(ctx context.Context, group *directoryv1. return updatedGroup, nil } -// CreateGSuiteGroupFromConfig converts a ConfigGroup to (GSuite) directoryv1.Group -func CreateGSuiteGroupFromConfig(group *config.Group) (*directoryv1.Group, *groupssettingsv1.Groups) { - googleGroup := &directoryv1.Group{ - Name: group.Name, - Email: group.Email, - } - if group.Description != "" { - googleGroup.Description = group.Description - } - - groupSettings := &groupssettingsv1.Groups{ - WhoCanContactOwner: group.WhoCanContactOwner, - WhoCanViewMembership: group.WhoCanViewMembership, - WhoCanApproveMembers: group.WhoCanApproveMembers, - WhoCanPostMessage: group.WhoCanPostMessage, - WhoCanJoin: group.WhoCanJoin, - IsArchived: strconv.FormatBool(group.IsArchived), - ArchiveOnly: strconv.FormatBool(group.IsArchived), - AllowExternalMembers: strconv.FormatBool(group.AllowExternalMembers), - } - - return googleGroup, groupSettings -} - -func CreateConfigGroupFromGSuite(googleGroup *directoryv1.Group, members []*directoryv1.Member, gSettings *groupssettingsv1.Groups) (config.Group, error) { - boolAllowExternalMembers, err := strconv.ParseBool(gSettings.AllowExternalMembers) - if err != nil { - return config.Group{}, fmt.Errorf("could not parse 'AllowExternalMembers' value from string to bool: %v", err) - } - - boolIsArchived, err := strconv.ParseBool(gSettings.IsArchived) - if err != nil { - return config.Group{}, fmt.Errorf("could not parse 'IsArchived' value from string to bool: %v", err) - } - - configGroup := config.Group{ - Name: googleGroup.Name, - Email: googleGroup.Email, - Description: googleGroup.Description, - WhoCanContactOwner: gSettings.WhoCanContactOwner, - WhoCanViewMembership: gSettings.WhoCanViewMembership, - WhoCanApproveMembers: gSettings.WhoCanApproveMembers, - WhoCanPostMessage: gSettings.WhoCanPostMessage, - WhoCanJoin: gSettings.WhoCanJoin, - AllowExternalMembers: boolAllowExternalMembers, - IsArchived: boolIsArchived, - Members: []config.Member{}, - } - - for _, m := range members { - configGroup.Members = append(configGroup.Members, config.Member{ - Email: m.Email, - Role: m.Role, - }) - } - - return configGroup, nil -} - -//----------------------------------------// -// Group Member handling // -//----------------------------------------// - // ListMembers returns a list of all current group members form the API func (ds *DirectoryService) ListMembers(ctx context.Context, group *directoryv1.Group) ([]*directoryv1.Member, error) { members := []*directoryv1.Member{} @@ -153,10 +86,8 @@ func (ds *DirectoryService) ListMembers(ctx context.Context, group *directoryv1. } // AddNewMember adds a new member to a group in GSuite -func (ds *DirectoryService) AddNewMember(ctx context.Context, groupEmail string, member *config.Member) error { - newMember := createGSuiteGroupMemberFromConfig(member) - - if _, err := ds.Members.Insert(groupEmail, newMember).Context(ctx).Do(); err != nil { +func (ds *DirectoryService) AddNewMember(ctx context.Context, group *directoryv1.Group, member *directoryv1.Member) error { + if _, err := ds.Members.Insert(group.Email, member).Context(ctx).Do(); err != nil { return fmt.Errorf("unable to add member to group: %v", err) } @@ -164,41 +95,19 @@ func (ds *DirectoryService) AddNewMember(ctx context.Context, groupEmail string, } // RemoveMember removes a member from a group in Gsuite -func (ds *DirectoryService) RemoveMember(ctx context.Context, groupEmail string, member *directoryv1.Member) error { - if err := ds.Members.Delete(groupEmail, member.Email).Context(ctx).Do(); err != nil { +func (ds *DirectoryService) RemoveMember(ctx context.Context, group *directoryv1.Group, member *directoryv1.Member) error { + if err := ds.Members.Delete(group.Email, member.Email).Context(ctx).Do(); err != nil { return fmt.Errorf("unable to delete member from group: %v", err) } return nil } -// MemberExists checks if member exists in group -func (ds *DirectoryService) MemberExists(ctx context.Context, group *directoryv1.Group, member *config.Member) (bool, error) { - exists, err := ds.Members.HasMember(group.Email, member.Email).Context(ctx).Do() - if err != nil { - return false, fmt.Errorf("unable to check if member exists in group: %v", err) - } - - return exists.IsMember, nil -} - // UpdateMembership changes the role of the member -func (ds *DirectoryService) UpdateMembership(ctx context.Context, groupEmail string, member *config.Member) error { - newMember := createGSuiteGroupMemberFromConfig(member) - - if _, err := ds.Members.Update(groupEmail, member.Email, newMember).Context(ctx).Do(); err != nil { +func (ds *DirectoryService) UpdateMembership(ctx context.Context, group *directoryv1.Group, member *directoryv1.Member) error { + if _, err := ds.Members.Update(group.Email, member.Email, member).Context(ctx).Do(); err != nil { return fmt.Errorf("unable to update member in group: %v", err) } return nil } - -// createGSuiteGroupMemberFromConfig converts a ConfigMember to (GSuite) admin.Member -func createGSuiteGroupMemberFromConfig(member *config.Member) *directoryv1.Member { - googleMember := &directoryv1.Member{ - Email: member.Email, - Role: member.Role, - } - - return googleMember -} diff --git a/pkg/glib/directory_orgunits.go b/pkg/glib/directory_orgunits.go index 64a2c6a..312faba 100644 --- a/pkg/glib/directory_orgunits.go +++ b/pkg/glib/directory_orgunits.go @@ -6,11 +6,8 @@ import ( "fmt" directoryv1 "google.golang.org/api/admin/directory/v1" - - "github.com/kubermatic-labs/gman/pkg/config" ) -// ListOrgUnits returns a list of all current organizational units from the API func (ds *DirectoryService) ListOrgUnits(ctx context.Context) ([]*directoryv1.OrgUnit, error) { // OrgUnits do not use pagination and always return all units in a single API call. request, err := ds.Orgunits.List("my_customer").Type("all").Context(ctx).Do() @@ -21,48 +18,35 @@ func (ds *DirectoryService) ListOrgUnits(ctx context.Context) ([]*directoryv1.Or return request.OrganizationUnits, nil } -// CreateOrgUnit creates a new org unit -func (ds *DirectoryService) CreateOrgUnit(ctx context.Context, ou *config.OrgUnit) error { - newOU := createGSuiteOUFromConfig(ou) - - if _, err := ds.Orgunits.Insert("my_customer", newOU).Context(ctx).Do(); err != nil { +func (ds *DirectoryService) CreateOrgUnit(ctx context.Context, orgUnit *directoryv1.OrgUnit) error { + if _, err := ds.Orgunits.Insert("my_customer", orgUnit).Context(ctx).Do(); err != nil { return fmt.Errorf("unable to create org unit: %v", err) } return nil } -// DeleteOrgUnit deletes an org group -func (ds *DirectoryService) DeleteOrgUnit(ctx context.Context, ou *directoryv1.OrgUnit) error { +func (ds *DirectoryService) DeleteOrgUnit(ctx context.Context, orgUnit *directoryv1.OrgUnit) error { // deletion can happen with the full orgunit's path *OR* it's unique ID - if err := ds.Orgunits.Delete("my_customer", ou.OrgUnitId).Context(ctx).Do(); err != nil { + if err := ds.Orgunits.Delete("my_customer", orgUnit.OrgUnitId).Context(ctx).Do(); err != nil { return fmt.Errorf("unable to delete org unit: %v", err) } return nil } -// UpdateOrgUnit updates the remote org unit with config -func (ds *DirectoryService) UpdateOrgUnit(ctx context.Context, ou *config.OrgUnit) error { - updatedOu := createGSuiteOUFromConfig(ou) - +func (ds *DirectoryService) UpdateOrgUnit(ctx context.Context, orgUnit *directoryv1.OrgUnit) error { // to update, we need the org unit's ID or its path; - // we have neither, but since the path is always just "{parent}/{orgunit-name}", + // we possibly have neither, but since the path is always just "{parent}/{orgunit-name}", // we can construct it (there is no encoding/escaping in the paths, amazingly) - path := ou.ParentOrgUnitPath + "/" + ou.Name + path := orgUnit.OrgUnitPath + if path == "" { + path = orgUnit.ParentOrgUnitPath + "/" + orgUnit.Name + } - if _, err := ds.Orgunits.Update("my_customer", path, updatedOu).Context(ctx).Do(); err != nil { + if _, err := ds.Orgunits.Update("my_customer", path, orgUnit).Context(ctx).Do(); err != nil { return fmt.Errorf("unable to update org unit: %v", err) } return nil } - -// createGSuiteOUFromConfig converts a OrgUnitConfig to (GSuite) admin.OrgUnit -func createGSuiteOUFromConfig(ou *config.OrgUnit) *directoryv1.OrgUnit { - return &directoryv1.OrgUnit{ - Name: ou.Name, - Description: ou.Description, - ParentOrgUnitPath: ou.ParentOrgUnitPath, - } -} diff --git a/pkg/glib/directory_users.go b/pkg/glib/directory_users.go index cc5b07e..b6d269b 100644 --- a/pkg/glib/directory_users.go +++ b/pkg/glib/directory_users.go @@ -9,10 +9,9 @@ import ( password "github.com/sethvargo/go-password/password" directoryv1 "google.golang.org/api/admin/directory/v1" - "github.com/kubermatic-labs/gman/pkg/config" + "github.com/kubermatic-labs/gman/pkg/util" ) -// ListUsers returns a list of all current users from the API func (ds *DirectoryService) ListUsers(ctx context.Context) ([]*directoryv1.User, error) { users := []*directoryv1.User{} token := "" @@ -36,44 +35,21 @@ func (ds *DirectoryService) ListUsers(ctx context.Context) ([]*directoryv1.User, return users, nil } -// GetUserEmails retrieves primary and secondary (type: work) user email addresses -func GetUserEmails(user *directoryv1.User) (string, string) { - var primEmail string - var secEmail string - - for _, email := range user.Emails.([]interface{}) { - if email.(map[string]interface{})["primary"] == true { - primEmail = fmt.Sprint(email.(map[string]interface{})["address"]) - } - if email.(map[string]interface{})["type"] == "work" { - secEmail = fmt.Sprint(email.(map[string]interface{})["address"]) - } - } - - return primEmail, secEmail -} - -func (ds *DirectoryService) CreateUser(ctx context.Context, user *config.User) (*directoryv1.User, error) { +func (ds *DirectoryService) CreateUser(ctx context.Context, user *directoryv1.User) (*directoryv1.User, error) { // generate a rand password pass, err := password.Generate(20, 5, 5, false, false) if err != nil { return nil, fmt.Errorf("unable to generate password: %v", err) } - newUser := createGSuiteUserFromConfig(user) - newUser.Password = pass - newUser.ChangePasswordAtNextLogin = true + user.Password = pass + user.ChangePasswordAtNextLogin = true - createdUser, err := ds.Users.Insert(newUser).Context(ctx).Do() + createdUser, err := ds.Users.Insert(user).Context(ctx).Do() if err != nil { return nil, fmt.Errorf("unable to create user: %v", err) } - // err = HandleUserAliases(ds, newUser, user.Aliases) - // if err != nil { - // return err - // } - return createdUser, nil } @@ -86,235 +62,15 @@ func (ds *DirectoryService) DeleteUser(ctx context.Context, user *directoryv1.Us return nil } -// UpdateUser updates the remote user with config -func (ds *DirectoryService) UpdateUser(ctx context.Context, user *config.User) (*directoryv1.User, error) { - apiUser := createGSuiteUserFromConfig(user) - - updatedUser, err := ds.Users.Update(user.PrimaryEmail, apiUser).Context(ctx).Do() +func (ds *DirectoryService) UpdateUser(ctx context.Context, user *directoryv1.User) (*directoryv1.User, error) { + updatedUser, err := ds.Users.Update(user.PrimaryEmail, user).Context(ctx).Do() if err != nil { return nil, fmt.Errorf("unable to update user: %v", err) } - // err = HandleUserAliases(srv, updatedUser, user.Aliases) - // if err != nil { - // return err - // } - return updatedUser, nil } -// createGSuiteUserFromConfig converts a ConfigUser to (GSuite) directoryv1.User -func createGSuiteUserFromConfig(user *config.User) *directoryv1.User { - googleUser := &directoryv1.User{ - Name: &directoryv1.UserName{ - GivenName: user.FirstName, - FamilyName: user.LastName, - }, - PrimaryEmail: user.PrimaryEmail, - OrgUnitPath: user.OrgUnitPath, - } - - if len(user.Phones) > 0 { - phNums := []directoryv1.UserPhone{} - for _, phone := range user.Phones { - phNum := directoryv1.UserPhone{ - Value: phone, - Type: "home", - } - phNums = append(phNums, phNum) - } - googleUser.Phones = phNums - } - - if user.Address != "" { - addr := []directoryv1.UserAddress{ - { - Formatted: user.Address, - Type: "home", - }, - } - googleUser.Addresses = addr - } - - if user.RecoveryEmail != "" { - googleUser.RecoveryEmail = user.RecoveryEmail - } - - if user.RecoveryPhone != "" { - googleUser.RecoveryPhone = user.RecoveryPhone - } - - if user.SecondaryEmail != "" { - workEm := []directoryv1.UserEmail{ - { - Address: user.SecondaryEmail, - Type: "work", - }, - } - googleUser.Emails = workEm - } - - if user.Employee != (config.Employee{}) { - uOrg := []directoryv1.UserOrganization{ - { - Department: user.Employee.Department, - Title: user.Employee.JobTitle, - CostCenter: user.Employee.CostCenter, - Description: user.Employee.Type, - }, - } - - googleUser.Organizations = uOrg - - if user.Employee.ManagerEmail != "" { - rel := []directoryv1.UserRelation{ - { - Value: user.Employee.ManagerEmail, - Type: "manager", - }, - } - googleUser.Relations = rel - } - - if user.Employee.EmployeeID != "" { - ids := []directoryv1.UserExternalId{ - { - Value: user.Employee.EmployeeID, - Type: "organization", - }, - } - googleUser.ExternalIds = ids - } - } - - if user.Location != (config.Location{}) { - loc := []directoryv1.UserLocation{ - { - Area: "desk", - BuildingId: user.Location.Building, - FloorName: user.Location.Floor, - FloorSection: user.Location.FloorSection, - Type: "desk", - }, - } - googleUser.Locations = loc - } - - return googleUser -} - -// CreateConfigUserFromGSuite converts a (GSuite) admin.User to ConfigUser -func CreateConfigUserFromGSuite(googleUser *directoryv1.User, userLicenses []config.License) config.User { - // get emails - primaryEmail, secondaryEmail := GetUserEmails(googleUser) - - configUser := config.User{ - FirstName: googleUser.Name.GivenName, - LastName: googleUser.Name.FamilyName, - PrimaryEmail: primaryEmail, - SecondaryEmail: secondaryEmail, - OrgUnitPath: googleUser.OrgUnitPath, - RecoveryPhone: googleUser.RecoveryPhone, - RecoveryEmail: googleUser.RecoveryEmail, - } - - if len(googleUser.Aliases) > 0 { - for _, alias := range googleUser.Aliases { - configUser.Aliases = append(configUser.Aliases, string(alias)) - } - } - - if googleUser.Phones != nil { - for _, phone := range googleUser.Phones.([]interface{}) { - if phoneMap, ok := phone.(map[string]interface{}); ok { - if phoneVal, exists := phoneMap["value"]; exists { - configUser.Phones = append(configUser.Phones, fmt.Sprint(phoneVal)) - } - } - } - } - - if googleUser.ExternalIds != nil { - for _, id := range googleUser.ExternalIds.([]interface{}) { - if idMap, ok := id.(map[string]interface{}); ok { - if idType := idMap["type"]; idType == "organization" { - if orgId, exists := idMap["value"]; exists { - configUser.Employee.EmployeeID = fmt.Sprint(orgId) - } - } - } - } - } - - if googleUser.Organizations != nil { - for _, org := range googleUser.Organizations.([]interface{}) { - if orgMap, ok := org.(map[string]interface{}); ok { - if department, exists := orgMap["department"]; exists { - configUser.Employee.JobTitle = fmt.Sprint(department) - } - if title, exists := orgMap["title"]; exists { - configUser.Employee.JobTitle = fmt.Sprint(title) - } - if description, exists := orgMap["description"]; exists { - configUser.Employee.Type = fmt.Sprint(description) - } - if costCenter, exists := orgMap["costCenter"]; exists { - configUser.Employee.CostCenter = fmt.Sprint(costCenter) - } - } - } - } - - if googleUser.Relations != nil { - for _, rel := range googleUser.Relations.([]interface{}) { - if relMap, ok := rel.(map[string]interface{}); ok { - if relType := relMap["type"]; relType == "manager" { - if managerEmail, exists := relMap["value"]; exists { - configUser.Employee.ManagerEmail = fmt.Sprint(managerEmail) - } - } - } - } - } - - if googleUser.Locations != nil { - for _, loc := range googleUser.Locations.([]interface{}) { - if locMap, ok := loc.(map[string]interface{}); ok { - if buildingId, exists := locMap["buildingId"]; exists { - configUser.Location.Building = fmt.Sprint(buildingId) - } - if floorName, exists := locMap["floorName"]; exists { - configUser.Location.Floor = fmt.Sprint(floorName) - } - if floorSection, exists := locMap["floorSection"]; exists { - configUser.Location.FloorSection = fmt.Sprint(floorSection) - } - } - } - } - - if googleUser.Addresses != nil { - for _, addr := range googleUser.Addresses.([]interface{}) { - - if addrMap, ok := addr.(map[string]interface{}); ok { - if addrType := addrMap["type"]; addrType == "home" { - if address, exists := addrMap["formatted"]; exists { - configUser.Address = fmt.Sprint(address) - } - } - } - } - } - - if len(userLicenses) > 0 { - for _, userLicense := range userLicenses { - configUser.Licenses = append(configUser.Licenses, userLicense.Name) - } - } - - return configUser -} - type aliases struct { Aliases []struct { Alias string `json:"alias"` @@ -322,14 +78,14 @@ type aliases struct { } `json:"aliases"` } -func (ds *DirectoryService) GetUserAliases(ctx context.Context, user *config.User) ([]string, error) { +func (ds *DirectoryService) GetUserAliases(ctx context.Context, user *directoryv1.User) ([]string, error) { data, err := ds.Users.Aliases.List(user.PrimaryEmail).Context(ctx).Do() if err != nil { return nil, fmt.Errorf("unable to list user aliases: %v", err) } aliases := aliases{} - if err := convertToStruct(data, &aliases); err != nil { + if err := util.ConvertToStruct(data, &aliases); err != nil { return nil, fmt.Errorf("failed to parse user aliases: %v", err) } @@ -343,7 +99,7 @@ func (ds *DirectoryService) GetUserAliases(ctx context.Context, user *config.Use return result, nil } -func (ds *DirectoryService) CreateUserAlias(ctx context.Context, user *config.User, alias string) error { +func (ds *DirectoryService) CreateUserAlias(ctx context.Context, user *directoryv1.User, alias string) error { newAlias := &directoryv1.Alias{ Alias: alias, } @@ -355,7 +111,7 @@ func (ds *DirectoryService) CreateUserAlias(ctx context.Context, user *config.Us return nil } -func (ds *DirectoryService) DeleteUserAlias(ctx context.Context, user *config.User, alias string) error { +func (ds *DirectoryService) DeleteUserAlias(ctx context.Context, user *directoryv1.User, alias string) error { if err := ds.Users.Aliases.Delete(user.PrimaryEmail, alias).Context(ctx).Do(); err != nil { return fmt.Errorf("unable to delete user alias: %v", err) } diff --git a/pkg/glib/groupssettings.go b/pkg/glib/groupssettings.go index 5cebbb1..18f53e7 100644 --- a/pkg/glib/groupssettings.go +++ b/pkg/glib/groupssettings.go @@ -19,12 +19,11 @@ type GroupsSettingsService struct { delay time.Duration } -// NewGroupsSettingsService() creates a client for communicating with Google Groupssettings API, -// returns a service object authorized to perform actions in Gsuite. +// NewGroupsSettingsService() creates a client for communicating with Google Groupssettings API. func NewGroupsSettingsService(ctx context.Context, clientSecretFile string, impersonatedUserEmail string, delay time.Duration) (*GroupsSettingsService, error) { jsonCredentials, err := ioutil.ReadFile(clientSecretFile) if err != nil { - return nil, fmt.Errorf("unable to read json credentials (clientSecretFile): %v", err) + return nil, fmt.Errorf("unable to read JSON credentials: %v", err) } config, err := google.JWTConfigFromJSON(jsonCredentials, groupssettingsv1.AppsGroupsSettingsScope) diff --git a/pkg/glib/licensing.go b/pkg/glib/licensing.go index 996d583..ad7eb80 100644 --- a/pkg/glib/licensing.go +++ b/pkg/glib/licensing.go @@ -24,12 +24,11 @@ type LicensingService struct { delay time.Duration } -// NewLicensingService() creates a client for communicating with Google Licensing API, -// returns a service object authorized to perform actions in Gsuite. +// NewLicensingService() creates a client for communicating with Google Licensing API. func NewLicensingService(ctx context.Context, organization string, clientSecretFile string, impersonatedUserEmail string, delay time.Duration, licenses []config.License) (*LicensingService, error) { jsonCredentials, err := ioutil.ReadFile(clientSecretFile) if err != nil { - return nil, fmt.Errorf("unable to read json credentials: %v", err) + return nil, fmt.Errorf("unable to read JSON credentials: %v", err) } config, err := google.JWTConfigFromJSON(jsonCredentials, licensing.AppsLicensingScope) @@ -60,29 +59,7 @@ func (ls *LicensingService) GetLicenses() ([]config.License, error) { return ls.licenses, nil } -// GetUserLicense returns a list of licenses of a user; -// note that this is extremely slow due to API limitations, consider -// listing all usages per license instead. -func (ls *LicensingService) GetUserLicenses(ctx context.Context, user string) ([]config.License, error) { - var result []config.License - - for _, license := range ls.licenses { - _, err := ls.LicenseAssignments.Get(license.ProductId, license.SkuId, user).Context(ctx).Do() - - log.Println("TODO: delay next request") - - if err != nil { - return nil, fmt.Errorf("unable to retrieve license in domain: %v", err) - } - - result = append(result, license) - } - - return result, nil -} - -// LicenseUsages lists all user IDs assigned licenses for a specific -// product SKU. +// LicenseUsages lists all user IDs assigned licenses for a specific product SKU. func (ls *LicensingService) LicenseUsages(ctx context.Context, license config.License) ([]string, error) { userIDs := []string{} token := "" diff --git a/pkg/glib/util.go b/pkg/glib/util.go deleted file mode 100644 index e240e6d..0000000 --- a/pkg/glib/util.go +++ /dev/null @@ -1,19 +0,0 @@ -package glib - -import ( - "encoding/json" - "fmt" -) - -func convertToStruct(data json.Marshaler, dst interface{}) error { - encoded, err := data.MarshalJSON() - if err != nil { - return fmt.Errorf("failed to encode as JSON: %v", err) - } - - if err := json.Unmarshal(encoded, dst); err != nil { - return fmt.Errorf("failed to decode as JSON: %v", err) - } - - return nil -} diff --git a/pkg/sync/groups.go b/pkg/sync/groups.go index baa7cdd..46b85f6 100644 --- a/pkg/sync/groups.go +++ b/pkg/sync/groups.go @@ -21,40 +21,40 @@ func SyncGroups( ) error { log.Println("⇄ Syncing groups…") - currentGroups, err := directorySrv.ListGroups(ctx) + liveGroups, err := directorySrv.ListGroups(ctx) if err != nil { return err } - currentGroupEmails := sets.NewString() + liveGroupEmails := sets.NewString() - for _, current := range currentGroups { - currentGroupEmails.Insert(current.Email) + for _, liveGroup := range liveGroups { + liveGroupEmails.Insert(liveGroup.Email) found := false - for _, configured := range cfg.Groups { - if configured.Email == current.Email { + for _, expectedGroup := range cfg.Groups { + if expectedGroup.Email == liveGroup.Email { found = true - currentMembers, err := directorySrv.ListMembers(ctx, current) + liveMembers, err := directorySrv.ListMembers(ctx, liveGroup) if err != nil { return fmt.Errorf("failed to fetch members: %v", err) } - currentSettings, err := groupsSettingsSrv.GetSettings(ctx, current.Email) + liveSettings, err := groupsSettingsSrv.GetSettings(ctx, liveGroup.Email) if err != nil { return fmt.Errorf("failed to fetch group settings: %v", err) } - if groupUpToDate(configured, current, currentMembers, currentSettings) { + if groupUpToDate(expectedGroup, liveGroup, liveMembers, liveSettings) { // no update needed - log.Printf(" ✓ %s", configured.Email) + log.Printf(" ✓ %s", expectedGroup.Email) } else { // update it - log.Printf(" ✎ %s", configured.Email) + log.Printf(" ✎ %s", expectedGroup.Email) - group, settings := glib.CreateGSuiteGroupFromConfig(&configured) + group, settings := config.ToGSuiteGroup(&expectedGroup) if confirm { group, err = directorySrv.UpdateGroup(ctx, group) @@ -67,7 +67,7 @@ func SyncGroups( } } - if err := syncGroupMembers(ctx, directorySrv, &configured, currentMembers, confirm); err != nil { + if err := syncGroupMembers(ctx, directorySrv, &expectedGroup, group, liveMembers, confirm); err != nil { return fmt.Errorf("failed to sync members: %v", err) } } @@ -77,21 +77,20 @@ func SyncGroups( } if !found { - log.Printf(" ✁ %s", current.Email) + log.Printf(" ✁ %s", liveGroup.Email) if confirm { - if err := directorySrv.DeleteGroup(ctx, current); err != nil { + if err := directorySrv.DeleteGroup(ctx, liveGroup); err != nil { return fmt.Errorf("failed to delete group: %v", err) } } } } - for _, configured := range cfg.Groups { - if !currentGroupEmails.Has(configured.Email) { - group, settings := glib.CreateGSuiteGroupFromConfig(&configured) - - log.Printf(" + %s", configured.Email) + for _, expectedGroup := range cfg.Groups { + if !liveGroupEmails.Has(expectedGroup.Email) { + group, settings := config.ToGSuiteGroup(&expectedGroup) + log.Printf(" + %s", expectedGroup.Email) if confirm { group, err = directorySrv.CreateGroup(ctx, group) @@ -104,7 +103,7 @@ func SyncGroups( } } - if err := syncGroupMembers(ctx, directorySrv, &configured, nil, confirm); err != nil { + if err := syncGroupMembers(ctx, directorySrv, &expectedGroup, group, nil, confirm); err != nil { return fmt.Errorf("failed to sync members: %v", err) } } @@ -126,7 +125,8 @@ func getConfiguredMember(group *config.Group, member *directoryv1.Member) *confi func syncGroupMembers( ctx context.Context, directorySrv *glib.DirectoryService, - configuredGroup *config.Group, + expectedGroup *config.Group, + liveGroup *directoryv1.Group, liveMembers []*directoryv1.Member, confirm bool, ) error { @@ -135,13 +135,13 @@ func syncGroupMembers( for _, liveMember := range liveMembers { liveMemberEmails.Insert(liveMember.Email) - expectedMember := getConfiguredMember(configuredGroup, liveMember) + expectedMember := getConfiguredMember(expectedGroup, liveMember) if expectedMember == nil { log.Printf(" - %s", liveMember.Email) if confirm { - if err := directorySrv.RemoveMember(ctx, configuredGroup.Email, liveMember); err != nil { + if err := directorySrv.RemoveMember(ctx, liveGroup, liveMember); err != nil { return fmt.Errorf("unable to remove member: %v", err) } } @@ -149,19 +149,21 @@ func syncGroupMembers( log.Printf(" ✎ %s", liveMember.Email) if confirm { - if err := directorySrv.UpdateMembership(ctx, configuredGroup.Email, expectedMember); err != nil { + member := config.ToGSuiteGroupMember(expectedMember) + if err := directorySrv.UpdateMembership(ctx, liveGroup, member); err != nil { return fmt.Errorf("unable to update membership: %v", err) } } } } - for _, configuredMember := range configuredGroup.Members { - if !liveMemberEmails.Has(configuredMember.Email) { - log.Printf(" + %s", configuredMember.Email) + for _, expectedMember := range expectedGroup.Members { + if !liveMemberEmails.Has(expectedMember.Email) { + log.Printf(" + %s", expectedMember.Email) if confirm { - if err := directorySrv.AddNewMember(ctx, configuredGroup.Email, &configuredMember); err != nil { + member := config.ToGSuiteGroupMember(&expectedMember) + if err := directorySrv.AddNewMember(ctx, liveGroup, member); err != nil { return fmt.Errorf("unable to add member: %v", err) } } diff --git a/pkg/sync/licensing.go b/pkg/sync/licensing.go index b50fb67..a22fb5f 100644 --- a/pkg/sync/licensing.go +++ b/pkg/sync/licensing.go @@ -35,12 +35,12 @@ func sliceContainsLicense(licenses []config.License, identifier string) bool { func syncUserLicenses( ctx context.Context, licenseSrv *glib.LicensingService, - configuredUser *config.User, + expectedUser *config.User, liveUser *directoryv1.User, licenseStatus *glib.LicenseStatus, confirm bool, ) error { - expectedLicenses := configuredUser.Licenses + expectedLicenses := expectedUser.Licenses liveLicenses := []config.License{} // in dry-run mode, there can be cases where there is no live user yet @@ -48,21 +48,21 @@ func syncUserLicenses( liveLicenses = licenseStatus.GetLicensesForUser(liveUser) } - for _, license := range liveLicenses { - if !userHasLicense(configuredUser, license) { - log.Printf(" - license %s", license.Name) + for _, liveLicense := range liveLicenses { + if !userHasLicense(expectedUser, liveLicense) { + log.Printf(" - license %s", liveLicense.Name) if confirm { - if err := licenseSrv.UnassignLicense(ctx, liveUser, license); err != nil { + if err := licenseSrv.UnassignLicense(ctx, liveUser, liveLicense); err != nil { return fmt.Errorf("unable to assign license: %v", err) } } } } - for _, identifier := range expectedLicenses { - if !sliceContainsLicense(liveLicenses, identifier) { - license := licenseStatus.GetLicense(identifier) + for _, expectedLicense := range expectedLicenses { + if !sliceContainsLicense(liveLicenses, expectedLicense) { + license := licenseStatus.GetLicense(expectedLicense) log.Printf(" + license %s", license.Name) if confirm { diff --git a/pkg/sync/orgunits.go b/pkg/sync/orgunits.go index e4e7b38..13f9837 100644 --- a/pkg/sync/orgunits.go +++ b/pkg/sync/orgunits.go @@ -19,32 +19,32 @@ func SyncOrgUnits( ) error { log.Println("⇄ Syncing organizational units…") - currentUnits, err := directorySrv.ListOrgUnits(ctx) + liveOrgUnits, err := directorySrv.ListOrgUnits(ctx) if err != nil { return err } - currentNames := sets.NewString() + liveNames := sets.NewString() - for _, current := range currentUnits { - currentNames.Insert(current.Name) + for _, liveOrgUnit := range liveOrgUnits { + liveNames.Insert(liveOrgUnit.Name) found := false - for _, configured := range cfg.OrgUnits { - if configured.Name == current.Name { + for _, expectedOrgUnit := range cfg.OrgUnits { + if expectedOrgUnit.Name == liveOrgUnit.Name { found = true - if orgUnitUpToDate(configured, current) { + if orgUnitUpToDate(expectedOrgUnit, liveOrgUnit) { // no update needed - log.Printf(" ✓ %s", configured.Name) + log.Printf(" ✓ %s", expectedOrgUnit.Name) } else { // update it - log.Printf(" ✎ %s", configured.Name) + log.Printf(" ✎ %s", expectedOrgUnit.Name) if confirm { - err := directorySrv.UpdateOrgUnit(ctx, &configured) - if err != nil { + apiOrgUnit := config.ToGSuiteOrgUnit(&expectedOrgUnit) + if err := directorySrv.UpdateOrgUnit(ctx, apiOrgUnit); err != nil { return fmt.Errorf("failed to update org unit: %v", err) } } @@ -55,10 +55,10 @@ func SyncOrgUnits( } if !found { - log.Printf(" ✁ %s", current.Name) + log.Printf(" ✁ %s", liveOrgUnit.Name) if confirm { - err := directorySrv.DeleteOrgUnit(ctx, current) + err := directorySrv.DeleteOrgUnit(ctx, liveOrgUnit) if err != nil { return fmt.Errorf("failed to delete org unit: %v", err) } @@ -66,13 +66,13 @@ func SyncOrgUnits( } } - for _, configured := range cfg.OrgUnits { - if !currentNames.Has(configured.Name) { - log.Printf(" + %s", configured.Name) + for _, expectedOrgUnit := range cfg.OrgUnits { + if !liveNames.Has(expectedOrgUnit.Name) { + log.Printf(" + %s", expectedOrgUnit.Name) if confirm { - err := directorySrv.CreateOrgUnit(ctx, &configured) - if err != nil { + apiOrgUnit := config.ToGSuiteOrgUnit(&expectedOrgUnit) + if err := directorySrv.CreateOrgUnit(ctx, apiOrgUnit); err != nil { return fmt.Errorf("failed to create org unit: %v", err) } } diff --git a/pkg/sync/users.go b/pkg/sync/users.go index 1d40958..a785ef2 100644 --- a/pkg/sync/users.go +++ b/pkg/sync/users.go @@ -22,49 +22,50 @@ func SyncUsers( ) error { log.Println("⇄ Syncing users…") - currentUsers, err := directorySrv.ListUsers(ctx) + liveUsers, err := directorySrv.ListUsers(ctx) if err != nil { return err } - currentEmails := sets.NewString() + liveEmails := sets.NewString() - for _, current := range currentUsers { - currentEmails.Insert(current.PrimaryEmail) + for _, liveUser := range liveUsers { + liveEmails.Insert(liveUser.PrimaryEmail) found := false - for _, configured := range cfg.Users { - if configured.PrimaryEmail == current.PrimaryEmail { + for _, expectedUser := range cfg.Users { + if expectedUser.PrimaryEmail == liveUser.PrimaryEmail { found = true - currentUserLicenses := licenseStatus.GetLicensesForUser(current) + currentUserLicenses := licenseStatus.GetLicensesForUser(liveUser) - currentAliases, err := directorySrv.GetUserAliases(ctx, &configured) + currentAliases, err := directorySrv.GetUserAliases(ctx, liveUser) if err != nil { return fmt.Errorf("failed to fetch aliases: %v", err) } - if userUpToDate(configured, current, currentUserLicenses, currentAliases) { + if userUpToDate(expectedUser, liveUser, currentUserLicenses, currentAliases) { // no update needed - log.Printf(" ✓ %s", configured.PrimaryEmail) + log.Printf(" ✓ %s", expectedUser.PrimaryEmail) } else { // update it - log.Printf(" ✎ %s", configured.PrimaryEmail) + log.Printf(" ✎ %s", expectedUser.PrimaryEmail) - updatedUser := current + updatedUser := liveUser if confirm { - updatedUser, err = directorySrv.UpdateUser(ctx, &configured) + apiUser := config.ToGSuiteUser(&expectedUser) + updatedUser, err = directorySrv.UpdateUser(ctx, apiUser) if err != nil { return fmt.Errorf("failed to update user: %v", err) } } - if err := syncUserAliases(ctx, directorySrv, &configured, updatedUser, currentAliases, confirm); err != nil { + if err := syncUserAliases(ctx, directorySrv, &expectedUser, updatedUser, currentAliases, confirm); err != nil { return fmt.Errorf("failed to sync aliases: %v", err) } - if err := syncUserLicenses(ctx, licensingSrv, &configured, updatedUser, licenseStatus, confirm); err != nil { + if err := syncUserLicenses(ctx, licensingSrv, &expectedUser, updatedUser, licenseStatus, confirm); err != nil { return fmt.Errorf("failed to sync licenses: %v", err) } } @@ -74,34 +75,35 @@ func SyncUsers( } if !found { - log.Printf(" ✁ %s", current.PrimaryEmail) + log.Printf(" ✁ %s", liveUser.PrimaryEmail) if confirm { - if err := directorySrv.DeleteUser(ctx, current); err != nil { + if err := directorySrv.DeleteUser(ctx, liveUser); err != nil { return fmt.Errorf("failed to delete user: %v", err) } } } } - for _, configured := range cfg.Users { - if !currentEmails.Has(configured.PrimaryEmail) { - log.Printf(" + %s", configured.PrimaryEmail) + for _, expectedUser := range cfg.Users { + if !liveEmails.Has(expectedUser.PrimaryEmail) { + log.Printf(" + %s", expectedUser.PrimaryEmail) var createdUser *directoryv1.User if confirm { - createdUser, err = directorySrv.CreateUser(ctx, &configured) + apiUser := config.ToGSuiteUser(&expectedUser) + createdUser, err = directorySrv.CreateUser(ctx, apiUser) if err != nil { return fmt.Errorf("failed to create user: %v", err) } } - if err := syncUserAliases(ctx, directorySrv, &configured, createdUser, nil, confirm); err != nil { + if err := syncUserAliases(ctx, directorySrv, &expectedUser, createdUser, nil, confirm); err != nil { return fmt.Errorf("failed to sync aliases: %v", err) } - if err := syncUserLicenses(ctx, licensingSrv, &configured, createdUser, licenseStatus, confirm); err != nil { + if err := syncUserLicenses(ctx, licensingSrv, &expectedUser, createdUser, licenseStatus, confirm); err != nil { return fmt.Errorf("failed to sync licenses: %v", err) } } @@ -113,32 +115,32 @@ func SyncUsers( func syncUserAliases( ctx context.Context, directorySrv *glib.DirectoryService, - configuredUser *config.User, + expectedUser *config.User, liveUser *directoryv1.User, liveAliases []string, confirm bool, ) error { - configuredAliases := sets.NewString(configuredUser.Aliases...) + expectedAliases := sets.NewString(expectedUser.Aliases...) liveAliasesSet := sets.NewString(liveAliases...) for _, liveAlias := range liveAliases { - if !configuredAliases.Has(liveAlias) { + if !expectedAliases.Has(liveAlias) { log.Printf(" - alias %s", liveAlias) if confirm { - if err := directorySrv.DeleteUserAlias(ctx, configuredUser, liveAlias); err != nil { + if err := directorySrv.DeleteUserAlias(ctx, liveUser, liveAlias); err != nil { return fmt.Errorf("unable to delete alias: %v", err) } } } } - for _, configuredAlias := range configuredAliases.List() { - if !liveAliasesSet.Has(configuredAlias) { - log.Printf(" + alias %s", configuredAlias) + for _, expectedAlias := range expectedAliases.List() { + if !liveAliasesSet.Has(expectedAlias) { + log.Printf(" + alias %s", expectedAlias) if confirm { - if err := directorySrv.CreateUserAlias(ctx, configuredUser, configuredAlias); err != nil { + if err := directorySrv.CreateUserAlias(ctx, liveUser, expectedAlias); err != nil { return fmt.Errorf("unable to create alias: %v", err) } } diff --git a/pkg/util/util.go b/pkg/util/util.go index c87e76e..d5f159d 100644 --- a/pkg/util/util.go +++ b/pkg/util/util.go @@ -1,5 +1,10 @@ package util +import ( + "encoding/json" + "fmt" +) + func StringSliceContains(s []string, needle string) bool { for _, item := range s { if item == needle { @@ -9,3 +14,16 @@ func StringSliceContains(s []string, needle string) bool { return false } + +func ConvertToStruct(data json.Marshaler, dst interface{}) error { + encoded, err := data.MarshalJSON() + if err != nil { + return fmt.Errorf("failed to encode as JSON: %v", err) + } + + if err := json.Unmarshal(encoded, dst); err != nil { + return fmt.Errorf("failed to decode as JSON: %v", err) + } + + return nil +} From 36a88db8980a4be084138dbf554df26dfa1a0d4e Mon Sep 17 00:00:00 2001 From: Christoph Mewes Date: Tue, 12 Jan 2021 18:05:09 +0100 Subject: [PATCH 07/18] improved validation and defaulting --- pkg/config/config.go | 303 +++++++++++++-------------------------- pkg/config/conversion.go | 3 +- pkg/config/defaulting.go | 132 +++++++++++++++++ pkg/config/validation.go | 189 ++++++++++++++++++++++++ 4 files changed, 424 insertions(+), 203 deletions(-) create mode 100644 pkg/config/defaulting.go create mode 100644 pkg/config/validation.go diff --git a/pkg/config/config.go b/pkg/config/config.go index 8043a3f..d42076d 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1,15 +1,97 @@ package config import ( - "errors" - "fmt" "os" - "regexp" - "strings" "gopkg.in/yaml.v3" + "k8s.io/apimachinery/pkg/util/sets" +) + +const ( + // WhoCanContactOwner + GroupOptionAllManagersCanContact = "ALL_MANAGERS_CAN_CONTACT" + GroupOptionAllMembersCanContact = "ALL_MEMBERS_CAN_CONTACT" + GroupOptionAllInDomainCanContact = "ALL_IN_DOMAIN_CAN_CONTACT" + GroupOptionAnyoneCanContact = "ANYONE_CAN_CONTACT" + GroupOptionWhoCanContactOwnerDefault = GroupOptionAllInDomainCanContact + + // WhoCanViewMembership + GroupOptionAllManagersCanViewMembership = "ALL_MANAGERS_CAN_VIEW" + GroupOptionAllMembersCanViewMembership = "ALL_MEMBERS_CAN_VIEW" + GroupOptionAllInDomainCanViewMembership = "ALL_IN_DOMAIN_CAN_VIEW" + GroupOptionWhoCanViewMembershipDefault = GroupOptionAllMembersCanViewMembership + + // WhoCanApproveMembers + GroupOptionAllManagersCanApproveMembers = "ALL_MANAGERS_CAN_APPROVE" + GroupOptionAllOwnersCanApproveMembers = "ALL_OWNERS_CAN_APPROVE" + GroupOptionAllMembersCanApproveMembers = "ALL_MEMBERS_CAN_APPROVE" + GroupOptionNoneCanApproveMembers = "NONE_CAN_APPROVE" + GroupOptionWhoCanApproveMembersDefault = GroupOptionAllManagersCanApproveMembers + + // WhoCanPostMessage + GroupOptionNoneCanPostMessage = "NONE_CAN_POST" + GroupOptionAllOwnersCanPostMessage = "ALL_OWNERS_CAN_POST" + GroupOptionAllManagersCanPostMessage = "ALL_MANAGERS_CAN_POST" + GroupOptionAllMembersCanPostMessage = "ALL_MEMBERS_CAN_POST" + GroupOptionAllInDomainCanPostMessage = "ALL_IN_DOMAIN_CAN_POST" + GroupOptionAnyoneCanPostMessage = "ANYONE_CAN_POST" + GroupOptionWhoCanPostMessageDefault = GroupOptionAllMembersCanPostMessage + + // WhoCanJoin + GroupOptionInvitedCanJoin = "INVITED_CAN_JOIN" + GroupOptionCanRequestToJoin = "CAN_REQUEST_TO_JOIN" + GroupOptionAllInDomainCanJoin = "ALL_IN_DOMAIN_CAN_JOIN" + GroupOptionAnyoneCanJoin = "ANYONE_CAN_JOIN" + GroupOptionWhoCanJoinDefault = GroupOptionInvitedCanJoin + + // membership roles + MemberRoleOwner = "OWNER" + MemberRoleManager = "MANAGER" + MemberRoleMember = "MEMBER" +) - "github.com/kubermatic-labs/gman/pkg/util" +var ( + allWhoCanContactOwnerOptions = sets.NewString( + GroupOptionAllManagersCanContact, + GroupOptionAllMembersCanContact, + GroupOptionAllInDomainCanContact, + GroupOptionAnyoneCanContact, + ) + + allWhoCanViewMembershipOptions = sets.NewString( + GroupOptionAllManagersCanViewMembership, + GroupOptionAllMembersCanViewMembership, + GroupOptionAllInDomainCanViewMembership, + ) + + allWhoCanApproveMembersOptions = sets.NewString( + GroupOptionAllManagersCanApproveMembers, + GroupOptionAllOwnersCanApproveMembers, + GroupOptionAllMembersCanApproveMembers, + GroupOptionNoneCanApproveMembers, + ) + + allWhoCanPostMessageOptions = sets.NewString( + GroupOptionNoneCanPostMessage, + GroupOptionAllOwnersCanPostMessage, + GroupOptionAllManagersCanPostMessage, + GroupOptionAllMembersCanPostMessage, + GroupOptionAllInDomainCanPostMessage, + GroupOptionAnyoneCanPostMessage, + ) + + allWhoCanJoinOptions = sets.NewString( + GroupOptionInvitedCanJoin, + GroupOptionCanRequestToJoin, + GroupOptionAllInDomainCanJoin, + GroupOptionAnyoneCanJoin, + ) + + allMemberRoles = sets.NewString( + MemberRoleOwner, + MemberRoleManager, + MemberRoleMember, + ) ) type Config struct { @@ -67,8 +149,8 @@ type Group struct { WhoCanApproveMembers string `yaml:"whoCanApproveMembers,omitempty"` WhoCanPostMessage string `yaml:"whoCanPostMessage,omitempty"` WhoCanJoin string `yaml:"whoCanJoin,omitempty"` - AllowExternalMembers bool `yaml:"allowExternalMembers"` - IsArchived bool `yaml:"isArchived"` + AllowExternalMembers bool `yaml:"allowExternalMembers,omitempty"` + IsArchived bool `yaml:"isArchived,omitempty"` Members []Member `yaml:"members,omitempty"` } @@ -85,9 +167,9 @@ type OrgUnit struct { } func LoadFromFile(filename string) (*Config, error) { - config := &Config{} // create config structure + config := &Config{} - f, err := os.Open(filename) // open file config + f, err := os.Open(filename) if err != nil { return nil, err } @@ -97,6 +179,11 @@ func LoadFromFile(filename string) (*Config, error) { return nil, err } + // apply default values + config.DefaultOrgUnits() + config.DefaultUsers() + config.DefaultGroups() + return config, nil } @@ -110,202 +197,14 @@ func SaveToFile(config *Config, filename string) error { encoder := yaml.NewEncoder(f) encoder.SetIndent(2) + // remove default values so we create a minimal config file + config.UndefaultOrgUnits() + config.UndefaultUsers() + config.UndefaultGroups() + if err := encoder.Encode(config); err != nil { return err } return nil } - -// validateEmailFormat is a helper function that checks for existance of '@' and the length of the address -func validateEmailFormat(email string) bool { - return (len(email) < 129 && strings.Contains(email, "@")) -} - -func (c *Config) ValidateUsers() []error { - var allTheErrors []error - re164 := regexp.MustCompile(`^\+[1-9]\d{1,14}$`) - - // validate organization - if c.Organization == "" { - allTheErrors = append(allTheErrors, errors.New("no organization configured")) - } - - //validate users - userEmails := []string{} - for _, user := range c.Users { - if util.StringSliceContains(userEmails, user.PrimaryEmail) { - allTheErrors = append(allTheErrors, fmt.Errorf("duplicate user defined (user: %s)", user.PrimaryEmail)) - } - - if user.PrimaryEmail == "" { - allTheErrors = append(allTheErrors, fmt.Errorf("primary email is required (user: %s)", user.LastName)) - } else { - if user.PrimaryEmail == user.SecondaryEmail { - allTheErrors = append(allTheErrors, fmt.Errorf("user has defined the same primary and secondary email (user: %s)", user.PrimaryEmail)) - } - if !validateEmailFormat(user.PrimaryEmail) { - allTheErrors = append(allTheErrors, fmt.Errorf("primary email is not a valid email-address (user: %s)", user.PrimaryEmail)) - } - } - - if user.FirstName == "" || user.LastName == "" { - allTheErrors = append(allTheErrors, fmt.Errorf("given and family names are required (user: %s)", user.PrimaryEmail)) - } - - if user.SecondaryEmail != "" { - if !validateEmailFormat(user.SecondaryEmail) { - allTheErrors = append(allTheErrors, fmt.Errorf("secondary email is not a valid email-address (user: %s)", user.PrimaryEmail)) - } - } - - if user.RecoveryEmail != "" { - if !validateEmailFormat(user.RecoveryEmail) { - allTheErrors = append(allTheErrors, fmt.Errorf("recovery email is not a valid email-address (user: %s)", user.PrimaryEmail)) - } - } - - if len(user.Aliases) > 0 { - for _, alias := range user.Aliases { - if !validateEmailFormat(alias) { - allTheErrors = append(allTheErrors, fmt.Errorf("alias email is not a valid email-address (user: %s)", user.PrimaryEmail)) - } - } - } - - if user.Employee.ManagerEmail != "" { - if !validateEmailFormat(user.Employee.ManagerEmail) { - allTheErrors = append(allTheErrors, fmt.Errorf("manager's email is not a valid email-address (user: %s)", user.PrimaryEmail)) - } - } - - if user.RecoveryPhone != "" { - if !re164.MatchString(user.RecoveryPhone) { - allTheErrors = append(allTheErrors, fmt.Errorf("invalid format of recovery phone (user: %s). The phone number must be in the E.164 format, starting with the plus sign (+). Example: +16506661212.", user.PrimaryEmail)) - } - } - - if len(user.Licenses) > 0 { - for _, license := range user.Licenses { - found := false - for _, permLicense := range AllLicenses { - if license == permLicense.Name { - found = true - } - } - if !found { - allTheErrors = append(allTheErrors, fmt.Errorf("wrong value specified for the user license (user: %s, license: %s)", user.PrimaryEmail, license)) - } - } - } - - userEmails = append(userEmails, user.PrimaryEmail) - } - - if allTheErrors != nil { - return allTheErrors - } - - return nil -} - -func (c *Config) ValidateGroups() []error { - var allTheErrors []error - - // validate organization - if c.Organization == "" { - allTheErrors = append(allTheErrors, errors.New("no organization configured")) - } - - // validate groups - groupEmails := []string{} - for _, group := range c.Groups { - if util.StringSliceContains(groupEmails, group.Email) { - allTheErrors = append(allTheErrors, fmt.Errorf("duplicate group email defined (%s)", group.Email)) - } - - if !validateEmailFormat(group.Email) { - allTheErrors = append(allTheErrors, fmt.Errorf("group email is not a valid email-address (%s)", group.Email)) - } - - if group.WhoCanContactOwner != "" { - if !(strings.Compare(group.WhoCanContactOwner, "ALL_IN_DOMAIN_CAN_CONTACT") == 0 || strings.Compare(group.WhoCanContactOwner, "ALL_MANAGERS_CAN_CONTACT") == 0 || strings.Compare(group.WhoCanContactOwner, "ALL_MEMBERS_CAN_CONTACT") == 0 || strings.Compare(group.WhoCanContactOwner, "ANYONE_CAN_CONTACT") == 0) { - allTheErrors = append(allTheErrors, fmt.Errorf("wrong value specified for 'who_can_contact_owner' field (group: %s). For the list of possible values, please refer to example config. Fields are case sensitive.", group.Name)) - } - } - - if group.WhoCanViewMembership != "" { - if !(strings.Compare(group.WhoCanViewMembership, "ALL_IN_DOMAIN_CAN_VIEW") == 0 || strings.Compare(group.WhoCanViewMembership, "ALL_MEMBERS_CAN_VIEW") == 0 || strings.Compare(group.WhoCanViewMembership, "ALL_MANAGERS_CAN_VIEW") == 0) { - allTheErrors = append(allTheErrors, fmt.Errorf("wrong value specified for 'who_can_view_members' field (group: %s)", group.Name)) - } - } - if group.WhoCanApproveMembers != "" { - if !(strings.Compare(group.WhoCanApproveMembers, "ALL_MEMBERS_CAN_APPROVE") == 0 || strings.Compare(group.WhoCanApproveMembers, "ALL_MANAGERS_CAN_APPROVE") == 0 || strings.Compare(group.WhoCanApproveMembers, "ALL_OWNERS_CAN_APPROVE") == 0 || strings.Compare(group.WhoCanApproveMembers, "NONE_CAN_APPROVE") == 0) { - allTheErrors = append(allTheErrors, fmt.Errorf("wrong value specified for 'who_can_approve_members' field (group: %s)", group.Name)) - } - } - - if group.WhoCanPostMessage != "" { - if !(strings.Compare(group.WhoCanPostMessage, "NONE_CAN_POST") == 0 || strings.Compare(group.WhoCanPostMessage, "ALL_MANAGERS_CAN_POST") == 0 || strings.Compare(group.WhoCanPostMessage, "ALL_MEMBERS_CAN_POST") == 0 || strings.Compare(group.WhoCanPostMessage, "ALL_OWNERS_CAN_POST") == 0 || strings.Compare(group.WhoCanPostMessage, "ALL_IN_DOMAIN_CAN_POST") == 0 || strings.Compare(group.WhoCanPostMessage, "ANYONE_CAN_POST") == 0) { - allTheErrors = append(allTheErrors, fmt.Errorf("wrong value specified for 'who_can_post' field (group: %s)", group.Name)) - } - } - if group.WhoCanJoin != "" { - if !(strings.Compare(group.WhoCanJoin, "CAN_REQUEST_TO_JOIN") == 0 || strings.Compare(group.WhoCanJoin, "INVITED_CAN_JOIN") == 0 || strings.Compare(group.WhoCanJoin, "ALL_IN_DOMAIN_CAN_JOIN") == 0 || strings.Compare(group.WhoCanJoin, "ANYONE_CAN_JOIN") == 0) { - allTheErrors = append(allTheErrors, fmt.Errorf("wrong value specified for 'who_can_contact_owner' field (group: %s)", group.Name)) - } - } - - memberEmails := []string{} - for _, member := range group.Members { - if util.StringSliceContains(memberEmails, member.Email) { - allTheErrors = append(allTheErrors, fmt.Errorf("duplicate member defined in a group (group: %s, member: %s)", group.Name, member.Email)) - } - - if !(strings.Compare(member.Role, "OWNER") == 0 || strings.Compare(member.Role, "MANAGER") == 0 || strings.Compare(member.Role, "MEMBER") == 0) { - allTheErrors = append(allTheErrors, fmt.Errorf("wrong member role specified (group: %s, member: %s). Permitted values are OWNER, MEMBER or MANAGER.", group.Name, member.Email)) - } - } - } - - if allTheErrors != nil { - return allTheErrors - } - - return nil -} - -func (c *Config) ValidateOrgUnits() []error { - var allTheErrors []error - - // validate organization - if c.Organization == "" { - allTheErrors = append(allTheErrors, errors.New("no organization configured")) - } - - // validate org_units - ouNames := []string{} - for _, ou := range c.OrgUnits { - if util.StringSliceContains(ouNames, ou.Name) { - allTheErrors = append(allTheErrors, fmt.Errorf("duplicate org unit defined (%s)", ou.Name)) - } - - if ou.Name == "" { - allTheErrors = append(allTheErrors, fmt.Errorf("'Name' is not specified (org unit %s)", ou.Name)) - } - - if ou.ParentOrgUnitPath == "" { - allTheErrors = append(allTheErrors, fmt.Errorf("'ParentOrgUnitPath' is not specified (org unit %s)", ou.Name)) - } else { - if ou.ParentOrgUnitPath[0] != '/' { - allTheErrors = append(allTheErrors, fmt.Errorf("'ParentOrgUnitPath' must start with a slash (org unit %s)", ou.Name)) - } - } - } - - if allTheErrors != nil { - return allTheErrors - } - - return nil -} diff --git a/pkg/config/conversion.go b/pkg/config/conversion.go index c3296f4..952598a 100644 --- a/pkg/config/conversion.go +++ b/pkg/config/conversion.go @@ -4,9 +4,10 @@ import ( "fmt" "strconv" - "github.com/kubermatic-labs/gman/pkg/util" directoryv1 "google.golang.org/api/admin/directory/v1" groupssettingsv1 "google.golang.org/api/groupssettings/v1" + + "github.com/kubermatic-labs/gman/pkg/util" ) func ToGSuiteUser(user *User) *directoryv1.User { diff --git a/pkg/config/defaulting.go b/pkg/config/defaulting.go new file mode 100644 index 0000000..3b26dff --- /dev/null +++ b/pkg/config/defaulting.go @@ -0,0 +1,132 @@ +package config + +import ( + "strings" +) + +func (c *Config) DefaultUsers() error { + for idx, user := range c.Users { + if user.OrgUnitPath == "" { + user.OrgUnitPath = "/" + } + + c.Users[idx] = user + } + + return nil +} + +func (c *Config) UndefaultUsers() error { + for idx, user := range c.Users { + if user.OrgUnitPath == "/" { + user.OrgUnitPath = "" + } + + c.Users[idx] = user + } + + return nil +} + +func (c *Config) DefaultGroups() error { + for idx, group := range c.Groups { + if group.WhoCanJoin == "" { + group.WhoCanJoin = GroupOptionWhoCanJoinDefault + } + + if group.WhoCanPostMessage == "" { + group.WhoCanPostMessage = GroupOptionWhoCanPostMessageDefault + } + + if group.WhoCanApproveMembers == "" { + group.WhoCanApproveMembers = GroupOptionWhoCanApproveMembersDefault + } + + if group.WhoCanContactOwner == "" { + group.WhoCanContactOwner = GroupOptionWhoCanContactOwnerDefault + } + + if group.WhoCanViewMembership == "" { + group.WhoCanViewMembership = GroupOptionWhoCanViewMembershipDefault + } + + group.WhoCanJoin = strings.ToUpper(group.WhoCanJoin) + group.WhoCanPostMessage = strings.ToUpper(group.WhoCanPostMessage) + group.WhoCanApproveMembers = strings.ToUpper(group.WhoCanApproveMembers) + group.WhoCanContactOwner = strings.ToUpper(group.WhoCanContactOwner) + group.WhoCanViewMembership = strings.ToUpper(group.WhoCanViewMembership) + + for n, member := range group.Members { + if member.Role == "" { + member.Role = MemberRoleMember + } + + member.Role = strings.ToUpper(member.Role) + group.Members[n] = member + } + + c.Groups[idx] = group + } + + return nil +} + +func (c *Config) UndefaultGroups() error { + for idx, group := range c.Groups { + if group.WhoCanJoin == GroupOptionWhoCanJoinDefault { + group.WhoCanJoin = "" + } + + if group.WhoCanPostMessage == GroupOptionWhoCanPostMessageDefault { + group.WhoCanPostMessage = "" + } + + if group.WhoCanApproveMembers == GroupOptionWhoCanApproveMembersDefault { + group.WhoCanApproveMembers = "" + } + + if group.WhoCanContactOwner == GroupOptionWhoCanContactOwnerDefault { + group.WhoCanContactOwner = "" + } + + if group.WhoCanViewMembership == GroupOptionWhoCanViewMembershipDefault { + group.WhoCanViewMembership = "" + } + + for n, member := range group.Members { + if member.Role == MemberRoleMember { + member.Role = "" + } + + group.Members[n] = member + } + + c.Groups[idx] = group + } + + return nil +} + +func (c *Config) DefaultOrgUnits() error { + for idx, orgUnit := range c.OrgUnits { + if orgUnit.ParentOrgUnitPath == "" { + orgUnit.ParentOrgUnitPath = "/" + } + + c.OrgUnits[idx] = orgUnit + } + + return nil +} + +func (c *Config) UndefaultOrgUnits() error { + for idx, orgUnit := range c.OrgUnits { + if orgUnit.ParentOrgUnitPath == "/" { + orgUnit.ParentOrgUnitPath = "" + } + + c.OrgUnits[idx] = orgUnit + } + + return nil +} diff --git a/pkg/config/validation.go b/pkg/config/validation.go new file mode 100644 index 0000000..492dd1b --- /dev/null +++ b/pkg/config/validation.go @@ -0,0 +1,189 @@ +package config + +import ( + "errors" + "fmt" + "regexp" + "strings" + + "github.com/kubermatic-labs/gman/pkg/util" +) + +// validateEmailFormat is a helper function that checks for existance of '@' and the length of the address +func validateEmailFormat(email string) bool { + return len(email) < 129 && strings.Contains(email, "@") +} + +func (c *Config) ValidateUsers() []error { + var allErrors []error + re164 := regexp.MustCompile(`^\+[1-9]\d{1,14}$`) + + // validate organization + if c.Organization == "" { + allErrors = append(allErrors, errors.New("no organization configured")) + } + + // validate users + userEmails := []string{} + for _, user := range c.Users { + if util.StringSliceContains(userEmails, user.PrimaryEmail) { + allErrors = append(allErrors, fmt.Errorf("duplicate user defined (user: %s)", user.PrimaryEmail)) + } + + if user.PrimaryEmail == "" { + allErrors = append(allErrors, fmt.Errorf("primary email is required (user: %s)", user.LastName)) + } else { + if user.PrimaryEmail == user.SecondaryEmail { + allErrors = append(allErrors, fmt.Errorf("user has defined the same primary and secondary email (user: %s)", user.PrimaryEmail)) + } + if !validateEmailFormat(user.PrimaryEmail) { + allErrors = append(allErrors, fmt.Errorf("primary email is not a valid email-address (user: %s)", user.PrimaryEmail)) + } + } + + if user.FirstName == "" || user.LastName == "" { + allErrors = append(allErrors, fmt.Errorf("given and family names are required (user: %s)", user.PrimaryEmail)) + } + + if user.SecondaryEmail != "" && !validateEmailFormat(user.SecondaryEmail) { + allErrors = append(allErrors, fmt.Errorf("secondary email is not a valid email-address (user: %s)", user.PrimaryEmail)) + } + + if user.RecoveryEmail != "" && !validateEmailFormat(user.RecoveryEmail) { + allErrors = append(allErrors, fmt.Errorf("recovery email is not a valid email-address (user: %s)", user.PrimaryEmail)) + } + + if user.Employee.ManagerEmail != "" && !validateEmailFormat(user.Employee.ManagerEmail) { + allErrors = append(allErrors, fmt.Errorf("manager's email is not a valid email-address (user: %s)", user.PrimaryEmail)) + } + + if user.RecoveryPhone != "" && !re164.MatchString(user.RecoveryPhone) { + allErrors = append(allErrors, fmt.Errorf("invalid format of recovery phone (user: %s). The phone number must be in the E.164 format, starting with the plus sign (+). Example: +16506661212.", user.PrimaryEmail)) + } + + if len(user.Aliases) > 0 { + for _, alias := range user.Aliases { + if !validateEmailFormat(alias) { + allErrors = append(allErrors, fmt.Errorf("alias email is not a valid email-address (user: %s)", user.PrimaryEmail)) + } + } + } + + if len(user.Licenses) > 0 { + for _, license := range user.Licenses { + found := false + for _, permLicense := range AllLicenses { + if license == permLicense.Name { + found = true + } + } + if !found { + allErrors = append(allErrors, fmt.Errorf("wrong value specified for the user license (user: %s, license: %s)", user.PrimaryEmail, license)) + } + } + } + + userEmails = append(userEmails, user.PrimaryEmail) + } + + return allErrors +} + +func (c *Config) ValidateGroups() []error { + var allErrors []error + + // validate organization + if c.Organization == "" { + allErrors = append(allErrors, errors.New("no organization configured")) + } + + // validate groups + groupEmails := []string{} + for _, group := range c.Groups { + if util.StringSliceContains(groupEmails, group.Email) { + allErrors = append(allErrors, fmt.Errorf("[group: %s] duplicate group email defined", group.Email)) + } + + if !validateEmailFormat(group.Email) { + allErrors = append(allErrors, fmt.Errorf("[group: %s] group email is not a valid email address", group.Email)) + } + + if group.WhoCanContactOwner != "" { + if !allWhoCanContactOwnerOptions.Has(strings.ToUpper(group.WhoCanContactOwner)) { + allErrors = append(allErrors, fmt.Errorf("[group: %s] invalid value specified for 'whoCanContactOwner' field, must be one of %v", group.Name, allWhoCanContactOwnerOptions.List())) + } + } + + if group.WhoCanViewMembership != "" { + if !allWhoCanViewMembershipOptions.Has(strings.ToUpper(group.WhoCanViewMembership)) { + allErrors = append(allErrors, fmt.Errorf("[group: %s] invalid value specified for 'whoCanViewMembers' field, must be one of %v", group.Name, allWhoCanViewMembershipOptions.List())) + } + } + + if group.WhoCanApproveMembers != "" { + if !allWhoCanApproveMembersOptions.Has(strings.ToUpper(group.WhoCanApproveMembers)) { + allErrors = append(allErrors, fmt.Errorf("[group: %s] invalid value specified for 'whoCanApproveMembers' field, must be one of %v", group.Name, allWhoCanApproveMembersOptions.List())) + } + } + + if group.WhoCanPostMessage != "" { + if !allWhoCanPostMessageOptions.Has(strings.ToUpper(group.WhoCanPostMessage)) { + allErrors = append(allErrors, fmt.Errorf("[group: %s] invalid value specified for 'whoCanPostMessage' field, must be one of %v", group.Name, allWhoCanPostMessageOptions.List())) + } + } + + if group.WhoCanJoin != "" { + if !allWhoCanJoinOptions.Has(strings.ToUpper(group.WhoCanJoin)) { + allErrors = append(allErrors, fmt.Errorf("[group: %s] invalid value specified for 'whoCanJoin' field, must be one of %v", group.Name, allWhoCanJoinOptions.List())) + } + } + + memberEmails := []string{} + for _, member := range group.Members { + if util.StringSliceContains(memberEmails, member.Email) { + allErrors = append(allErrors, fmt.Errorf("[group: %s] duplicate member %q defined", group.Name, member.Email)) + } + + // default role to Member + role := strings.ToUpper(member.Role) + if role == "" { + role = MemberRoleMember + } + + if !allMemberRoles.Has(role) { + allErrors = append(allErrors, fmt.Errorf("[group: %s] invalid member role specified for %q, must be one of %v", group.Name, member.Email, allMemberRoles.List())) + } + } + } + + return allErrors +} + +func (c *Config) ValidateOrgUnits() []error { + var allErrors []error + + // validate organization + if c.Organization == "" { + allErrors = append(allErrors, errors.New("no organization configured")) + } + + // validate org units + unitNames := []string{} + for _, orgUnit := range c.OrgUnits { + if util.StringSliceContains(unitNames, orgUnit.Name) { + allErrors = append(allErrors, fmt.Errorf("[org unit: %s] duplicate org unit defined", orgUnit.Name)) + } + + if orgUnit.Name == "" { + allErrors = append(allErrors, fmt.Errorf("[org unit: %s] no name specified", orgUnit.Name)) + } + + if orgUnit.ParentOrgUnitPath == "" { + allErrors = append(allErrors, fmt.Errorf("[org unit: %s] no parentOrgUnitPath specified", orgUnit.Name)) + } else if !strings.HasPrefix(orgUnit.ParentOrgUnitPath, "/") { + allErrors = append(allErrors, fmt.Errorf("[org unit: %s] parentOrgUnitPath must start with a slash", orgUnit.Name)) + } + } + + return allErrors +} From 8cbd36932406515201bb5dc8cb2cf5aa47ed688e Mon Sep 17 00:00:00 2001 From: Christoph Mewes Date: Tue, 12 Jan 2021 18:12:44 +0100 Subject: [PATCH 08/18] add missing boilerplate --- .goreleaser.yml | 14 ++++++++++++++ hack/ci/release-docker.sh | 2 +- hack/ci/release-github.sh | 14 ++++++++++++++ main.go | 16 ++++++++++++++++ pkg/config/config.go | 16 ++++++++++++++++ pkg/config/conversion.go | 16 ++++++++++++++++ pkg/config/defaulting.go | 16 ++++++++++++++++ pkg/config/licenses.go | 16 ++++++++++++++++ pkg/config/validation.go | 16 ++++++++++++++++ pkg/export/export.go | 16 ++++++++++++++++ pkg/glib/directory.go | 16 ++++++++++++++++ pkg/glib/directory_groups.go | 16 ++++++++++++++++ pkg/glib/directory_orgunits.go | 16 ++++++++++++++++ pkg/glib/directory_users.go | 16 ++++++++++++++++ pkg/glib/groupssettings.go | 16 ++++++++++++++++ pkg/glib/licensing.go | 16 ++++++++++++++++ pkg/sync/compare.go | 16 ++++++++++++++++ pkg/sync/groups.go | 16 ++++++++++++++++ pkg/sync/licensing.go | 16 ++++++++++++++++ pkg/sync/orgunits.go | 16 ++++++++++++++++ pkg/sync/users.go | 16 ++++++++++++++++ pkg/util/util.go | 16 ++++++++++++++++ 22 files changed, 333 insertions(+), 1 deletion(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index 13188a6..0f5cd7c 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,3 +1,17 @@ +# Copyright 2021 The Kubermatic Kubernetes Platform contributors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + project_name: gman builds: diff --git a/hack/ci/release-docker.sh b/hack/ci/release-docker.sh index bb346e0..8514b6a 100755 --- a/hack/ci/release-docker.sh +++ b/hack/ci/release-docker.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Copyright 2020 The Kubermatic Kubernetes Platform contributors. +# Copyright 2021 The Kubermatic Kubernetes Platform contributors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/hack/ci/release-github.sh b/hack/ci/release-github.sh index 3ebee83..11252ef 100755 --- a/hack/ci/release-github.sh +++ b/hack/ci/release-github.sh @@ -1,5 +1,19 @@ #!/usr/bin/env bash +# Copyright 2021 The Kubermatic Kubernetes Platform contributors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + # This script is creating release binaries and # Docker images via goreleaser. It's meant to # run in the Kubermatic CI environment only, diff --git a/main.go b/main.go index 3a3edd1..1a9e4c1 100644 --- a/main.go +++ b/main.go @@ -1,3 +1,19 @@ +/* +Copyright 2021 The Kubermatic Kubernetes Platform contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + package main import ( diff --git a/pkg/config/config.go b/pkg/config/config.go index d42076d..4c1727a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1,3 +1,19 @@ +/* +Copyright 2021 The Kubermatic Kubernetes Platform contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + package config import ( diff --git a/pkg/config/conversion.go b/pkg/config/conversion.go index 952598a..9da59df 100644 --- a/pkg/config/conversion.go +++ b/pkg/config/conversion.go @@ -1,3 +1,19 @@ +/* +Copyright 2021 The Kubermatic Kubernetes Platform contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + package config import ( diff --git a/pkg/config/defaulting.go b/pkg/config/defaulting.go index 3b26dff..80acf30 100644 --- a/pkg/config/defaulting.go +++ b/pkg/config/defaulting.go @@ -1,3 +1,19 @@ +/* +Copyright 2021 The Kubermatic Kubernetes Platform contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + package config import ( diff --git a/pkg/config/licenses.go b/pkg/config/licenses.go index 5d06919..812af18 100644 --- a/pkg/config/licenses.go +++ b/pkg/config/licenses.go @@ -1,3 +1,19 @@ +/* +Copyright 2021 The Kubermatic Kubernetes Platform contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + package config type License struct { diff --git a/pkg/config/validation.go b/pkg/config/validation.go index 492dd1b..5015476 100644 --- a/pkg/config/validation.go +++ b/pkg/config/validation.go @@ -1,3 +1,19 @@ +/* +Copyright 2021 The Kubermatic Kubernetes Platform contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + package config import ( diff --git a/pkg/export/export.go b/pkg/export/export.go index 3960e85..3f409f6 100644 --- a/pkg/export/export.go +++ b/pkg/export/export.go @@ -1,3 +1,19 @@ +/* +Copyright 2021 The Kubermatic Kubernetes Platform contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + package export import ( diff --git a/pkg/glib/directory.go b/pkg/glib/directory.go index a6d7dc4..ba11c9b 100644 --- a/pkg/glib/directory.go +++ b/pkg/glib/directory.go @@ -1,3 +1,19 @@ +/* +Copyright 2021 The Kubermatic Kubernetes Platform contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + // Package glib contains methods for interactions with GSuite API package glib diff --git a/pkg/glib/directory_groups.go b/pkg/glib/directory_groups.go index 1782803..2d2116e 100644 --- a/pkg/glib/directory_groups.go +++ b/pkg/glib/directory_groups.go @@ -1,3 +1,19 @@ +/* +Copyright 2021 The Kubermatic Kubernetes Platform contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + // Package glib contains methods for interactions with GSuite API package glib diff --git a/pkg/glib/directory_orgunits.go b/pkg/glib/directory_orgunits.go index 312faba..61e3e0c 100644 --- a/pkg/glib/directory_orgunits.go +++ b/pkg/glib/directory_orgunits.go @@ -1,3 +1,19 @@ +/* +Copyright 2021 The Kubermatic Kubernetes Platform contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + // Package glib contains methods for interactions with GSuite API package glib diff --git a/pkg/glib/directory_users.go b/pkg/glib/directory_users.go index b6d269b..74b3a3c 100644 --- a/pkg/glib/directory_users.go +++ b/pkg/glib/directory_users.go @@ -1,3 +1,19 @@ +/* +Copyright 2021 The Kubermatic Kubernetes Platform contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + // Package glib contains methods for interactions with GSuite API package glib diff --git a/pkg/glib/groupssettings.go b/pkg/glib/groupssettings.go index 18f53e7..539b46c 100644 --- a/pkg/glib/groupssettings.go +++ b/pkg/glib/groupssettings.go @@ -1,3 +1,19 @@ +/* +Copyright 2021 The Kubermatic Kubernetes Platform contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + // Package glib contains methods for interactions with GSuite API package glib diff --git a/pkg/glib/licensing.go b/pkg/glib/licensing.go index ad7eb80..12f7fb8 100644 --- a/pkg/glib/licensing.go +++ b/pkg/glib/licensing.go @@ -1,3 +1,19 @@ +/* +Copyright 2021 The Kubermatic Kubernetes Platform contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + // Package glib contains methods for interactions with GSuite API package glib diff --git a/pkg/sync/compare.go b/pkg/sync/compare.go index df6eb12..cf582b6 100644 --- a/pkg/sync/compare.go +++ b/pkg/sync/compare.go @@ -1,3 +1,19 @@ +/* +Copyright 2021 The Kubermatic Kubernetes Platform contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + package sync import ( diff --git a/pkg/sync/groups.go b/pkg/sync/groups.go index 46b85f6..8f3132f 100644 --- a/pkg/sync/groups.go +++ b/pkg/sync/groups.go @@ -1,3 +1,19 @@ +/* +Copyright 2021 The Kubermatic Kubernetes Platform contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + package sync import ( diff --git a/pkg/sync/licensing.go b/pkg/sync/licensing.go index a22fb5f..42f5cdd 100644 --- a/pkg/sync/licensing.go +++ b/pkg/sync/licensing.go @@ -1,3 +1,19 @@ +/* +Copyright 2021 The Kubermatic Kubernetes Platform contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + package sync import ( diff --git a/pkg/sync/orgunits.go b/pkg/sync/orgunits.go index 13f9837..04aff4f 100644 --- a/pkg/sync/orgunits.go +++ b/pkg/sync/orgunits.go @@ -1,3 +1,19 @@ +/* +Copyright 2021 The Kubermatic Kubernetes Platform contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + package sync import ( diff --git a/pkg/sync/users.go b/pkg/sync/users.go index a785ef2..8277430 100644 --- a/pkg/sync/users.go +++ b/pkg/sync/users.go @@ -1,3 +1,19 @@ +/* +Copyright 2021 The Kubermatic Kubernetes Platform contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + package sync import ( diff --git a/pkg/util/util.go b/pkg/util/util.go index d5f159d..778a205 100644 --- a/pkg/util/util.go +++ b/pkg/util/util.go @@ -1,3 +1,19 @@ +/* +Copyright 2021 The Kubermatic Kubernetes Platform contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + package util import ( From 6d965471015692e7533e9e58a7cc1c25905ea48f Mon Sep 17 00:00:00 2001 From: Christoph Mewes Date: Wed, 13 Jan 2021 01:42:13 +0100 Subject: [PATCH 09/18] fix org unit sync --- pkg/glib/directory_orgunits.go | 12 ++---------- pkg/sync/groups.go | 2 +- pkg/sync/orgunits.go | 6 +++--- pkg/sync/users.go | 2 +- 4 files changed, 7 insertions(+), 15 deletions(-) diff --git a/pkg/glib/directory_orgunits.go b/pkg/glib/directory_orgunits.go index 61e3e0c..6b311cc 100644 --- a/pkg/glib/directory_orgunits.go +++ b/pkg/glib/directory_orgunits.go @@ -51,16 +51,8 @@ func (ds *DirectoryService) DeleteOrgUnit(ctx context.Context, orgUnit *director return nil } -func (ds *DirectoryService) UpdateOrgUnit(ctx context.Context, orgUnit *directoryv1.OrgUnit) error { - // to update, we need the org unit's ID or its path; - // we possibly have neither, but since the path is always just "{parent}/{orgunit-name}", - // we can construct it (there is no encoding/escaping in the paths, amazingly) - path := orgUnit.OrgUnitPath - if path == "" { - path = orgUnit.ParentOrgUnitPath + "/" + orgUnit.Name - } - - if _, err := ds.Orgunits.Update("my_customer", path, orgUnit).Context(ctx).Do(); err != nil { +func (ds *DirectoryService) UpdateOrgUnit(ctx context.Context, oldUnit *directoryv1.OrgUnit, newUnit *directoryv1.OrgUnit) error { + if _, err := ds.Orgunits.Update("my_customer", oldUnit.OrgUnitId, newUnit).Context(ctx).Do(); err != nil { return fmt.Errorf("unable to update org unit: %v", err) } diff --git a/pkg/sync/groups.go b/pkg/sync/groups.go index 8f3132f..629e3d4 100644 --- a/pkg/sync/groups.go +++ b/pkg/sync/groups.go @@ -93,7 +93,7 @@ func SyncGroups( } if !found { - log.Printf(" ✁ %s", liveGroup.Email) + log.Printf(" - %s", liveGroup.Email) if confirm { if err := directorySrv.DeleteGroup(ctx, liveGroup); err != nil { diff --git a/pkg/sync/orgunits.go b/pkg/sync/orgunits.go index 04aff4f..53e9b19 100644 --- a/pkg/sync/orgunits.go +++ b/pkg/sync/orgunits.go @@ -59,8 +59,8 @@ func SyncOrgUnits( log.Printf(" ✎ %s", expectedOrgUnit.Name) if confirm { - apiOrgUnit := config.ToGSuiteOrgUnit(&expectedOrgUnit) - if err := directorySrv.UpdateOrgUnit(ctx, apiOrgUnit); err != nil { + newOrgUnit := config.ToGSuiteOrgUnit(&expectedOrgUnit) + if err := directorySrv.UpdateOrgUnit(ctx, liveOrgUnit, newOrgUnit); err != nil { return fmt.Errorf("failed to update org unit: %v", err) } } @@ -71,7 +71,7 @@ func SyncOrgUnits( } if !found { - log.Printf(" ✁ %s", liveOrgUnit.Name) + log.Printf(" - %s", liveOrgUnit.Name) if confirm { err := directorySrv.DeleteOrgUnit(ctx, liveOrgUnit) diff --git a/pkg/sync/users.go b/pkg/sync/users.go index 8277430..04b0b97 100644 --- a/pkg/sync/users.go +++ b/pkg/sync/users.go @@ -91,7 +91,7 @@ func SyncUsers( } if !found { - log.Printf(" ✁ %s", liveUser.PrimaryEmail) + log.Printf(" - %s", liveUser.PrimaryEmail) if confirm { if err := directorySrv.DeleteUser(ctx, liveUser); err != nil { From 0229bfe6064786fdbfd48e76cbaaca61cd07f34d Mon Sep 17 00:00:00 2001 From: Christoph Mewes Date: Wed, 13 Jan 2021 01:45:01 +0100 Subject: [PATCH 10/18] make group sync work --- pkg/glib/directory_groups.go | 16 ++++++++-------- pkg/sync/groups.go | 34 ++++++++++++++++++++-------------- 2 files changed, 28 insertions(+), 22 deletions(-) diff --git a/pkg/glib/directory_groups.go b/pkg/glib/directory_groups.go index 2d2116e..ce91072 100644 --- a/pkg/glib/directory_groups.go +++ b/pkg/glib/directory_groups.go @@ -52,7 +52,7 @@ func (ds *DirectoryService) ListGroups(ctx context.Context) ([]*directoryv1.Grou func (ds *DirectoryService) CreateGroup(ctx context.Context, group *directoryv1.Group) (*directoryv1.Group, error) { updatedGroup, err := ds.Groups.Insert(group).Context(ctx).Do() if err != nil { - return nil, fmt.Errorf("unable to create group: %v", err) + return nil, err } return updatedGroup, nil @@ -61,17 +61,17 @@ func (ds *DirectoryService) CreateGroup(ctx context.Context, group *directoryv1. // DeleteGroup deletes a group in GSuite via their API func (ds *DirectoryService) DeleteGroup(ctx context.Context, group *directoryv1.Group) error { if err := ds.Groups.Delete(group.Email).Context(ctx).Do(); err != nil { - return fmt.Errorf("unable to delete group: %v", err) + return err } return nil } // UpdateGroup updates the remote group with config -func (ds *DirectoryService) UpdateGroup(ctx context.Context, group *directoryv1.Group) (*directoryv1.Group, error) { - updatedGroup, err := ds.Groups.Update(group.Email, group).Context(ctx).Do() +func (ds *DirectoryService) UpdateGroup(ctx context.Context, oldGroup *directoryv1.Group, newGroup *directoryv1.Group) (*directoryv1.Group, error) { + updatedGroup, err := ds.Groups.Update(oldGroup.Email, newGroup).Context(ctx).Do() if err != nil { - return nil, fmt.Errorf("unable to update a group: %v", err) + return nil, err } return updatedGroup, nil @@ -104,7 +104,7 @@ func (ds *DirectoryService) ListMembers(ctx context.Context, group *directoryv1. // AddNewMember adds a new member to a group in GSuite func (ds *DirectoryService) AddNewMember(ctx context.Context, group *directoryv1.Group, member *directoryv1.Member) error { if _, err := ds.Members.Insert(group.Email, member).Context(ctx).Do(); err != nil { - return fmt.Errorf("unable to add member to group: %v", err) + return err } return nil @@ -113,7 +113,7 @@ func (ds *DirectoryService) AddNewMember(ctx context.Context, group *directoryv1 // RemoveMember removes a member from a group in Gsuite func (ds *DirectoryService) RemoveMember(ctx context.Context, group *directoryv1.Group, member *directoryv1.Member) error { if err := ds.Members.Delete(group.Email, member.Email).Context(ctx).Do(); err != nil { - return fmt.Errorf("unable to delete member from group: %v", err) + return err } return nil @@ -122,7 +122,7 @@ func (ds *DirectoryService) RemoveMember(ctx context.Context, group *directoryv1 // UpdateMembership changes the role of the member func (ds *DirectoryService) UpdateMembership(ctx context.Context, group *directoryv1.Group, member *directoryv1.Member) error { if _, err := ds.Members.Update(group.Email, member.Email, member).Context(ctx).Do(); err != nil { - return fmt.Errorf("unable to update member in group: %v", err) + return err } return nil diff --git a/pkg/sync/groups.go b/pkg/sync/groups.go index 629e3d4..5033077 100644 --- a/pkg/sync/groups.go +++ b/pkg/sync/groups.go @@ -34,12 +34,14 @@ func SyncGroups( groupsSettingsSrv *glib.GroupsSettingsService, cfg *config.Config, confirm bool, -) error { +) (bool, error) { + changes := false + log.Println("⇄ Syncing groups…") liveGroups, err := directorySrv.ListGroups(ctx) if err != nil { - return err + return changes, err } liveGroupEmails := sets.NewString() @@ -55,12 +57,12 @@ func SyncGroups( liveMembers, err := directorySrv.ListMembers(ctx, liveGroup) if err != nil { - return fmt.Errorf("failed to fetch members: %v", err) + return changes, fmt.Errorf("failed to fetch members: %v", err) } liveSettings, err := groupsSettingsSrv.GetSettings(ctx, liveGroup.Email) if err != nil { - return fmt.Errorf("failed to fetch group settings: %v", err) + return changes, fmt.Errorf("failed to fetch group settings: %v", err) } if groupUpToDate(expectedGroup, liveGroup, liveMembers, liveSettings) { @@ -68,23 +70,24 @@ func SyncGroups( log.Printf(" ✓ %s", expectedGroup.Email) } else { // update it + changes = true log.Printf(" ✎ %s", expectedGroup.Email) group, settings := config.ToGSuiteGroup(&expectedGroup) if confirm { - group, err = directorySrv.UpdateGroup(ctx, group) + group, err = directorySrv.UpdateGroup(ctx, liveGroup, group) if err != nil { - return fmt.Errorf("failed to update group: %v", err) + return changes, fmt.Errorf("failed to update group: %v", err) } if _, err := groupsSettingsSrv.UpdateSettings(ctx, group, settings); err != nil { - return fmt.Errorf("failed to update group settings: %v", err) + return changes, fmt.Errorf("failed to update group settings: %v", err) } } if err := syncGroupMembers(ctx, directorySrv, &expectedGroup, group, liveMembers, confirm); err != nil { - return fmt.Errorf("failed to sync members: %v", err) + return changes, fmt.Errorf("failed to sync members: %v", err) } } @@ -93,11 +96,12 @@ func SyncGroups( } if !found { + changes = true log.Printf(" - %s", liveGroup.Email) if confirm { if err := directorySrv.DeleteGroup(ctx, liveGroup); err != nil { - return fmt.Errorf("failed to delete group: %v", err) + return changes, fmt.Errorf("failed to delete group: %v", err) } } } @@ -105,27 +109,29 @@ func SyncGroups( for _, expectedGroup := range cfg.Groups { if !liveGroupEmails.Has(expectedGroup.Email) { - group, settings := config.ToGSuiteGroup(&expectedGroup) + changes = true log.Printf(" + %s", expectedGroup.Email) + group, settings := config.ToGSuiteGroup(&expectedGroup) + if confirm { group, err = directorySrv.CreateGroup(ctx, group) if err != nil { - return fmt.Errorf("failed to create group: %v", err) + return changes, fmt.Errorf("failed to create group: %v", err) } if _, err := groupsSettingsSrv.UpdateSettings(ctx, group, settings); err != nil { - return fmt.Errorf("failed to update group settings: %v", err) + return changes, fmt.Errorf("failed to update group settings: %v", err) } } if err := syncGroupMembers(ctx, directorySrv, &expectedGroup, group, nil, confirm); err != nil { - return fmt.Errorf("failed to sync members: %v", err) + return changes, fmt.Errorf("failed to sync members: %v", err) } } } - return nil + return changes, nil } func getConfiguredMember(group *config.Group, member *directoryv1.Member) *config.Member { From 0eb0d66297ef1cd8d9fea42493dfd13a56b4a2c2 Mon Sep 17 00:00:00 2001 From: Christoph Mewes Date: Wed, 13 Jan 2021 16:21:19 +0100 Subject: [PATCH 11/18] fix user sync --- main.go | 24 ++++++++------- pkg/config/config.go | 54 +++++++++++++++++++++------------- pkg/config/conversion.go | 48 +++++++++++++++--------------- pkg/config/defaulting.go | 25 ++++++++++++++++ pkg/config/validation.go | 13 ++------ pkg/export/export.go | 13 -------- pkg/glib/directory.go | 1 - pkg/glib/directory_groups.go | 6 ++-- pkg/glib/directory_orgunits.go | 10 +++---- pkg/glib/directory_users.go | 15 +++++----- pkg/glib/doc.go | 18 ++++++++++++ pkg/glib/groupssettings.go | 5 ++-- pkg/glib/licensing.go | 17 ++++++++--- pkg/sync/compare.go | 51 ++++++++++++++++++++------------ pkg/sync/licensing.go | 2 +- pkg/sync/users.go | 29 ++++++++++-------- 16 files changed, 195 insertions(+), 136 deletions(-) create mode 100644 pkg/glib/doc.go diff --git a/main.go b/main.go index 1a9e4c1..1962c40 100644 --- a/main.go +++ b/main.go @@ -151,25 +151,29 @@ func syncAction( licensingSrv *glib.LicensingService, groupsSettingsSrv *glib.GroupsSettingsService, ) { - log.Println("► Updating org units…") - if err := sync.SyncOrgUnits(ctx, directorySrv, opt.orgUnitsConfig, opt.confirm); err != nil { - log.Fatalf("⚠ Failed to sync: %v.", err) - } + // log.Println("► Updating org units…") + // if err := sync.SyncOrgUnits(ctx, directorySrv, opt.orgUnitsConfig, opt.confirm); err != nil { + // log.Fatalf("⚠ Failed to sync: %v.", err) + // } log.Println("► Updating users…") - if err := sync.SyncUsers(ctx, directorySrv, licensingSrv, opt.usersConfig, opt.licenseStatus, opt.confirm); err != nil { + userChanges, err := sync.SyncUsers(ctx, directorySrv, licensingSrv, opt.usersConfig, opt.licenseStatus, opt.confirm) + if err != nil { log.Fatalf("⚠ Failed to sync: %v.", err) } - log.Println("► Updating groups…") - if err := sync.SyncGroups(ctx, directorySrv, groupsSettingsSrv, opt.groupsConfig, opt.confirm); err != nil { - log.Fatalf("⚠ Failed to sync: %v.", err) - } + // log.Println("► Updating groups…") + // groupChanges, err := sync.SyncGroups(ctx, directorySrv, groupsSettingsSrv, opt.groupsConfig, opt.confirm) + // if err != nil { + // log.Fatalf("⚠ Failed to sync: %v.", err) + // } if opt.confirm { log.Println("✓ Organization successfully synchronized.") - } else { + } else if userChanges /* || groupChanges */ { log.Println("⚠ Run again with -confirm to apply the changes above.") + } else { + log.Println("✓ No changes necessary, organization is in sync.") } } diff --git a/pkg/config/config.go b/pkg/config/config.go index 4c1727a..be5c1da 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -18,6 +18,7 @@ package config import ( "os" + "sort" "gopkg.in/yaml.v3" "k8s.io/apimachinery/pkg/util/sets" @@ -117,20 +118,32 @@ type Config struct { Groups []Group `yaml:"groups,omitempty"` } +type OrgUnit struct { + Name string `yaml:"name"` + Description string `yaml:"description,omitempty"` + ParentOrgUnitPath string `yaml:"parentOrgUnitPath,omitempty"` + BlockInheritance bool `yaml:"blockInheritance,omitempty"` +} + type User struct { - FirstName string `yaml:"givenName"` - LastName string `yaml:"familyName"` - PrimaryEmail string `yaml:"primaryEmail"` - SecondaryEmail string `yaml:"secondaryEmail,omitempty"` - Aliases []string `yaml:"aliases,omitempty"` - Phones []string `yaml:"phones,omitempty"` - RecoveryPhone string `yaml:"recoveryPhone,omitempty"` - RecoveryEmail string `yaml:"recoveryEmail,omitempty"` - OrgUnitPath string `yaml:"orgUnitPath,omitempty"` - Licenses []string `yaml:"licenses,omitempty"` - Employee Employee `yaml:"employeeInfo,omitempty"` - Location Location `yaml:"location,omitempty"` - Address string `yaml:"addresses,omitempty"` + FirstName string `yaml:"givenName"` + LastName string `yaml:"familyName"` + PrimaryEmail string `yaml:"primaryEmail"` + Aliases []string `yaml:"aliases,omitempty"` + Phones []string `yaml:"phones,omitempty"` + RecoveryPhone string `yaml:"recoveryPhone,omitempty"` + RecoveryEmail string `yaml:"recoveryEmail,omitempty"` + OrgUnitPath string `yaml:"orgUnitPath,omitempty"` + Licenses []string `yaml:"licenses,omitempty"` + Employee Employee `yaml:"employeeInfo,omitempty"` + Location Location `yaml:"location,omitempty"` + Address string `yaml:"address,omitempty"` +} + +func (u *User) Sort() { + sort.Strings(u.Aliases) + sort.Strings(u.Phones) + sort.Strings(u.Licenses) } type Location struct { @@ -170,18 +183,17 @@ type Group struct { Members []Member `yaml:"members,omitempty"` } +func (g *Group) Sort() { + sort.SliceStable(g.Members, func(i, j int) bool { + return g.Members[i].Email < g.Members[j].Email + }) +} + type Member struct { Email string `yaml:"email"` Role string `yaml:"role,omitempty"` } -type OrgUnit struct { - Name string `yaml:"name"` - Description string `yaml:"description,omitempty"` - ParentOrgUnitPath string `yaml:"parentOrgUnitPath,omitempty"` - BlockInheritance bool `yaml:"blockInheritance,omitempty"` -} - func LoadFromFile(filename string) (*Config, error) { config := &Config{} @@ -199,6 +211,7 @@ func LoadFromFile(filename string) (*Config, error) { config.DefaultOrgUnits() config.DefaultUsers() config.DefaultGroups() + config.Sort() return config, nil } @@ -217,6 +230,7 @@ func SaveToFile(config *Config, filename string) error { config.UndefaultOrgUnits() config.UndefaultUsers() config.UndefaultGroups() + config.Sort() if err := encoder.Encode(config); err != nil { return err diff --git a/pkg/config/conversion.go b/pkg/config/conversion.go index 9da59df..14e16f2 100644 --- a/pkg/config/conversion.go +++ b/pkg/config/conversion.go @@ -36,6 +36,16 @@ func ToGSuiteUser(user *User) *directoryv1.User { RecoveryEmail: user.RecoveryEmail, RecoveryPhone: user.RecoveryPhone, OrgUnitPath: user.OrgUnitPath, + + // set to empty list, because having them as "nil" + // will not cause proper updates, i.e. orphaned phone numbers + Phones: []directoryv1.UserPhone{}, + Addresses: []directoryv1.UserAddress{}, + Emails: []directoryv1.UserEmail{}, + Organizations: []directoryv1.UserOrganization{}, + Relations: []directoryv1.UserRelation{}, + ExternalIds: []directoryv1.UserExternalId{}, + Locations: []directoryv1.UserLocation{}, } if len(user.Phones) > 0 { @@ -59,15 +69,6 @@ func ToGSuiteUser(user *User) *directoryv1.User { } } - if user.SecondaryEmail != "" { - gsuiteUser.Emails = []directoryv1.UserEmail{ - { - Address: user.SecondaryEmail, - Type: "work", - }, - } - } - if !user.Employee.Empty() { userOrg := []directoryv1.UserOrganization{ { @@ -163,29 +164,22 @@ func ToConfigUser(gsuiteUser *directoryv1.User, userLicenses []License) (User, e return User{}, fmt.Errorf("failed to decode user: %v", err) } - primaryEmail, secondaryEmail := "", "" + primaryEmail := "" for _, email := range apiUser.Emails { if email.Primary { primaryEmail = email.Address - } else { - secondaryEmail = email.Address + break } } user := User{ - FirstName: gsuiteUser.Name.GivenName, - LastName: gsuiteUser.Name.FamilyName, - PrimaryEmail: primaryEmail, - SecondaryEmail: secondaryEmail, - OrgUnitPath: gsuiteUser.OrgUnitPath, - RecoveryPhone: gsuiteUser.RecoveryPhone, - RecoveryEmail: gsuiteUser.RecoveryEmail, - } - - if len(gsuiteUser.Aliases) > 0 { - for _, alias := range gsuiteUser.Aliases { - user.Aliases = append(user.Aliases, string(alias)) - } + FirstName: gsuiteUser.Name.GivenName, + LastName: gsuiteUser.Name.FamilyName, + PrimaryEmail: primaryEmail, + OrgUnitPath: gsuiteUser.OrgUnitPath, + RecoveryPhone: gsuiteUser.RecoveryPhone, + RecoveryEmail: gsuiteUser.RecoveryEmail, + Aliases: gsuiteUser.Aliases, } for _, phone := range apiUser.Phones { @@ -233,6 +227,8 @@ func ToConfigUser(gsuiteUser *directoryv1.User, userLicenses []License) (User, e } } + user.Sort() + return user, nil } @@ -286,6 +282,8 @@ func ToConfigGroup(gsuiteGroup *directoryv1.Group, settings *groupssettingsv1.Gr group.Members = append(group.Members, ToConfigGroupMember(m)) } + group.Sort() + return group, nil } diff --git a/pkg/config/defaulting.go b/pkg/config/defaulting.go index 80acf30..aa0babd 100644 --- a/pkg/config/defaulting.go +++ b/pkg/config/defaulting.go @@ -17,6 +17,7 @@ limitations under the License. package config import ( + "sort" "strings" ) @@ -146,3 +147,27 @@ func (c *Config) UndefaultOrgUnits() error { return nil } + +func (c *Config) Sort() { + sort.SliceStable(c.OrgUnits, func(i, j int) bool { + return c.OrgUnits[i].Name < c.OrgUnits[j].Name + }) + + sort.SliceStable(c.Users, func(i, j int) bool { + return c.Users[i].PrimaryEmail < c.Users[j].PrimaryEmail + }) + + sort.SliceStable(c.Groups, func(i, j int) bool { + return c.Groups[i].Name < c.Groups[j].Name + }) + + for idx, user := range c.Users { + user.Sort() + c.Users[idx] = user + } + + for idx, group := range c.Groups { + group.Sort() + c.Groups[idx] = group + } +} diff --git a/pkg/config/validation.go b/pkg/config/validation.go index 5015476..ea5db58 100644 --- a/pkg/config/validation.go +++ b/pkg/config/validation.go @@ -48,23 +48,14 @@ func (c *Config) ValidateUsers() []error { if user.PrimaryEmail == "" { allErrors = append(allErrors, fmt.Errorf("primary email is required (user: %s)", user.LastName)) - } else { - if user.PrimaryEmail == user.SecondaryEmail { - allErrors = append(allErrors, fmt.Errorf("user has defined the same primary and secondary email (user: %s)", user.PrimaryEmail)) - } - if !validateEmailFormat(user.PrimaryEmail) { - allErrors = append(allErrors, fmt.Errorf("primary email is not a valid email-address (user: %s)", user.PrimaryEmail)) - } + } else if !validateEmailFormat(user.PrimaryEmail) { + allErrors = append(allErrors, fmt.Errorf("primary email is not a valid email-address (user: %s)", user.PrimaryEmail)) } if user.FirstName == "" || user.LastName == "" { allErrors = append(allErrors, fmt.Errorf("given and family names are required (user: %s)", user.PrimaryEmail)) } - if user.SecondaryEmail != "" && !validateEmailFormat(user.SecondaryEmail) { - allErrors = append(allErrors, fmt.Errorf("secondary email is not a valid email-address (user: %s)", user.PrimaryEmail)) - } - if user.RecoveryEmail != "" && !validateEmailFormat(user.RecoveryEmail) { allErrors = append(allErrors, fmt.Errorf("recovery email is not a valid email-address (user: %s)", user.PrimaryEmail)) } diff --git a/pkg/export/export.go b/pkg/export/export.go index 3f409f6..067661b 100644 --- a/pkg/export/export.go +++ b/pkg/export/export.go @@ -20,7 +20,6 @@ import ( "context" "fmt" "log" - "sort" "github.com/kubermatic-labs/gman/pkg/config" "github.com/kubermatic-labs/gman/pkg/glib" @@ -38,10 +37,6 @@ func ExportOrgUnits(ctx context.Context, directorySrv *glib.DirectoryService) ([ result = append(result, config.ToConfigOrgUnit(ou)) } - sort.Slice(result, func(i, j int) bool { - return result[i].Name < result[j].Name - }) - return result, nil } @@ -65,10 +60,6 @@ func ExportUsers(ctx context.Context, directorySrv *glib.DirectoryService, licen result = append(result, configUser) } - sort.Slice(result, func(i, j int) bool { - return result[i].PrimaryEmail < result[j].PrimaryEmail - }) - return result, nil } @@ -100,9 +91,5 @@ func ExportGroups(ctx context.Context, directorySrv *glib.DirectoryService, grou result = append(result, configGroup) } - sort.Slice(result, func(i, j int) bool { - return result[i].Name < result[j].Name - }) - return result, nil } diff --git a/pkg/glib/directory.go b/pkg/glib/directory.go index ba11c9b..17ec12d 100644 --- a/pkg/glib/directory.go +++ b/pkg/glib/directory.go @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Package glib contains methods for interactions with GSuite API package glib import ( diff --git a/pkg/glib/directory_groups.go b/pkg/glib/directory_groups.go index ce91072..b656f63 100644 --- a/pkg/glib/directory_groups.go +++ b/pkg/glib/directory_groups.go @@ -14,12 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Package glib contains methods for interactions with GSuite API package glib import ( "context" - "fmt" directoryv1 "google.golang.org/api/admin/directory/v1" ) @@ -34,7 +32,7 @@ func (ds *DirectoryService) ListGroups(ctx context.Context) ([]*directoryv1.Grou response, err := request.Do() if err != nil { - return nil, fmt.Errorf("unable to retrieve list of groups in domain: %v", err) + return nil, err } groups = append(groups, response.Groups...) @@ -87,7 +85,7 @@ func (ds *DirectoryService) ListMembers(ctx context.Context, group *directoryv1. response, err := request.Do() if err != nil { - return nil, fmt.Errorf("unable to retrieve list of members in group %s: %v", group.Name, err) + return nil, err } members = append(members, response.Members...) diff --git a/pkg/glib/directory_orgunits.go b/pkg/glib/directory_orgunits.go index 6b311cc..63a73f9 100644 --- a/pkg/glib/directory_orgunits.go +++ b/pkg/glib/directory_orgunits.go @@ -14,12 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Package glib contains methods for interactions with GSuite API package glib import ( "context" - "fmt" directoryv1 "google.golang.org/api/admin/directory/v1" ) @@ -28,7 +26,7 @@ func (ds *DirectoryService) ListOrgUnits(ctx context.Context) ([]*directoryv1.Or // OrgUnits do not use pagination and always return all units in a single API call. request, err := ds.Orgunits.List("my_customer").Type("all").Context(ctx).Do() if err != nil { - return nil, fmt.Errorf("unable to list OrgUnits in domain: %v", err) + return nil, err } return request.OrganizationUnits, nil @@ -36,7 +34,7 @@ func (ds *DirectoryService) ListOrgUnits(ctx context.Context) ([]*directoryv1.Or func (ds *DirectoryService) CreateOrgUnit(ctx context.Context, orgUnit *directoryv1.OrgUnit) error { if _, err := ds.Orgunits.Insert("my_customer", orgUnit).Context(ctx).Do(); err != nil { - return fmt.Errorf("unable to create org unit: %v", err) + return err } return nil @@ -45,7 +43,7 @@ func (ds *DirectoryService) CreateOrgUnit(ctx context.Context, orgUnit *director func (ds *DirectoryService) DeleteOrgUnit(ctx context.Context, orgUnit *directoryv1.OrgUnit) error { // deletion can happen with the full orgunit's path *OR* it's unique ID if err := ds.Orgunits.Delete("my_customer", orgUnit.OrgUnitId).Context(ctx).Do(); err != nil { - return fmt.Errorf("unable to delete org unit: %v", err) + return err } return nil @@ -53,7 +51,7 @@ func (ds *DirectoryService) DeleteOrgUnit(ctx context.Context, orgUnit *director func (ds *DirectoryService) UpdateOrgUnit(ctx context.Context, oldUnit *directoryv1.OrgUnit, newUnit *directoryv1.OrgUnit) error { if _, err := ds.Orgunits.Update("my_customer", oldUnit.OrgUnitId, newUnit).Context(ctx).Do(); err != nil { - return fmt.Errorf("unable to update org unit: %v", err) + return err } return nil diff --git a/pkg/glib/directory_users.go b/pkg/glib/directory_users.go index 74b3a3c..ac1f1d0 100644 --- a/pkg/glib/directory_users.go +++ b/pkg/glib/directory_users.go @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Package glib contains methods for interactions with GSuite API package glib import ( @@ -37,7 +36,7 @@ func (ds *DirectoryService) ListUsers(ctx context.Context) ([]*directoryv1.User, response, err := request.Do() if err != nil { - return nil, fmt.Errorf("unable to retrieve list of users in domain: %v", err) + return nil, err } users = append(users, response.Users...) @@ -72,16 +71,16 @@ func (ds *DirectoryService) CreateUser(ctx context.Context, user *directoryv1.Us func (ds *DirectoryService) DeleteUser(ctx context.Context, user *directoryv1.User) error { err := ds.Users.Delete(user.PrimaryEmail).Context(ctx).Do() if err != nil { - return fmt.Errorf("unable to delete user: %v", err) + return err } return nil } -func (ds *DirectoryService) UpdateUser(ctx context.Context, user *directoryv1.User) (*directoryv1.User, error) { - updatedUser, err := ds.Users.Update(user.PrimaryEmail, user).Context(ctx).Do() +func (ds *DirectoryService) UpdateUser(ctx context.Context, oldUser *directoryv1.User, newUser *directoryv1.User) (*directoryv1.User, error) { + updatedUser, err := ds.Users.Update(oldUser.PrimaryEmail, newUser).Context(ctx).Do() if err != nil { - return nil, fmt.Errorf("unable to update user: %v", err) + return nil, err } return updatedUser, nil @@ -121,7 +120,7 @@ func (ds *DirectoryService) CreateUserAlias(ctx context.Context, user *directory } if _, err := ds.Users.Aliases.Insert(user.PrimaryEmail, newAlias).Context(ctx).Do(); err != nil { - return fmt.Errorf("unable to create user alias: %v", err) + return err } return nil @@ -129,7 +128,7 @@ func (ds *DirectoryService) CreateUserAlias(ctx context.Context, user *directory func (ds *DirectoryService) DeleteUserAlias(ctx context.Context, user *directoryv1.User, alias string) error { if err := ds.Users.Aliases.Delete(user.PrimaryEmail, alias).Context(ctx).Do(); err != nil { - return fmt.Errorf("unable to delete user alias: %v", err) + return err } return nil diff --git a/pkg/glib/doc.go b/pkg/glib/doc.go new file mode 100644 index 0000000..81e910f --- /dev/null +++ b/pkg/glib/doc.go @@ -0,0 +1,18 @@ +/* +Copyright 2021 The Kubermatic Kubernetes Platform contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package glib contains methods for interactions with GSuite API +package glib diff --git a/pkg/glib/groupssettings.go b/pkg/glib/groupssettings.go index 539b46c..43457da 100644 --- a/pkg/glib/groupssettings.go +++ b/pkg/glib/groupssettings.go @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Package glib contains methods for interactions with GSuite API package glib import ( @@ -66,7 +65,7 @@ func NewGroupsSettingsService(ctx context.Context, clientSecretFile string, impe func (gs *GroupsSettingsService) GetSettings(ctx context.Context, groupId string) (*groupssettingsv1.Groups, error) { request, err := gs.Groups.Get(groupId).Context(ctx).Do() if err != nil { - return nil, fmt.Errorf("unable to retrieve group settings: %v", err) + return nil, err } return request, nil @@ -75,7 +74,7 @@ func (gs *GroupsSettingsService) GetSettings(ctx context.Context, groupId string func (gs *GroupsSettingsService) UpdateSettings(ctx context.Context, group *directoryv1.Group, settings *groupssettingsv1.Groups) (*groupssettingsv1.Groups, error) { updatedSettings, err := gs.Groups.Update(group.Email, settings).Context(ctx).Do() if err != nil { - return nil, fmt.Errorf("unable to update a group settings: %v", err) + return nil, err } return updatedSettings, nil diff --git a/pkg/glib/licensing.go b/pkg/glib/licensing.go index 12f7fb8..9ad5e95 100644 --- a/pkg/glib/licensing.go +++ b/pkg/glib/licensing.go @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Package glib contains methods for interactions with GSuite API package glib import ( @@ -75,6 +74,16 @@ func (ls *LicensingService) GetLicenses() ([]config.License, error) { return ls.licenses, nil } +func (ls *LicensingService) GetLicenseByName(name string) *config.License { + for k, license := range ls.licenses { + if license.Name == name { + return &ls.licenses[k] + } + } + + return nil +} + // LicenseUsages lists all user IDs assigned licenses for a specific product SKU. func (ls *LicensingService) LicenseUsages(ctx context.Context, license config.License) ([]string, error) { userIDs := []string{} @@ -88,7 +97,7 @@ func (ls *LicensingService) LicenseUsages(ctx context.Context, license config.Li response, err := request.Do() if err != nil { - return nil, fmt.Errorf("unable to retrieve list of users: %v", err) + return nil, err } for _, assignment := range response.Items { @@ -108,7 +117,7 @@ func (ls *LicensingService) AssignLicense(ctx context.Context, user *directoryv1 op := licensing.LicenseAssignmentInsert{UserId: user.PrimaryEmail} if _, err := ls.LicenseAssignments.Insert(license.ProductId, license.SkuId, &op).Context(ctx).Do(); err != nil { - return fmt.Errorf("unable to assign license: %v", err) + return err } return nil @@ -116,7 +125,7 @@ func (ls *LicensingService) AssignLicense(ctx context.Context, user *directoryv1 func (ls *LicensingService) UnassignLicense(ctx context.Context, user *directoryv1.User, license config.License) error { if _, err := ls.LicenseAssignments.Delete(license.ProductId, license.SkuId, user.PrimaryEmail).Context(ctx).Do(); err != nil { - return fmt.Errorf("unable to unassign license: %v", err) + return err } return nil diff --git a/pkg/sync/compare.go b/pkg/sync/compare.go index cf582b6..c3d4376 100644 --- a/pkg/sync/compare.go +++ b/pkg/sync/compare.go @@ -17,6 +17,8 @@ limitations under the License. package sync import ( + "reflect" + directoryv1 "google.golang.org/api/admin/directory/v1" "google.golang.org/api/groupssettings/v1" @@ -24,34 +26,47 @@ import ( ) func orgUnitUpToDate(configured config.OrgUnit, live *directoryv1.OrgUnit) bool { - return configured.Description == live.Description && - configured.ParentOrgUnitPath != live.ParentOrgUnitPath && - configured.BlockInheritance != live.BlockInheritance + converted := config.ToConfigOrgUnit(live) + + return reflect.DeepEqual(configured, converted) } func userUpToDate(configured config.User, live *directoryv1.User, liveLicenses []config.License, liveAliases []string) bool { - // currentUserConfig := glib.CreateConfigUserFromGSuite(currentUser, currentUserLicenses) - // if !reflect.DeepEqual(currentUserConfig, configured) { - // usersToUpdate = append(usersToUpdate, configured) - // } + converted, err := config.ToConfigUser(live, liveLicenses) + if err != nil { + return false + } + + if converted.Aliases == nil { + converted.Aliases = []string{} + } - return configured.PrimaryEmail == live.PrimaryEmail + if configured.Aliases == nil { + configured.Aliases = []string{} + } + + return reflect.DeepEqual(configured, converted) } func groupUpToDate(configured config.Group, live *directoryv1.Group, liveMembers []*directoryv1.Member, settings *groupssettings.Groups) bool { - // currentUserConfig := glib.CreateConfigUserFromGSuite(currentUser, currentUserLicenses) - // if !reflect.DeepEqual(currentUserConfig, configured) { - // usersToUpdate = append(usersToUpdate, configured) - // } + converted, err := config.ToConfigGroup(live, settings, liveMembers) + if err != nil { + return false + } + + if converted.Members == nil { + converted.Members = []config.Member{} + } + + if configured.Members == nil { + configured.Members = []config.Member{} + } - return configured.Email == live.Email + return reflect.DeepEqual(configured, converted) } func memberUpToDate(configured config.Member, live *directoryv1.Member) bool { - // currentUserConfig := glib.CreateConfigUserFromGSuite(currentUser, currentUserLicenses) - // if !reflect.DeepEqual(currentUserConfig, configured) { - // usersToUpdate = append(usersToUpdate, configured) - // } + converted := config.ToConfigGroupMember(live) - return configured.Email == live.Email + return reflect.DeepEqual(configured, converted) } diff --git a/pkg/sync/licensing.go b/pkg/sync/licensing.go index 42f5cdd..476ee57 100644 --- a/pkg/sync/licensing.go +++ b/pkg/sync/licensing.go @@ -78,7 +78,7 @@ func syncUserLicenses( for _, expectedLicense := range expectedLicenses { if !sliceContainsLicense(liveLicenses, expectedLicense) { - license := licenseStatus.GetLicense(expectedLicense) + license := licenseSrv.GetLicenseByName(expectedLicense) log.Printf(" + license %s", license.Name) if confirm { diff --git a/pkg/sync/users.go b/pkg/sync/users.go index 04b0b97..2e08e53 100644 --- a/pkg/sync/users.go +++ b/pkg/sync/users.go @@ -35,12 +35,14 @@ func SyncUsers( cfg *config.Config, licenseStatus *glib.LicenseStatus, confirm bool, -) error { +) (bool, error) { + changes := false + log.Println("⇄ Syncing users…") liveUsers, err := directorySrv.ListUsers(ctx) if err != nil { - return err + return changes, err } liveEmails := sets.NewString() @@ -58,7 +60,7 @@ func SyncUsers( currentAliases, err := directorySrv.GetUserAliases(ctx, liveUser) if err != nil { - return fmt.Errorf("failed to fetch aliases: %v", err) + return changes, fmt.Errorf("failed to fetch aliases: %v", err) } if userUpToDate(expectedUser, liveUser, currentUserLicenses, currentAliases) { @@ -66,23 +68,24 @@ func SyncUsers( log.Printf(" ✓ %s", expectedUser.PrimaryEmail) } else { // update it + changes = true log.Printf(" ✎ %s", expectedUser.PrimaryEmail) updatedUser := liveUser if confirm { apiUser := config.ToGSuiteUser(&expectedUser) - updatedUser, err = directorySrv.UpdateUser(ctx, apiUser) + updatedUser, err = directorySrv.UpdateUser(ctx, liveUser, apiUser) if err != nil { - return fmt.Errorf("failed to update user: %v", err) + return changes, fmt.Errorf("failed to update user: %v", err) } } if err := syncUserAliases(ctx, directorySrv, &expectedUser, updatedUser, currentAliases, confirm); err != nil { - return fmt.Errorf("failed to sync aliases: %v", err) + return changes, fmt.Errorf("failed to sync aliases: %v", err) } if err := syncUserLicenses(ctx, licensingSrv, &expectedUser, updatedUser, licenseStatus, confirm); err != nil { - return fmt.Errorf("failed to sync licenses: %v", err) + return changes, fmt.Errorf("failed to sync licenses: %v", err) } } @@ -91,11 +94,12 @@ func SyncUsers( } if !found { + changes = true log.Printf(" - %s", liveUser.PrimaryEmail) if confirm { if err := directorySrv.DeleteUser(ctx, liveUser); err != nil { - return fmt.Errorf("failed to delete user: %v", err) + return changes, fmt.Errorf("failed to delete user: %v", err) } } } @@ -103,6 +107,7 @@ func SyncUsers( for _, expectedUser := range cfg.Users { if !liveEmails.Has(expectedUser.PrimaryEmail) { + changes = true log.Printf(" + %s", expectedUser.PrimaryEmail) var createdUser *directoryv1.User @@ -111,21 +116,21 @@ func SyncUsers( apiUser := config.ToGSuiteUser(&expectedUser) createdUser, err = directorySrv.CreateUser(ctx, apiUser) if err != nil { - return fmt.Errorf("failed to create user: %v", err) + return changes, fmt.Errorf("failed to create user: %v", err) } } if err := syncUserAliases(ctx, directorySrv, &expectedUser, createdUser, nil, confirm); err != nil { - return fmt.Errorf("failed to sync aliases: %v", err) + return changes, fmt.Errorf("failed to sync aliases: %v", err) } if err := syncUserLicenses(ctx, licensingSrv, &expectedUser, createdUser, licenseStatus, confirm); err != nil { - return fmt.Errorf("failed to sync licenses: %v", err) + return changes, fmt.Errorf("failed to sync licenses: %v", err) } } } - return nil + return changes, nil } func syncUserAliases( From acd15a7282fcebcd9b667c27ac273ad31eced8b2 Mon Sep 17 00:00:00 2001 From: Christoph Mewes Date: Wed, 13 Jan 2021 16:39:55 +0100 Subject: [PATCH 12/18] improve logging --- main.go | 26 ++++++++++++++------------ pkg/config/defaulting.go | 4 ++-- pkg/glib/directory_groups.go | 10 ++++++++++ pkg/glib/directory_orgunits.go | 6 ++++++ pkg/glib/directory_users.go | 4 ++++ pkg/sync/orgunits.go | 16 ++++++++++------ 6 files changed, 46 insertions(+), 20 deletions(-) diff --git a/main.go b/main.go index 1962c40..e367cda 100644 --- a/main.go +++ b/main.go @@ -108,7 +108,11 @@ func main() { } orgName := opt.groupsConfig.Organization - log.Printf("Working with organization %q…", orgName) + log.Printf("☁ Working with organization %q…", orgName) + + if !opt.exportAction && !opt.confirm { + log.Println("☞ This is a dry-run, no actual changes are being made.") + } // create glib services ctx := context.Background() @@ -151,26 +155,24 @@ func syncAction( licensingSrv *glib.LicensingService, groupsSettingsSrv *glib.GroupsSettingsService, ) { - // log.Println("► Updating org units…") - // if err := sync.SyncOrgUnits(ctx, directorySrv, opt.orgUnitsConfig, opt.confirm); err != nil { - // log.Fatalf("⚠ Failed to sync: %v.", err) - // } + orgUnitChanges, err := sync.SyncOrgUnits(ctx, directorySrv, opt.orgUnitsConfig, opt.confirm) + if err != nil { + log.Fatalf("⚠ Failed to sync: %v.", err) + } - log.Println("► Updating users…") userChanges, err := sync.SyncUsers(ctx, directorySrv, licensingSrv, opt.usersConfig, opt.licenseStatus, opt.confirm) if err != nil { log.Fatalf("⚠ Failed to sync: %v.", err) } - // log.Println("► Updating groups…") - // groupChanges, err := sync.SyncGroups(ctx, directorySrv, groupsSettingsSrv, opt.groupsConfig, opt.confirm) - // if err != nil { - // log.Fatalf("⚠ Failed to sync: %v.", err) - // } + groupChanges, err := sync.SyncGroups(ctx, directorySrv, groupsSettingsSrv, opt.groupsConfig, opt.confirm) + if err != nil { + log.Fatalf("⚠ Failed to sync: %v.", err) + } if opt.confirm { log.Println("✓ Organization successfully synchronized.") - } else if userChanges /* || groupChanges */ { + } else if orgUnitChanges || userChanges || groupChanges { log.Println("⚠ Run again with -confirm to apply the changes above.") } else { log.Println("✓ No changes necessary, organization is in sync.") diff --git a/pkg/config/defaulting.go b/pkg/config/defaulting.go index aa0babd..2d7947a 100644 --- a/pkg/config/defaulting.go +++ b/pkg/config/defaulting.go @@ -150,7 +150,7 @@ func (c *Config) UndefaultOrgUnits() error { func (c *Config) Sort() { sort.SliceStable(c.OrgUnits, func(i, j int) bool { - return c.OrgUnits[i].Name < c.OrgUnits[j].Name + return strings.ToLower(c.OrgUnits[i].Name) < strings.ToLower(c.OrgUnits[j].Name) }) sort.SliceStable(c.Users, func(i, j int) bool { @@ -158,7 +158,7 @@ func (c *Config) Sort() { }) sort.SliceStable(c.Groups, func(i, j int) bool { - return c.Groups[i].Name < c.Groups[j].Name + return strings.ToLower(c.Groups[i].Name) < strings.ToLower(c.Groups[j].Name) }) for idx, user := range c.Users { diff --git a/pkg/glib/directory_groups.go b/pkg/glib/directory_groups.go index b656f63..e3d2f88 100644 --- a/pkg/glib/directory_groups.go +++ b/pkg/glib/directory_groups.go @@ -18,6 +18,8 @@ package glib import ( "context" + "sort" + "strings" directoryv1 "google.golang.org/api/admin/directory/v1" ) @@ -43,6 +45,10 @@ func (ds *DirectoryService) ListGroups(ctx context.Context) ([]*directoryv1.Grou } } + sort.SliceStable(groups, func(i, j int) bool { + return strings.ToLower(groups[i].Name) < strings.ToLower(groups[j].Name) + }) + return groups, nil } @@ -96,6 +102,10 @@ func (ds *DirectoryService) ListMembers(ctx context.Context, group *directoryv1. } } + sort.SliceStable(members, func(i, j int) bool { + return members[i].Email < members[j].Email + }) + return members, nil } diff --git a/pkg/glib/directory_orgunits.go b/pkg/glib/directory_orgunits.go index 63a73f9..22b09b4 100644 --- a/pkg/glib/directory_orgunits.go +++ b/pkg/glib/directory_orgunits.go @@ -18,6 +18,8 @@ package glib import ( "context" + "sort" + "strings" directoryv1 "google.golang.org/api/admin/directory/v1" ) @@ -29,6 +31,10 @@ func (ds *DirectoryService) ListOrgUnits(ctx context.Context) ([]*directoryv1.Or return nil, err } + sort.SliceStable(request.OrganizationUnits, func(i, j int) bool { + return strings.ToLower(request.OrganizationUnits[i].Name) < strings.ToLower(request.OrganizationUnits[j].Name) + }) + return request.OrganizationUnits, nil } diff --git a/pkg/glib/directory_users.go b/pkg/glib/directory_users.go index ac1f1d0..cc11a2c 100644 --- a/pkg/glib/directory_users.go +++ b/pkg/glib/directory_users.go @@ -47,6 +47,10 @@ func (ds *DirectoryService) ListUsers(ctx context.Context) ([]*directoryv1.User, } } + sort.SliceStable(users, func(i, j int) bool { + return users[i].PrimaryEmail < users[j].PrimaryEmail + }) + return users, nil } diff --git a/pkg/sync/orgunits.go b/pkg/sync/orgunits.go index 53e9b19..655ed11 100644 --- a/pkg/sync/orgunits.go +++ b/pkg/sync/orgunits.go @@ -32,12 +32,13 @@ func SyncOrgUnits( directorySrv *glib.DirectoryService, cfg *config.Config, confirm bool, -) error { +) (bool, error) { + changes := false log.Println("⇄ Syncing organizational units…") liveOrgUnits, err := directorySrv.ListOrgUnits(ctx) if err != nil { - return err + return changes, err } liveNames := sets.NewString() @@ -56,12 +57,13 @@ func SyncOrgUnits( log.Printf(" ✓ %s", expectedOrgUnit.Name) } else { // update it + changes = true log.Printf(" ✎ %s", expectedOrgUnit.Name) if confirm { newOrgUnit := config.ToGSuiteOrgUnit(&expectedOrgUnit) if err := directorySrv.UpdateOrgUnit(ctx, liveOrgUnit, newOrgUnit); err != nil { - return fmt.Errorf("failed to update org unit: %v", err) + return changes, fmt.Errorf("failed to update org unit: %v", err) } } } @@ -71,12 +73,13 @@ func SyncOrgUnits( } if !found { + changes = true log.Printf(" - %s", liveOrgUnit.Name) if confirm { err := directorySrv.DeleteOrgUnit(ctx, liveOrgUnit) if err != nil { - return fmt.Errorf("failed to delete org unit: %v", err) + return changes, fmt.Errorf("failed to delete org unit: %v", err) } } } @@ -84,16 +87,17 @@ func SyncOrgUnits( for _, expectedOrgUnit := range cfg.OrgUnits { if !liveNames.Has(expectedOrgUnit.Name) { + changes = true log.Printf(" + %s", expectedOrgUnit.Name) if confirm { apiOrgUnit := config.ToGSuiteOrgUnit(&expectedOrgUnit) if err := directorySrv.CreateOrgUnit(ctx, apiOrgUnit); err != nil { - return fmt.Errorf("failed to create org unit: %v", err) + return changes, fmt.Errorf("failed to create org unit: %v", err) } } } } - return nil + return changes, nil } From 9b092a6ced64b62b957a5e34cb1596b9b7b5d669 Mon Sep 17 00:00:00 2001 From: Christoph Mewes Date: Wed, 13 Jan 2021 17:03:10 +0100 Subject: [PATCH 13/18] allow to override the list of possible licenses --- main.go | 24 +++++++++++++- pkg/config/config.go | 1 + pkg/config/defaulting.go | 3 ++ pkg/config/licenses.go | 6 ++-- pkg/config/validation.go | 69 +++++++++++++++++++++++++++++----------- pkg/util/util.go | 10 ------ 6 files changed, 81 insertions(+), 32 deletions(-) diff --git a/main.go b/main.go index e367cda..b53f110 100644 --- a/main.go +++ b/main.go @@ -42,6 +42,7 @@ type options struct { usersConfigFile string groupsConfigFile string orgUnitsConfigFile string + licensesConfigFile string usersConfig *config.Config groupsConfig *config.Config orgUnitsConfig *config.Config @@ -53,6 +54,7 @@ type options struct { clientSecretFile string impersonatedUserEmail string throttleRequests time.Duration + licenses []config.License } func main() { @@ -64,6 +66,7 @@ func main() { flag.StringVar(&opt.usersConfigFile, "users-config", "", "path to the config.yaml that contains all users") flag.StringVar(&opt.groupsConfigFile, "groups-config", "", "path to the config.yaml that contains all groups") flag.StringVar(&opt.orgUnitsConfigFile, "orgunits-config", "", "path to the config.yaml that contains all organization units") + flag.StringVar(&opt.licensesConfigFile, "licenses-config", "", "(optional) instead of using the inbuilt license list, this is a config.yaml that contains the relevant licenses") flag.StringVar(&opt.clientSecretFile, "private-key", "", "path to the Service Account secret file (.json) coontaining Keys used for authorization") flag.StringVar(&opt.impersonatedUserEmail, "impersonated-email", "", "Admin email used to impersonate Service Account") flag.BoolVar(&opt.versionAction, "version", false, "show the GMan version and exit") @@ -94,6 +97,17 @@ func main() { log.Fatalf("⚠ Failed to load org unit config from %q: %v.", opt.orgUnitsConfigFile, err) } + // load licenses + opt.licenses = config.AllLicenses + if opt.licensesConfigFile != "" { + licensesConfig, err := config.LoadFromFile(opt.licensesConfigFile) + if err != nil { + log.Fatalf("⚠ Failed to load license config from %q: %v.", opt.licensesConfigFile, err) + } + + opt.licenses = licensesConfig.Licenses + } + // validate config unless in export mode, where an incomplete configuration is expected if !opt.exportAction { valid := validateAction(&opt) @@ -124,7 +138,7 @@ func main() { log.Fatalf("⚠ Failed to create GSuite Directory API client: %v", err) } - licensingSrv, err := glib.NewLicensingService(ctx, orgName, opt.clientSecretFile, opt.impersonatedUserEmail, opt.throttleRequests, config.AllLicenses) + licensingSrv, err := glib.NewLicensingService(ctx, orgName, opt.clientSecretFile, opt.impersonatedUserEmail, opt.throttleRequests, opt.licenses) if err != nil { log.Fatalf("⚠ Failed to create GSuite Licensing API client: %v", err) } @@ -238,6 +252,14 @@ func saveExport(filename string, patch func(*config.Config)) error { func validateAction(opt *options) bool { valid := true + if errs := config.ValidateLicenses(opt.licenses); errs != nil { + log.Println("⚠ License configuration is invalid:") + for _, e := range errs { + log.Printf(" - %v", e) + } + valid = false + } + if errs := opt.orgUnitsConfig.ValidateOrgUnits(); errs != nil { log.Println("⚠ Org unit configuration is invalid:") for _, e := range errs { diff --git a/pkg/config/config.go b/pkg/config/config.go index be5c1da..f1567e0 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -116,6 +116,7 @@ type Config struct { OrgUnits []OrgUnit `yaml:"orgUnits,omitempty"` Users []User `yaml:"users,omitempty"` Groups []Group `yaml:"groups,omitempty"` + Licenses []License `yaml:"licenses,omitempty"` } type OrgUnit struct { diff --git a/pkg/config/defaulting.go b/pkg/config/defaulting.go index 2d7947a..dbb93c8 100644 --- a/pkg/config/defaulting.go +++ b/pkg/config/defaulting.go @@ -161,6 +161,9 @@ func (c *Config) Sort() { return strings.ToLower(c.Groups[i].Name) < strings.ToLower(c.Groups[j].Name) }) + // do not sort licenses because they contain numerical values with units that sort badly, + // like "2TB" > "4GB" + for idx, user := range c.Users { user.Sort() c.Users[idx] = user diff --git a/pkg/config/licenses.go b/pkg/config/licenses.go index 812af18..8499a65 100644 --- a/pkg/config/licenses.go +++ b/pkg/config/licenses.go @@ -17,9 +17,9 @@ limitations under the License. package config type License struct { - ProductId string - SkuId string - Name string // used in yaml + Name string `yaml:"name"` + ProductId string `yaml:"productId"` + SkuId string `yaml:"skuId"` } // list of available GSuite Licenses diff --git a/pkg/config/validation.go b/pkg/config/validation.go index ea5db58..6ea83d8 100644 --- a/pkg/config/validation.go +++ b/pkg/config/validation.go @@ -22,7 +22,7 @@ import ( "regexp" "strings" - "github.com/kubermatic-labs/gman/pkg/util" + "k8s.io/apimachinery/pkg/util/sets" ) // validateEmailFormat is a helper function that checks for existance of '@' and the length of the address @@ -40,11 +40,12 @@ func (c *Config) ValidateUsers() []error { } // validate users - userEmails := []string{} + userEmails := sets.NewString() for _, user := range c.Users { - if util.StringSliceContains(userEmails, user.PrimaryEmail) { + if userEmails.Has(user.PrimaryEmail) { allErrors = append(allErrors, fmt.Errorf("duplicate user defined (user: %s)", user.PrimaryEmail)) } + userEmails.Insert(user.PrimaryEmail) if user.PrimaryEmail == "" { allErrors = append(allErrors, fmt.Errorf("primary email is required (user: %s)", user.LastName)) @@ -89,8 +90,6 @@ func (c *Config) ValidateUsers() []error { } } } - - userEmails = append(userEmails, user.PrimaryEmail) } return allErrors @@ -105,11 +104,12 @@ func (c *Config) ValidateGroups() []error { } // validate groups - groupEmails := []string{} + groupEmails := sets.NewString() for _, group := range c.Groups { - if util.StringSliceContains(groupEmails, group.Email) { + if groupEmails.Has(group.Email) { allErrors = append(allErrors, fmt.Errorf("[group: %s] duplicate group email defined", group.Email)) } + groupEmails.Insert(group.Email) if !validateEmailFormat(group.Email) { allErrors = append(allErrors, fmt.Errorf("[group: %s] group email is not a valid email address", group.Email)) @@ -145,19 +145,14 @@ func (c *Config) ValidateGroups() []error { } } - memberEmails := []string{} + memberEmails := sets.NewString() for _, member := range group.Members { - if util.StringSliceContains(memberEmails, member.Email) { + if memberEmails.Has(member.Email) { allErrors = append(allErrors, fmt.Errorf("[group: %s] duplicate member %q defined", group.Name, member.Email)) } + memberEmails.Insert(member.Email) - // default role to Member - role := strings.ToUpper(member.Role) - if role == "" { - role = MemberRoleMember - } - - if !allMemberRoles.Has(role) { + if !allMemberRoles.Has(member.Role) { allErrors = append(allErrors, fmt.Errorf("[group: %s] invalid member role specified for %q, must be one of %v", group.Name, member.Email, allMemberRoles.List())) } } @@ -175,11 +170,12 @@ func (c *Config) ValidateOrgUnits() []error { } // validate org units - unitNames := []string{} + unitNames := sets.NewString() for _, orgUnit := range c.OrgUnits { - if util.StringSliceContains(unitNames, orgUnit.Name) { + if unitNames.Has(orgUnit.Name) { allErrors = append(allErrors, fmt.Errorf("[org unit: %s] duplicate org unit defined", orgUnit.Name)) } + unitNames.Insert(orgUnit.Name) if orgUnit.Name == "" { allErrors = append(allErrors, fmt.Errorf("[org unit: %s] no name specified", orgUnit.Name)) @@ -190,6 +186,43 @@ func (c *Config) ValidateOrgUnits() []error { } else if !strings.HasPrefix(orgUnit.ParentOrgUnitPath, "/") { allErrors = append(allErrors, fmt.Errorf("[org unit: %s] parentOrgUnitPath must start with a slash", orgUnit.Name)) } + + } + + return allErrors +} + +func ValidateLicenses(licenses []License) []error { + var allErrors []error + + // validate org units + licenseNames := sets.NewString() + licenseIdentifiers := sets.NewString() + for _, license := range licenses { + identifier := fmt.Sprintf("%s:%s", license.ProductId, license.SkuId) + + if licenseNames.Has(license.Name) { + allErrors = append(allErrors, fmt.Errorf("[license: %s] duplicate license name defined", license.Name)) + } + + if licenseIdentifiers.Has(identifier) { + allErrors = append(allErrors, fmt.Errorf("[license: %s] duplicate license productId/skuId combination defined", license.Name)) + } + + licenseNames.Insert(license.Name) + licenseIdentifiers.Insert(identifier) + + if license.Name == "" { + allErrors = append(allErrors, fmt.Errorf("[license: %s] no name specified", license.Name)) + } + + if license.ProductId == "" { + allErrors = append(allErrors, fmt.Errorf("[license: %s] no productId specified", license.Name)) + } + + if license.SkuId == "" { + allErrors = append(allErrors, fmt.Errorf("[license: %s] no skuId specified", license.Name)) + } } return allErrors diff --git a/pkg/util/util.go b/pkg/util/util.go index 778a205..8d3329c 100644 --- a/pkg/util/util.go +++ b/pkg/util/util.go @@ -21,16 +21,6 @@ import ( "fmt" ) -func StringSliceContains(s []string, needle string) bool { - for _, item := range s { - if item == needle { - return true - } - } - - return false -} - func ConvertToStruct(data json.Marshaler, dst interface{}) error { encoded, err := data.MarshalJSON() if err != nil { From 2a35a457db1afce90a4af9ea852a76c976398d3c Mon Sep 17 00:00:00 2001 From: Christoph Mewes Date: Wed, 13 Jan 2021 17:45:35 +0100 Subject: [PATCH 14/18] update documentation --- CHANGELOG.md | 13 ++- Configuration.md | 250 ++++++++++++++++++++--------------------------- README.md | 233 ++++++++++++++++++------------------------- 3 files changed, 211 insertions(+), 285 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4bd4f1..51ffd09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,18 @@ All notable changes to this module will be documented in this file. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [v0.5.0] - 2021-01-xx + +* improved license handling speed +* allow to omit default values +* auotmatic sorting +* removed `secondaryEmailAddress`, relying on aliases instead +* list of possible licenses can be overwritten (insetad of relying + on the built-in licenses) +* removed orgUnitPath from orgUnit configuration, as it is always + deduced from the name anyway and cannot be changed +* user, group and org unit configuration files must now always be + given, but they can be the same file ## [v0.0.7] - 2021-01-06 diff --git a/Configuration.md b/Configuration.md index d37022c..841bbd5 100644 --- a/Configuration.md +++ b/Configuration.md @@ -1,6 +1,6 @@ # Configuration -*Here can be found all the configuration details of GMan.* +*Here are the configuration details of GMan.* **Table of contents:** @@ -9,170 +9,128 @@ - [Users](#users) - [User Licenses](#user-licenses) - [Groups](#groups) - - [Group's Permissions](#groups-permissions) - - [Contacting owner](#contacting-owner) - - [Viewing membership](#viewing-membership) - - [Approving membership](#approving-membership) - - [Posting messages](#posting-messages) - - [Joining group](#joining-group) ## Organizational Units -The organizational units are specified as the entries of the `org_units` collection. +The organizational units (OU) are specified as the entries of the `orgUnits` collection. -Each OU contains: +```yaml +organization: exampleorg +orgUnits: + - # unique name (required) + name: Org Unit 1 + description: An optional description text. + # The organizational unit's parent path. + # If the OU is directly under the parent organization, the entry should contain a single slash `/` + # (which is also the default) + parentOrgUnitPath: / + blockInheritance: false -| parameter | type | description | required | -|--------------|--------|--------------|----------| -| name | string | The name of the organizational unit. Inside of the OU's path it is the last entry, i.e. an organizational unit's name within the `/students/math/extended_math` parent path is `extended_math` | yes | -| description | string | The description of the organizational unit. | no | -| parentOrgUnitPath | string | The organizational unit's parent path. If the OU is directly under the parent organization, the entry should contain a single slash `/`. If OU is nested, then, for example, `/students/mathematics` is the parent path for `extended_math` organizational unit with full path `/students/math/extended_math`. | yes | -| org_unit_path | string | The full path of the OU. It is derived from parentOrgUnitPath and organizational unit's name. | yes | + - ... +``` ## Users The users are specified as the entries of the `users` collection. -Each user contains: - -| parameter | type | description | required | -|------------|--------|--------------|----------| -| given_name | string | first name of the user | yes | -| family_name | string | last name of the user | yes | -| primary_email | string | a GSuite email address; must end with your domain name | yes | -| secondary_email | string | additional, private email address | no | -| org_unit_path | string | org unit path indicates in which OU the user should be created; single slash '/' points to parent organization | no | -| aliases | list of strings | list of the user's alias email addresses | no | -| phones | list of strings | list of the user's phone numbers | no | -| recovery_phone | string | recovery phone of the user | no | -| recovery_email | string | recovery email of the user; allows password recovery for the users | no | -| licenses | list of strings | Google products and related Stock Keeping Units (SKUs) assigned to the user; for detailed information about possible values, see table below | no | -| employee_info: employee_ID | string | employee ID | no | -| employee_info: department | string | department | no | -| employee_info: job_title | string | title of the work position | no | -| employee_info: type | string | description of the employment type | no | -| employee_info: cost_center | string | cost center of the user's organization | no | -| employee_info: manager_email | string | email of the person (manager) the user is related to | no | -| location: building | string | building name | no | -| location: floor | string | floor name/number | no | -| location: floor_section | string | floor section | no | -| addresses | string | private address of the user | no | +```yaml +organization: exampleorg +users: + - # first name of the user (required) + givenName: Roxy + # last name of the user (required) + familyName: Sampleperson + # a GSuite email address; must end with your domain name (required) + primaryEmail: roxy@example.com + # org unit path indicates in which OU the user should be created; + # single slash '/' points to parent organization (default) + orgUnitPath: /AwesomePeople + # optional list of additional email aliases + aliases: + - roxyrocks@example.com + # optional list of phone numbers + phones: + - 555-887951-87 + # recovery phone number (optional) + recoveryPhone: 555-887951-87 + # recovery email address (optional) + recoveryEmail: roxys-recovery-address@gmail.com + # list of licenses this user is assigned to + licenses: + - GoogleDriveStorage20GB + - GoogleVoicePremier + # optional detailed employee information + employee: + # employee ID + id: '' + department: '' + jobTitle: '' + type: '' + costCenter: '' + managerEmail: '' + # optional location info + location: + building: '' + floor: '' + floorSection: '' + # optional address + address: "Rue d'Example 42, 12345 Sampleville" + + - ... +``` ### User Licenses The user's licenses are the Google products and related Stock Keeping Units (SKUs). The official list of all the available products can be found in [the official Google documentation](https://developers.google.com/admin-sdk/licensing/v1/how-tos/products). -GMan supports the following names as the equivalents of the Google SKUs: - -| Google SKU Name (License) | GMan value | -|---------------------------|------------| -| G Suite Enterprise | GSuiteEnterprise | -| G Suite Business | GSuiteBusiness | -| G Suite Basic | GSuiteBasic -| G Suite Essentials | GSuiteEssentials | -| G Suite Lite | GSuiteLite | -| Google Apps Message Security | GoogleAppsMessageSecurity | -| G Suite Enterprise for Education | GSuiteEducation | -| G Suite Enterprise for Education (Student) | GSuiteEducationStudent | -| Google Drive storage 20 GB | GoogleDrive20GB | -| Google Drive storage 50 GB | GoogleDrive50GB | -| Google Drive storage 200 GB | GoogleDrive200GB | -| Google Drive storage 400 GB | GoogleDrive400GB | -| Google Drive storage 1 TB | GoogleDrive1TB | -| Google Drive storage 2 TB | GoogleDrive2TB | -| Google Drive storage 4 TB | GoogleDrive4TB | -| Google Drive storage 8 TB | GoogleDrive8TB | -| Google Drive storage 16 TB | GoogleDrive16TB | -| Google Vault | GoogleVault | -| Google Vault - Former Employee | GoogleVaultFormerEmployee | -| Cloud Identity Premium | CloudIdentityPremium | -| Google Voice Starter | GoogleVoiceStarter | -| Google Voice Standard | GoogleVoiceStandard | -| Google Voice Premier | GoogleVoicePremier | - -Remark: *Cloud Identity Free Edition* is a site-wide SKU (applied at customer level), hence it cannot be managed by GMan as it is not assigned to individual users. +GMan has a list of licenses built-in, but this can be overwritten by running gman with +`-licenses-config `, which must be a YAML file that contains a list of licenses. +Run GMan with `-licenses` to see the list of default licenses. + +Remark: *Cloud Identity Free Edition* is a site-wide SKU (applied at customer level), +hence it cannot be managed by GMan as it is not assigned to individual users. ## Groups The groups are specified as the entries of the `groups` collection. -Each user contains: - -| parameter | type | description | required | -|-----------|------|-------------|----------| -| name | string | name of the group | yes | -| email | string | email of the group; must end with your organization's domain name | yes | -| description | string | group's description; max 300 characters | -| who_can_contact_owner | string | permissions to view contact owner of the group; for possible values see below | yes | -| who_can_view_members | string | permissions to view group messages; for possible values see below | yes | -| who_can_approve_members | string | permissions to approve members who ask to join groups; for possible values see below | yes | -| who_can_post | string | permissions to post messages; for possible values see below | yes | -| who_can_join | string | permissions to join group; for possible values see below | yes | -| allow_external_members | bool | identifies whether members external to your organization can join the group | yes | -| is_archived | bool | allows the group content to be archived | yes | -| members | list of members | each member is specified by the email and the role; for the limits of numebr of users please refer to [the official Google documentation](https://support.google.com/a/answer/6099642?hl=en) | yes | -| member: email | string | primary email of the user | yes | -| member: role | string | role in the group of the user; possible values are: `MEMBER`, `OWNER` or `MANAGER` | yes | - -### Group's Permissions - -The group permissions designate who can perform which actions in the group. - -#### Contacting owner - -Permission to contact owner of the group via web UI. Field name is `who_can_contact_owner`. The entered values are case sensitive. - -| possible value | description | -|----------------|-------------| -| ALL_IN_DOMAIN_CAN_CONTACT | all users in the domain | -| ALL_MANAGERS_CAN_CONTACT | only managers of the group | -| ALL_MEMBERS_CAN_CONTACT | only members of the group | -| ANYONE_CAN_CONTACT | any Internet user | - -#### Viewing membership - -Permissions to view group members. Field name is `who_can_view_members`. The entered values are case sensitive. - -| possible value | description | -|----------------|-------------| -| ALL_IN_DOMAIN_CAN_VIEW | all users in the domain | -| ALL_MANAGERS_CAN_VIEW | only managers of the group | -| ALL_MEMBERS_CAN_VIEW | only members of the group | -| ANYONE_CAN_VIEW | anyone in the group | - -#### Approving membership - -Permissions to approve members who ask to join group. Field name is `who_can_approve_members`. The entered values are case sensitive. - -| possible value | description | -|----------------|-------------| -| ALL_OWNERS_CAN_APPROVE | only owners of the group | -| ALL_MANAGERS_CAN_APPROVE | only managers of the group | -| ALL_MEMBERS_CAN_APPROVE | only members of the group | -| NONE_CAN_APPROVE | noone in the group | - -#### Posting messages - -Permissions to post messages in the group. Field name is `who_can_post`. The entered values are case sensitive. - -| possible value | description | -|----------------|-------------| -| NONE_CAN_POST | the group is disabled and archived; 'is_archived' must be set to true, otherwise will result in an error | -| ALL_MANAGERS_CAN_POST | only managers and owners of the group | -| ALL_MEMBERS_CAN_POST | only members of the group | -| ALL_OWNERS_CAN_POST | only owners of the group | -| ALL_IN_DOMAIN_CAN_POST | anyone in the organization | -| ANYONE_CAN_POST | any Internet user who can access your Google Groups service | - -#### Joining group - -Permissions to join the group. Field name is `who_can_join`. The entered values are case sensitive. - -| possible value | description | -|----------------|-------------| -| ANYONE_CAN_JOIN | any Internet user who can access your Google Groups service | -| ALL_IN_DOMAIN_CAN_JOIN | anyone in the organization | -| INVITED_CAN_JOIN | only invited candidates | -| CAN_REQUEST_TO_JOIN | non-members can request an invitation to join | +```yaml +organization: exampleorg +groups: + - # unique name (required) + name: Christmas 2021 + # group email address (required) + email: christmas2021@example.com + + # the following settings control access to the group; + # the shown value is the implicit default value + + # one of ALL_MANAGERS_CAN_CONTACT, ALL_MEMBERS_CAN_CONTACT, ALL_IN_DOMAIN_CAN_CONTACT, ANYONE_CAN_CONTACT + whoCanContactOwner: ALL_MANAGERS_CAN_CONTACT + # one of ALL_MANAGERS_CAN_VIEW, ALL_MEMBERS_CAN_VIEW, ALL_IN_DOMAIN_CAN_VIEW + whoCanViewMembership: ALL_MEMBERS_CAN_VIEW + # one of ALL_MANAGERS_CAN_APPROVE, ALL_OWNERS_CAN_APPROVE, ALL_MEMBERS_CAN_APPROVE, NONE_CAN_APPROVE + whoCanApproveMembers: ALL_MANAGERS_CAN_APPROVE + # one of NONE_CAN_POST, ALL_OWNERS_CAN_POST, ALL_MANAGERS_CAN_POST, ALL_MEMBERS_CAN_POST, ALL_IN_DOMAIN_CAN_POST, ANYONE_CAN_POST + whoCanPostMessage: ALL_MEMBERS_CAN_POST + # one of INVITED_CAN_JOIN, CAN_REQUEST_TO_JOIN, ALL_IN_DOMAIN_CAN_JOIN, ANYONE_CAN_JOIN + whoCanJoin: INVITED_CAN_JOIN + + # whether external users can join the group + allowExternalMembers: false + + # whether the group is archived (readonly) + isArchived: false + + # list of members in this group + members: + - email: roxy@example.com + - email: rubert@example.com + - email: santa@northpole.example.com + # each member must be either OWNER, MANAGER or MEMBER (default) + role: OWNER + + - ... +``` diff --git a/README.md b/README.md index 259be1c..f701e27 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # GMan -*GMan links all GSuite accounts with the matching user-list storage in form of a YAML. It is based on [Aquayman](https://github.com/kubermatic-labs/aquayman).* +*GMan links all GSuite accounts with the matching user-list storage in form of a YAML.* **Features:** -- declare your users, groups and org units in code (infrastructure as code) via config YAML, which will then be applied to GSuite organization +- declare your users, groups and org units in code (infrastructure as code) via config YAML, which will then be + applied to your GSuite organization - export the current state as a starter config file -- preview any action taken (validation) +- preview any action taken **Table of contents:** @@ -38,69 +39,60 @@ The official releases can be found [on GitHub](https://github.com/kubermatic-lab The **Directory API** is intended for management of devices, groups, group members, organizational units and users. -To be able to use it, please make sure that you have access to an admin account in the Admin Console and you have set up your API. -For more detailed information, see [the official Google documentation](https://developers.google.com/admin-sdk/directory/v1/guides/prerequisites). +To be able to use it, please make sure that you have access to an admin account in the Admin Console and you have +set up your API. For more detailed information, see +[the official Google documentation](https://developers.google.com/admin-sdk/directory/v1/guides/prerequisites). -Moreover, to access the extended settings of the groups, the **Groups Settings API** must be enabled (see [the official documentation](https://developers.google.com/admin-sdk/groups-settings/prerequisites#prereqs-enableapis)). To manage user licenses the **Enterprise License Manager API** has to be activated too (see [the official documentation](https://developers.google.com/admin-sdk/licensing/v1/how-tos/prerequisites#api-setup-steps)). +Moreover, to access the extended settings of the groups, the **Groups Settings API** must be enabled (see +[the official documentation](https://developers.google.com/admin-sdk/groups-settings/prerequisites#prereqs-enableapis)). +To manage user licenses the **Enterprise License Manager API** has to be activated too (see +[the official documentation](https://developers.google.com/admin-sdk/licensing/v1/how-tos/prerequisites#api-setup-steps)). ### Service account To authorize and to perform the operations on behalf of *GMan* a Service Account is required. -After creating one, it needs to be registered as an API client and have enabled this OAuth scopes: +After creating one, it needs to be registered as an API client and have enabled these OAuth scopes: -* https://www.googleapis.com/auth/admin.directory.user -* https://www.googleapis.com/auth/admin.directory.user.readonly -* https://www.googleapis.com/auth/admin.directory.orgunit -* https://www.googleapis.com/auth/admin.directory.orgunit.readonly -* https://www.googleapis.com/auth/admin.directory.group -* https://www.googleapis.com/auth/admin.directory.group.readonly -* https://www.googleapis.com/auth/admin.directory.group.member -* https://www.googleapis.com/auth/admin.directory.group.member.readonly -* https://www.googleapis.com/auth/admin.directory.resource.calendar -* https://www.googleapis.com/auth/admin.directory.resource.calendar.readonly -* https://www.googleapis.com/auth/apps.groups.settings -* https://www.googleapis.com/auth/apps.licensing +* `https://www.googleapis.com/auth/admin.directory.user` +* `https://www.googleapis.com/auth/admin.directory.user.readonly` +* `https://www.googleapis.com/auth/admin.directory.orgunit` +* `https://www.googleapis.com/auth/admin.directory.orgunit.readonly` +* `https://www.googleapis.com/auth/admin.directory.group` +* `https://www.googleapis.com/auth/admin.directory.group.readonly` +* `https://www.googleapis.com/auth/admin.directory.group.member` +* `https://www.googleapis.com/auth/admin.directory.group.member.readonly` +* `https://www.googleapis.com/auth/admin.directory.resource.calendar` +* `https://www.googleapis.com/auth/admin.directory.resource.calendar.readonly` +* `https://www.googleapis.com/auth/apps.groups.settings` +* `https://www.googleapis.com/auth/apps.licensing` -Those scopes can be added in Admin console under *Security -> API Controls -> Domain-wide Delegation*. +The scopes can be added in Admin console under *Security -> API Controls -> Domain-wide Delegation*. -Furthermore, please, generate a Key (save the *.json* config) for this Service Account. -For more detailed information, follow [the official instructions](https://developers.google.com/admin-sdk/directory/v1/guides/delegation#create_the_service_account_and_credentials). +Furthermore please generate a Key (save the *.json* config) for this Service Account. For more detailed +information, follow [the official instructions](https://developers.google.com/admin-sdk/directory/v1/guides/delegation#create_the_service_account_and_credentials). -The Service Account private key must be provided to *GMan*. There are two ways to do so: - -- set up environmental variable: `GMAN_SERVICE_ACCOUNT_KEY=` -- start the application with specified flag `-private-key ` +The Service Account private key must be provided to *GMan* using the `-private-key` flag. ### Impersonated Email -Only users with access to the Admin APIs can access the Admin SDK Directory API, therefore the service account needs to impersonate one of the admin users. - -In order to delegate domain-wide authority to your service account follow this [official guide](https://developers.google.com/admin-sdk/directory/v1/guides/delegation#delegate_domain-wide_authority_to_your_service_account). +Only users with access to the Admin APIs can access the Admin SDK Directory API, therefore the service +account needs to impersonate one of the admin users. -The impersonated email must be specified in *GMan*. There are two ways to do so: +In order to delegate domain-wide authority to your service account follow this +[official guide](https://developers.google.com/admin-sdk/directory/v1/guides/delegation#delegate_domain-wide_authority_to_your_service_account). -- set up environmental variable: `GMAN_IMPERSONATED_EMAIL=` -- start the application with specified flag `-impersonated-email ` +The impersonated email must be specified in *GMan* using the `-impersonated-email` flag. ### Config YAML -All configuration of the users happens in a YAML file. See the [configuration documentation](/Configuration.md) for more information, available parameters and values, or refer to the annotated [config.example.yaml](/config.example.yaml) for the example usage. - -This file must be created beforehand with the minimal configuration, i.e. organization name specified. -In order to get the initial config of the users that are already in place in your Organizaiton, run *GMan* with `-export` flag specified, so the depicted on your side YAML can be populated. - -There are two ways to specify the path to the general configuration YAML file: - -- set up environmental variable: `GMAN_CONFIG_FILE=` -- start the application with specified flag `-config ` +All configuration happens in YAML file(s). See the [configuration documentation](/Configuration.md) for +more information, available parameters and values, or refer to the annotated +[config.example.yaml](/config.example.yaml) for the example usage. -The configuration can be splitted as well in different files: - -- users config file, specified by flag `-users-config ` -- groups config file, specified by flag `-groups-config ` -- organizational units config file, specified by flag `-orgunits-config ` - -Splitting the configuration allows as well to use *GMan* to manage only users, groups or organizational units, depending on the need. +The configuration can happen all in a single file, or be split into distinct files for users, groups and +org units. In all cases, the three flag `-users-config`, `-groups-config` and `-orgunits-config` must be +specified, though they can point to the same file. All three resources are always synchronized, it is +not possible to _just_ sync users. ## Usage @@ -108,14 +100,12 @@ After the completion of the steps above, *GMan* can perform for you: 1. [exporting](#exporting) existing users in the domain; 2. [validating](#validating) the config file; -3. [synchronizing](#synchronizing) (comparing the state) the users without executing the changes; -4. and [confirming synchronization](#confirming-synchronization). +3. [synchronizing](#synchronizing) the state of your GSuite organization ### Exporting -To get started, *GMan* can export your existing GSuite users into a configuration file. -For this to work, prepare a fresh configuration file and put your organisation name in it. -You can skip everything else: +To get started, *GMan* can export your existing GSuite users into a configuration file. For this to work, +prepare a fresh configuration file and put your organization name in it. You can skip everything else: ```yaml organization: myorganization @@ -124,11 +114,16 @@ organization: myorganization Now run *GMan* with the `-export` flag: ```bash -$ gman -config myconfig.yaml -export -2020/06/25 18:54:56 ► Exporting organization myorganization... -2020/06/25 18:54:56 ⇄ Exporting OrgUnits from GSuite... -2020/06/25 18:54:57 ⇄ Exporting users from GSuite... -2020/06/25 18:54:57 ⇄ Exporting groups from GSuite... +$ gman \ + -private-key MYKEY.json \ + -impersonated-email me@example.com \ + -users-config myconfig.yaml \ + -groups-config myconfig.yaml \ + -orgunits-config myconfig.yaml \ + -export +2020/06/25 18:54:56 ⇄ Exporting organizational units… +2020/06/25 18:54:57 ⇄ Exporting users… +2020/06/25 18:54:57 ⇄ Exporting groups… 2020/06/25 18:54:58 ✓ Export successful. ``` @@ -136,22 +131,19 @@ Afterwards, the `myconfig.yaml` will contain an exact representation of your org ```yaml organization: myorganization -org_units: +orgUnits: - name: Developers description: dedicated org unit for devs parentOrgUnitPath: / - org_unit_path: /Developers users: - - given_name: Josef - family_name: K - primary_email: josef@myorganization.com - secondary_email: josef@privatedomain.com - org_unit_path: /Developers - - given_name: Gregor - family_name: Samsa - primary_email: gregor@myorganization.com - secondary_email: gregor@privatedomain.com - org_unit_path: / + - givenName: Josef + familyName: K + primaryEmail: josef@myorganization.com + orgUnitPath: /Developers + - givenName: Gregor + familyName: Samsa + primaryEmail: gregor@myorganization.com + orgUnitPath: / groups: - name: Team GMan email: teamgman@myorganization.com @@ -178,22 +170,34 @@ It's possible to validate a configuration file for: - valid group members roles, - valid group members emails. -In order to validate the file, run *GMan* with the `-validate` flag: +In order to validate the file, run *GMan* with the `-validate` flag. In this case, the private +key and impersonated email can be omitted. ```bash -$ gman -config myconfig.yaml -validate +$ gman \ + -users-config myconfig.yaml \ + -groups-config myconfig.yaml \ + -orgunits-config myconfig.yaml \ + -validate 2020/06/17 19:24:49 ✓ Configuration is valid. ``` -If the config is valid, the program exits with code 0, otherwise with a non-zero code. -If this flag is specified, *GMan* performs **only** the config validation. Otherwise, validation takes place before every synchronization. +If the config is valid, the program exits with code 0, otherwise with a non-zero code. If this flag +is specified, *GMan* performs **only** the config validation. Otherwise, validation takes place +before every synchronization. ### Synchronizing -Synchronizing means updating GSuite's state to match the given configuration file. Without specifying the `-confirm` flag the changes are not performed: +Synchronizing means updating GSuite's state to match the given configuration file. Without +specifying the `-confirm` flag the changes are just previewed: ```bash -$ gman -config myconfig.yaml +$ gman \ + -private-key MYKEY.json \ + -impersonated-email me@example.com \ + -users-config myconfig.yaml \ + -groups-config myconfig.yaml \ + -orgunits-config myconfig.yaml 2020/06/25 18:55:54 ✓ Configuration is valid. 2020/06/25 18:55:54 ► Updating organization myorganization... 2020/06/25 18:55:54 ⇄ Syncing organizational units @@ -211,77 +215,30 @@ $ gman -config myconfig.yaml 2020/06/25 18:55:57 ⚠ Run again with -confirm to apply the changes above. ``` -### Confirming synchronization - -When running *GMan* with the `-confirm` flag the magic of synchronization happens! - - - The users, groups and org units - that have been depicted to be present in config file, but not in GSuite - are automatically created: - -```bash -$ gman -config myconfig.yaml -confirm -2020/06/25 18:59:47 ✓ Configuration is valid. -2020/06/25 18:59:47 ► Updating organization myorganization... -2020/06/25 18:59:47 ⇄ Syncing organizational units -2020/06/25 18:59:48 ✎ Creating... -2020/06/25 18:59:49 + org unit: NewOrgUnit -2020/06/25 18:59:49 ⇄ Syncing users -2020/06/25 18:59:50 ✎ Creating... -2020/06/25 18:59:51 + user: someonenew@myorganization.com -2020/06/25 18:59:51 ⇄ Syncing groups -2020/06/25 18:59:51 ✎ Creating... -2020/06/25 18:59:54 + group: NewGroup -2020/06/25 18:59:54 ✓ Organization successfully synchronized. -``` - -- The users, groups and org units - that hold different values in the config file, than they have in GSuite - are automatically updated: - -```bash -gman -config myconfig.yaml -confirm -2020/06/25 19:01:33 ✓ Configuration is valid. -2020/06/25 19:01:33 ► Updating organization myorganization... -2020/06/25 19:01:33 ⇄ Syncing organizational units -2020/06/25 19:01:34 ✎ Updating... -2020/06/25 19:01:35 ~ org unit: NewOrgUnit -2020/06/25 19:01:35 ⇄ Syncing users -2020/06/25 19:01:36 ✎ Updating... -2020/06/25 19:01:36 ~ user: someonenew@myorganization.com -2020/06/25 19:01:36 ⇄ Syncing groups -2020/06/25 19:01:37 ✎ Updating... -2020/06/25 19:01:38 ~ group: UpdatedGroup -2020/06/25 19:01:38 ✓ Organization successfully synchronized. -``` - -- The users, groups and org units - that are present in GSuite, but not in config file - are automatically deleted: - -```bash -$ gman -config myconfig.yaml -confirm -2020/06/25 19:06:04 ✓ Configuration is valid. -2020/06/25 19:06:04 ► Updating organization myorganization... -2020/06/25 19:06:04 ⇄ Syncing organizational units -2020/06/25 19:06:06 ✁ Deleting... -2020/06/25 19:06:07 - org unit: NewOrgUnit -2020/06/25 19:06:07 ⇄ Syncing users -2020/06/25 19:06:08 ✁ Deleting... -2020/06/25 19:06:08 - user: someonenew@myorganization.com -2020/06/25 19:06:08 ⇄ Syncing groups -2020/06/25 19:06:09 ✁ Deleting... -2020/06/25 19:06:10 - group: test group -2020/06/25 19:06:11 - group: UpdatedGroup -2020/06/25 19:06:11 ✓ Organization successfully synchronized. -``` +Run the same command again with `-confirm` to perform the changes. ## Limitations ### Sending the login info email to the new users -Due to the fact that it is impossible to automate the send out of the login information email via Google API there are two possibilities to enable the first log in of the new users: +Due to the fact that it is impossible to automate the send out of the login information +email via Google API there are two possibilities to enable the first log in of the new users: -- manually send the login information email from admin console via _RESET PASSWORD_ option (follow instructions on [this official Google documentation](https://support.google.com/a/answer/33319?hl=en)) -- set up a password recovery for users (follow [this official Google documentation](https://support.google.com/a/answer/33382?p=accnt_recovery_users&visit_id=637279854011127407-389630162&rd=1&hl=en) to perform it). This requires the `recovery_email` field to be set for the users. Hence, in the onboarding message the new users ought to be informed about their new GSuite email address and that on the first login, the _Forgot password?_ option should be chosen, so the verification code can be sent to to the private recovery email. +- manually send the login information email from admin console via _RESET PASSWORD_ option + (follow instructions on [this official Google documentation](https://support.google.com/a/answer/33319?hl=en)) +- set up a password recovery for users (follow [this official Google documentation](https://support.google.com/a/answer/33382?p=accnt_recovery_users&visit_id=637279854011127407-389630162&rd=1&hl=en) to perform it). + This requires the `recovery_email` field to be set for the users. Hence, in the onboarding + message the new users ought to be informed about their new GSuite email address and that on + the first login, the _Forgot password?_ option should be chosen, so the verification code + can be sent to to the private recovery email. ### API requests quota -In order to retrieve information about licenses of each user, there are multiple API requests performed. This can result in hitting the maximum limit of allowed calls per 100 seconds. In order to avoid it, *GMan* waits after every Enterprise Licensing API request for 0.5 second. This delay can be changed by starting the application with specified flag `-throttle-requests `, where value designates the waiting time in seconds. +In order to retrieve information about licenses of each user, there are multiple API requests +performed. This can result in hitting the maximum limit of allowed calls per 100 seconds. In +order to avoid it, *GMan* waits after every Enterprise Licensing API request for 0.5 second. +This delay can be changed by starting the application with specified flag +`-throttle-requests `, where value designates the waiting time in seconds (e.g. `5s`). ## Changelog From e0224641b9f8adaeef036afe152f1102bdbe5309 Mon Sep 17 00:00:00 2001 From: Christoph Mewes Date: Wed, 13 Jan 2021 17:51:58 +0100 Subject: [PATCH 15/18] add -licenses run mode --- Configuration.md | 3 ++- main.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/Configuration.md b/Configuration.md index 841bbd5..9dd0884 100644 --- a/Configuration.md +++ b/Configuration.md @@ -87,7 +87,8 @@ The official list of all the available products can be found in [the official Go GMan has a list of licenses built-in, but this can be overwritten by running gman with `-licenses-config `, which must be a YAML file that contains a list of licenses. -Run GMan with `-licenses` to see the list of default licenses. +Run GMan with `-licenses` to see the list of default licenses. If you also specify +`-licenses-yaml`, you get an output that can be directly used as a config file. Remark: *Cloud Identity Free Edition* is a site-wide SKU (applied at customer level), hence it cannot be managed by GMan as it is not assigned to individual users. diff --git a/main.go b/main.go index b53f110..78c8bc2 100644 --- a/main.go +++ b/main.go @@ -25,6 +25,7 @@ import ( "time" directoryv1 "google.golang.org/api/admin/directory/v1" + "gopkg.in/yaml.v3" "github.com/kubermatic-labs/gman/pkg/config" "github.com/kubermatic-labs/gman/pkg/export" @@ -51,6 +52,8 @@ type options struct { confirm bool validateAction bool exportAction bool + licensesAction bool + licensesYAML bool clientSecretFile string impersonatedUserEmail string throttleRequests time.Duration @@ -72,6 +75,8 @@ func main() { flag.BoolVar(&opt.versionAction, "version", false, "show the GMan version and exit") flag.BoolVar(&opt.validateAction, "validate", false, "validate the given configuration and then exit") flag.BoolVar(&opt.exportAction, "export", false, "export the state and update the config files (-[user|groups|orgunits]-config flags)") + flag.BoolVar(&opt.licensesAction, "licenses", false, "print the builtin licenses and then exit") + flag.BoolVar(&opt.licensesYAML, "licenses-yaml", false, "print the builtin licenses as YAML (use together with -licenses)") flag.BoolVar(&opt.confirm, "confirm", false, "must be set to actually perform any changes") flag.DurationVar(&opt.throttleRequests, "throttle-requests", 500*time.Millisecond, "the delay between Enterprise Licensing API requests") flag.Parse() @@ -81,6 +86,11 @@ func main() { return } + if opt.licensesAction { + licenseAction(opt.licensesYAML) + return + } + // open the files opt.usersConfig, err = config.LoadFromFile(opt.usersConfigFile) if err != nil { @@ -162,6 +172,25 @@ func main() { } } +func licenseAction(asYAML bool) { + if asYAML { + output := struct { + Licenses []config.License `yaml:"licenses"` + }{ + Licenses: config.AllLicenses, + } + + encoder := yaml.NewEncoder(os.Stdout) + encoder.SetIndent(2) + + encoder.Encode(output) + } else { + for _, license := range config.AllLicenses { + fmt.Printf("- %s (productID %q, SKU %q)\n", license.Name, license.ProductId, license.SkuId) + } + } +} + func syncAction( ctx context.Context, opt *options, From ce0fb6e72a3ae96935ac79a342b2fa16f93e6df0 Mon Sep 17 00:00:00 2001 From: Christoph Mewes Date: Wed, 13 Jan 2021 17:53:35 +0100 Subject: [PATCH 16/18] fix typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f701e27..788a06d 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,7 @@ email via Google API there are two possibilities to enable the first log in of t - manually send the login information email from admin console via _RESET PASSWORD_ option (follow instructions on [this official Google documentation](https://support.google.com/a/answer/33319?hl=en)) - set up a password recovery for users (follow [this official Google documentation](https://support.google.com/a/answer/33382?p=accnt_recovery_users&visit_id=637279854011127407-389630162&rd=1&hl=en) to perform it). - This requires the `recovery_email` field to be set for the users. Hence, in the onboarding + This requires the `recoveryEmail` field to be set for the users. Hence, in the onboarding message the new users ought to be informed about their new GSuite email address and that on the first login, the _Forgot password?_ option should be chosen, so the verification code can be sent to to the private recovery email. From 53d2fd723d2f52d807509b179e2ff96dfd1950b0 Mon Sep 17 00:00:00 2001 From: Christoph Mewes Date: Wed, 13 Jan 2021 17:56:23 +0100 Subject: [PATCH 17/18] cleanup --- README.md | 3 +- config.example.yaml | 107 -------------------------------------------- 2 files changed, 1 insertion(+), 109 deletions(-) delete mode 100644 config.example.yaml diff --git a/README.md b/README.md index 788a06d..ef33890 100644 --- a/README.md +++ b/README.md @@ -86,8 +86,7 @@ The impersonated email must be specified in *GMan* using the `-impersonated-emai ### Config YAML All configuration happens in YAML file(s). See the [configuration documentation](/Configuration.md) for -more information, available parameters and values, or refer to the annotated -[config.example.yaml](/config.example.yaml) for the example usage. +more information, available parameters and values. The configuration can happen all in a single file, or be split into distinct files for users, groups and org units. In all cases, the three flag `-users-config`, `-groups-config` and `-orgunits-config` must be diff --git a/config.example.yaml b/config.example.yaml deleted file mode 100644 index 41c98d1..0000000 --- a/config.example.yaml +++ /dev/null @@ -1,107 +0,0 @@ -# The organization for which this configuration applies; -# this must always be set. -organization: myorganization - -# The list of org units in this organization. Org units not defined -# here will be deleted during synchronization. -org_units: - - name: ExampleUnit - description: test org unit - parentOrgUnitPath: / # single slash indicates the parent org - org_unit_path: /ExampleUnit # single slash followed by the name of the OU - -# The list of users in this organization. Users not defined -# here will be deleted during synchronization. -users: - - #user 1 - given_name: Horst # first name; this field is required - family_name: Example # last name; this field is required - # the primary email is a GSuite address; must end with your organization's domain name - # primary_email field is always required - primary_email: horst@myorganization.com - # secondary, private email - secondary_email: horst@privateaddress.com - # org unit path indicates in which OU the user should be created - org_unit_path: / # single slash indicates parent org - # list of the user's alias email addresses - aliases: - - horst.example@myorganization.com - # list of the user's phone numbers - phones: - - "1234567890" - # recovery email is used to send out an invitation - recovery_email: horst@privateaddress.com # commonly the same as the secondary email - # recovery phone of the user; the phone number must be in the E.164 format, starting with the plus sign (+) - recovery_phone: "+16506661212" - # additional informaton about employee - employee_info: - employee_ID: 123 - department: development - job_title: working student - type: part-time - cost_center: cc1 - manager_email: gregor@myorganization.com - # additional information about employee location - location: - building: BuildingA - floor: 2 - floor_section: B12 - # private home address - addresses: Whatever Street 1000, Sometown - - - given_name: Gregor - family_name: Samsa - primary_email: gregor@myorganization.com - secondary_email: gregor@privatedomain.com - org_unit_path: /ExampleUnit # create user in beforehand specified OU - -# The list of groups and its members in this organization. -# Groups not defined here will be deleted during synchronization. -# Members not defined in the group will be removed from it during synchronization. -groups: - - name: Test Group - # email of the group; must end with your organization's domain name - email: testgroup@myorganization.com - # Permission to contact owner of the group via web UI. Possible values are: - # - ALL_IN_DOMAIN_CAN_CONTACT - # - ALL_MANAGERS_CAN_CONTACT - # - ALL_MEMBERS_CAN_CONTACT - # - ANYONE_CAN_CONTACT - who_can_contact_owner: ALL_MEMBERS_CAN_CONTACT - # Permissions to view group members. Possible values are: - # - ANYONE_CAN_VIEW: Any Internet user - # - ALL_IN_DOMAIN_CAN_VIEW: Anyone in your account - # - ALL_MEMBERS_CAN_VIEW: All group members - # - ALL_MANAGERS_CAN_VIEW: Any group manager - who_can_view_members: ALL_MEMBERS_CAN_VIEW - # Specifies who can approve members who ask to join groups. Possible values are: - # - ALL_MEMBERS_CAN_APPROVE - # - ALL_MANAGERS_CAN_APPROVE - # - ALL_OWNERS_CAN_APPROVE - # - NONE_CAN_APPROVE - who_can_approve_members: ALL_MANAGERS_CAN_APPROVE - # Permissions to post messages. Possible values are: - # - NONE_CAN_POST: The group is disabled and archived; 'is_archived' must be set to true, otherwise will result in an error - # - ALL_MANAGERS_CAN_POST: Managers, including group owners - # - ALL_MEMBERS_CAN_POST: Any group member - # - ALL_OWNERS_CAN_POST: Only group owners - # - ALL_IN_DOMAIN_CAN_POST: Anyone in the account - # - ANYONE_CAN_POST: Any Internet user who can access your Google Groups service - who_can_post: ALL_MEMBERS_CAN_POST - # Permission to join group. Possible values are: - # - ALL_IN_DOMAIN_CAN_JOIN: Anyone in the account domain - # - ANYONE_CAN_JOIN: Any Internet user who can access your Google Groups service - # - INVITED_CAN_JOIN: Candidates for membership can be invited to join - # - CAN_REQUEST_TO_JOIN: Non members can request an invitation to join - who_can_join: CAN_REQUEST_TO_JOIN - # Identifies whether members external to your organization can join the group. - # Possible values are: true or false - allow_external_members: false - # Allows the Group contents to be archived. Possible values are: true or false - is_archived: false - # list of members - members: - - email: horst@myorganization.com - role: MEMBER # role can be MEMBER, OWNER or MANAGER - - email: gregor@myorganization.com - role: OWNER From 1aa33426c30ea688ad31c94a9f5eab3f7dec7aaf Mon Sep 17 00:00:00 2001 From: Christoph Mewes Date: Thu, 4 Feb 2021 21:38:28 +0100 Subject: [PATCH 18/18] update goreleaser config --- .goreleaser.yml | 5 +++-- CHANGELOG.md | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index 0f5cd7c..43b8e0f 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -28,9 +28,10 @@ archives: - id: gman format: zip files: - - README.md + - CHANGELOG.md + - Configuration.md - LICENSE - - config.example.yaml + - README.md release: prerelease: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 51ffd09..9b6b3f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to this module will be documented in this file. -## [v0.5.0] - 2021-01-xx +## [v0.5.0] - 2021-02-04 * improved license handling speed * allow to omit default values