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/.goreleaser.yml b/.goreleaser.yml index 13188a6..43b8e0f 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: @@ -14,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 a4bd4f1..9b6b3f5 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-02-04 + +* 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/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..9dd0884 100644 --- a/Configuration.md +++ b/Configuration.md @@ -1,6 +1,6 @@ -# Configuration +# Configuration -*Here can be found all the configuration details of Gman.* +*Here are the configuration details of GMan.* **Table of contents:** @@ -9,170 +9,129 @@ - [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. - -Each OU contains: - -| 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 | - -### User Licenses +The organizational units (OU) are specified as the entries of the `orgUnits` collection. + +```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 + + - ... +``` + +## Users + +The users are specified as the entries of the `users` collection. + +```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. +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). -## 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 +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. If you also specify +`-licenses-yaml`, you get an output that can be directly used as a config file. -Permissions to view group members. Field name is `who_can_view_members`. The entered values are case sensitive. +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. -| 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. +## Groups -| 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 | +The groups are specified as the entries of the `groups` collection. + +```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 9023b3a..ef33890 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,17 @@ -# 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.* **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 your GSuite organization - export the current state as a starter config file -- preview any action taken (validation) +- preview any action taken **Table of contents:** -- [gman](#gman) +- [GMan](#gman) - [Installation](#installation) - [Configuration & Authentication](#configuration--authentication) - [Basics: Admin & Directory API](#basics-admin--directory-api) @@ -38,97 +39,90 @@ 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: +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 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. -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 -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; -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 ``` -Now run *Gman* with the `-export` flag: +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,24 +130,21 @@ 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 + - name: Team GMan email: teamgman@myorganization.com members: - email: josef@myorganization.com @@ -178,22 +169,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 +214,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 `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. ### 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 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 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/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 fad84ac..78c8bc2 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 ( @@ -8,11 +24,13 @@ import ( "os" "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" "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 +39,299 @@ var ( date = "unknown" ) -func main() { - ctx := context.Background() +type options struct { + usersConfigFile string + groupsConfigFile string + orgUnitsConfigFile string + licensesConfigFile string + usersConfig *config.Config + groupsConfig *config.Config + orgUnitsConfig *config.Config + licenseStatus *glib.LicenseStatus + versionAction bool + confirm bool + validateAction bool + exportAction bool + licensesAction bool + licensesYAML bool + clientSecretFile string + impersonatedUserEmail string + throttleRequests time.Duration + licenses []config.License +} +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.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") + 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() - 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 opt.licensesAction { + licenseAction(opt.licensesYAML) + return + } + + // 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) + } + + // load licenses + opt.licenses = config.AllLicenses + if opt.licensesConfigFile != "" { + licensesConfig, err := config.LoadFromFile(opt.licensesConfigFile) 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) - } + 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 !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) - } + orgName := opt.groupsConfig.Organization + 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.") } - 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) - } + // create glib services + ctx := context.Background() + readonly := opt.exportAction || !opt.confirm + scopes := getScopes(readonly) + + 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) } - // 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) + 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) + } + + 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.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 { + syncAction(ctx, &opt, directorySrv, licensingSrv, groupsSettingsSrv) + } +} + +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 { - 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) + 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, + directorySrv *glib.DirectoryService, + licensingSrv *glib.LicensingService, + groupsSettingsSrv *glib.GroupsSettingsService, +) { + orgUnitChanges, err := sync.SyncOrgUnits(ctx, directorySrv, opt.orgUnitsConfig, opt.confirm) + if err != nil { + log.Fatalf("⚠ Failed to sync: %v.", err) + } + + userChanges, err := sync.SyncUsers(ctx, directorySrv, licensingSrv, opt.usersConfig, opt.licenseStatus, 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 orgUnitChanges || userChanges || groupChanges { + log.Println("⚠ Run again with -confirm to apply the changes above.") + } else { + log.Println("✓ No changes necessary, organization is in sync.") + } +} + +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) + } - // 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.") - } + 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 := config.ValidateLicenses(opt.licenses); errs != nil { + log.Println("⚠ License configuration is invalid:") + for _, e := range errs { + log.Printf(" - %v", e) } + valid = false } - // 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) + 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 + } + + return valid +} - 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.") - } +func getScopes(readonly bool) []string { + if readonly { + return []string{ + directoryv1.AdminDirectoryUserReadonlyScope, + directoryv1.AdminDirectoryGroupReadonlyScope, + directoryv1.AdminDirectoryOrgunitReadonlyScope, + directoryv1.AdminDirectoryGroupMemberReadonlyScope, + directoryv1.AdminDirectoryResourceCalendarReadonlyScope, } } + + return []string{ + directoryv1.AdminDirectoryUserScope, + directoryv1.AdminDirectoryGroupScope, + directoryv1.AdminDirectoryOrgunitScope, + directoryv1.AdminDirectoryGroupMemberScope, + directoryv1.AdminDirectoryResourceCalendarScope, + } } diff --git a/pkg/config/config.go b/pkg/config/config.go index b92d8bc..f1567e0 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1,86 +1,204 @@ +/* +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 ( - "errors" - "fmt" "os" - "regexp" - "strings" + "sort" - "github.com/kubermatic-labs/gman/pkg/data" - "github.com/kubermatic-labs/gman/pkg/util" "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" +) + +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 { - 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:"orgUnits,omitempty"` + Users []User `yaml:"users,omitempty"` + Groups []Group `yaml:"groups,omitempty"` + Licenses []License `yaml:"licenses,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 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"` + 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"` } -type LocationConfig struct { +func (u *User) Sort() { + sort.Strings(u.Aliases) + sort.Strings(u.Phones) + sort.Strings(u.Licenses) +} + +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 EmployeeConfig struct { - EmployeeID string `yaml:"employee_ID,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"` - 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 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"` +func (e *Employee) Empty() bool { + return e.EmployeeID == "" && e.Department == "" && e.JobTitle == "" && e.Type == "" && e.CostCenter == "" && e.ManagerEmail == "" } -type MemberConfig struct { - Email string `yaml:"email"` - Role string `yaml:"role,omitempty"` +type Group struct { + Name string `yaml:"name"` + Email string `yaml:"email"` + Description string `yaml:"description,omitempty"` + 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,omitempty"` + IsArchived bool `yaml:"isArchived,omitempty"` + Members []Member `yaml:"members,omitempty"` } -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"` +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"` } 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 } @@ -90,6 +208,12 @@ func LoadFromFile(filename string) (*Config, error) { return nil, err } + // apply default values + config.DefaultOrgUnits() + config.DefaultUsers() + config.DefaultGroups() + config.Sort() + return config, nil } @@ -103,210 +227,15 @@ 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() + config.Sort() + 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 data.GoogleLicenses { - 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 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 { - return allTheErrors - } - - return nil -} diff --git a/pkg/config/conversion.go b/pkg/config/conversion.go new file mode 100644 index 0000000..14e16f2 --- /dev/null +++ b/pkg/config/conversion.go @@ -0,0 +1,320 @@ +/* +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 ( + "fmt" + "strconv" + + 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 { + gsuiteUser := &directoryv1.User{ + Name: &directoryv1.UserName{ + GivenName: user.FirstName, + FamilyName: user.LastName, + }, + PrimaryEmail: user.PrimaryEmail, + 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 { + 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.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 := "" + for _, email := range apiUser.Emails { + if email.Primary { + primaryEmail = email.Address + break + } + } + + user := User{ + 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 { + 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) + } + } + + user.Sort() + + 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)) + } + + group.Sort() + + 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/config/defaulting.go b/pkg/config/defaulting.go new file mode 100644 index 0000000..dbb93c8 --- /dev/null +++ b/pkg/config/defaulting.go @@ -0,0 +1,176 @@ +/* +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 ( + "sort" + "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 +} + +func (c *Config) Sort() { + sort.SliceStable(c.OrgUnits, func(i, j int) bool { + return strings.ToLower(c.OrgUnits[i].Name) < strings.ToLower(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 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 + } + + for idx, group := range c.Groups { + group.Sort() + c.Groups[idx] = group + } +} diff --git a/pkg/config/licenses.go b/pkg/config/licenses.go new file mode 100644 index 0000000..8499a65 --- /dev/null +++ b/pkg/config/licenses.go @@ -0,0 +1,200 @@ +/* +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 { + Name string `yaml:"name"` + ProductId string `yaml:"productId"` + SkuId string `yaml:"skuId"` +} + +// list of available GSuite Licenses +var AllLicenses = []License{ + { + ProductId: "Google-Apps", + SkuId: "1010020027", + Name: "GoogleWorkspaceBusinessStarter", + }, + + { + ProductId: "Google-Apps", + SkuId: "1010020028", + Name: "GoogleWorkspaceBusinessStandard", + }, + + { + ProductId: "Google-Apps", + 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: "Google-Apps", + SkuId: "Google-Apps-For-Business", + Name: "GSuiteBasic", + }, + + { + ProductId: "Google-Apps", + SkuId: "Google-Apps-Lite", + Name: "GSuiteLite", + }, + + { + ProductId: "Google-Apps", + SkuId: "Google-Apps-For-Postini", + Name: "GoogleAppsMessageSecurity", + }, + + { + ProductId: "101031", + SkuId: "1010310002", + Name: "GSuiteEnterpriseForEducation", + }, + + { + ProductId: "101031", + SkuId: "1010310003", + Name: "GSuiteEnterpriseForEducationStudent", + }, + + { + ProductId: "Google-Drive-storage", + SkuId: "Google-Drive-storage-20GB", + Name: "GoogleDriveStorage20GB", + }, + + { + ProductId: "Google-Drive-storage", + SkuId: "Google-Drive-storage-50GB", + Name: "GoogleDriveStorage50GB", + }, + + { + ProductId: "Google-Drive-storage", + SkuId: "Google-Drive-storage-200GB", + Name: "GoogleDriveStorage200GB", + }, + + { + ProductId: "Google-Drive-storage", + SkuId: "Google-Drive-storage-400GB", + Name: "GoogleDriveStorage400GB", + }, + + { + ProductId: "Google-Drive-storage", + SkuId: "Google-Drive-storage-1TB", + Name: "GoogleDriveStorage1TB", + }, + + { + ProductId: "Google-Drive-storage", + SkuId: "Google-Drive-storage-2TB", + Name: "GoogleDriveStorage2TB", + }, + + { + ProductId: "Google-Drive-storage", + SkuId: "Google-Drive-storage-4TB", + Name: "GoogleDriveStorage4TB", + }, + + { + ProductId: "Google-Drive-storage", + SkuId: "Google-Drive-storage-8TB", + Name: "GoogleDriveStorage8TB", + }, + + { + ProductId: "Google-Drive-storage", + SkuId: "Google-Drive-storage-16TB", + 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", + SkuId: "1010050001", + Name: "CloudIdentityPremium", + }, + + { + ProductId: "101033", + SkuId: "1010330003", + Name: "GoogleVoiceStarter", + }, + + { + ProductId: "101033", + SkuId: "1010330004", + Name: "GoogleVoiceStandard", + }, + + { + ProductId: "101033", + SkuId: "1010330002", + Name: "GoogleVoicePremier", + }, +} diff --git a/pkg/config/validation.go b/pkg/config/validation.go new file mode 100644 index 0000000..6ea83d8 --- /dev/null +++ b/pkg/config/validation.go @@ -0,0 +1,229 @@ +/* +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 ( + "errors" + "fmt" + "regexp" + "strings" + + "k8s.io/apimachinery/pkg/util/sets" +) + +// 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 := sets.NewString() + for _, user := range c.Users { + 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)) + } 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.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)) + } + } + } + } + + 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 := sets.NewString() + for _, group := range c.Groups { + 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)) + } + + 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 := sets.NewString() + for _, member := range group.Members { + if memberEmails.Has(member.Email) { + allErrors = append(allErrors, fmt.Errorf("[group: %s] duplicate member %q defined", group.Name, member.Email)) + } + memberEmails.Insert(member.Email) + + 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())) + } + } + } + + 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 := sets.NewString() + for _, orgUnit := range c.OrgUnits { + 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)) + } + + 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 +} + +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/data/data.go b/pkg/data/data.go deleted file mode 100644 index 12efd00..0000000 --- a/pkg/data/data.go +++ /dev/null @@ -1,126 +0,0 @@ -package data - -type License struct { - ProductId string - SkuId string - Name string // used in yaml -} - -// list of available Gsuite Licenses -var GoogleLicenses = []License{ - { - ProductId: "Google-Apps", - SkuId: "1010020020", // G Suite Enterprise - Name: "GSuiteEnterprise", - }, - { - ProductId: "Google-Apps", - SkuId: "Google-Apps-Unlimited", // G Suite Business - Name: "GSuiteBusiness", - }, - { - ProductId: "Google-Apps", - SkuId: "Google-Apps-For-Business", // G Suite Basic - Name: "GSuiteBasic", - }, - { - ProductId: "101006", - SkuId: "1010060001", // G Suite Essentials - Name: "GSuiteEssentials", - }, - { - ProductId: "Google-Apps", - SkuId: "Google-Apps-Lite", // G Suite Lite - Name: "GSuiteLite", - }, - { - ProductId: "Google-Apps", - SkuId: "Google-Apps-For-Postini", // Google Apps Message Security - Name: "GoogleAppsMessageSecurity", - }, - { - ProductId: "101031", // G Suite Enterprise for Education - SkuId: "1010310002", // G Suite Enterprise for Education - Name: "GSuiteEducation", - }, - { - ProductId: "101031", // G Suite Enterprise for Education - SkuId: "1010310003", // G Suite Enterprise for Education (Student) - Name: "GSuiteEducationStudent", - }, - { - ProductId: "Google-Drive-storage", - SkuId: "Google-Drive-storage-20GB", - Name: "GoogleDrive20GB", - }, - { - ProductId: "Google-Drive-storage", - SkuId: "Google-Drive-storage-50GB", - Name: "GoogleDrive50GB", - }, - { - ProductId: "Google-Drive-storage", - SkuId: "Google-Drive-storage-200GB", - Name: "GoogleDrive200GB", - }, - { - ProductId: "Google-Drive-storage", - SkuId: "Google-Drive-storage-400GB", - Name: "GoogleDrive400GB", - }, - { - ProductId: "Google-Drive-storage", - SkuId: "Google-Drive-storage-1TB", - Name: "GoogleDrive1TB", - }, - { - ProductId: "Google-Drive-storage", - SkuId: "Google-Drive-storage-2TB", - Name: "GoogleDrive2TB", - }, - { - ProductId: "Google-Drive-storage", - SkuId: "Google-Drive-storage-4TB", - Name: "GoogleDrive4TB", - }, - { - ProductId: "Google-Drive-storage", - SkuId: "Google-Drive-storage-8TB", - Name: "GoogleDrive8TB", - }, - { - ProductId: "Google-Drive-storage", - SkuId: "Google-Drive-storage-16TB", - Name: "GoogleDrive16TB", - }, - { - ProductId: "Google-Vault", - SkuId: "Google-Vault", - Name: "GoogleVault", - }, - { - ProductId: "Google-Vault", - SkuId: "Google-Vault-Former-Employee", - Name: "GoogleVaultFormerEmployee", - }, - { - ProductId: "101005", // Cloud Identity Premium - SkuId: "1010050001", - Name: "CloudIdentityPremium", - }, - { - ProductId: "101033", // Google Voice - SkuId: "1010330003", - Name: "GoogleVoiceStarter", - }, - { - ProductId: "101033", // Google Voice - SkuId: "1010330004", - Name: "GoogleVoiceStandard", - }, - { - ProductId: "101033", // Google Voice - SkuId: "1010330002", - Name: "GoogleVoicePremier", - }, -} diff --git a/pkg/export/export.go b/pkg/export/export.go index 61df9e8..067661b 100644 --- a/pkg/export/export.go +++ b/pkg/export/export.go @@ -1,119 +1,95 @@ +/* +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 ( "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.UserConfig{} - - // 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 := []config.OrgUnit{} + for _, ou := range orgUnits { + log.Printf(" %s", ou.Name) + result = append(result, config.ToConfigOrgUnit(ou)) } - return nil + 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.GroupConfig{} - - // 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) + + result := []config.User{} + for _, user := range users { + log.Printf(" %s", user.PrimaryEmail) + + userLicenses := licenseStatus.GetLicensesForUser(user) + + configUser, err := config.ToConfigUser(user, userLicenses) + if err != nil { + return nil, fmt.Errorf("failed to convert user: %v", err) } - sort.Slice(cfg.Groups, func(i, j int) bool { - return cfg.Groups[i].Name < cfg.Groups[j].Name - }) + result = append(result, configUser) } - return nil + 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.OrgUnitConfig{} - - // 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.OrgUnitConfig{ - Name: ou.Name, - Description: ou.Description, - ParentOrgUnitPath: ou.ParentOrgUnitPath, - BlockInheritance: ou.BlockInheritance, - OrgUnitPath: ou.OrgUnitPath, - }) + 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) + } + + members, err := directorySrv.ListMembers(ctx, group) + if err != nil { + return nil, fmt.Errorf("failed to list members: %v", err) + } + + configGroup, err := config.ToConfigGroup(group, settings, members) + if err != nil { + return nil, fmt.Errorf("failed to create config group: %v", err) } - sort.Slice(cfg.OrgUnits, func(i, j int) bool { - return cfg.OrgUnits[i].Name < cfg.OrgUnits[j].Name - }) + result = append(result, configGroup) } - return nil + return result, nil } diff --git a/pkg/glib/directory.go b/pkg/glib/directory.go new file mode 100644 index 0000000..17ec12d --- /dev/null +++ b/pkg/glib/directory.go @@ -0,0 +1,64 @@ +/* +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 + +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 + + organization string + delay time.Duration +} + +// 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) + } + + 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, + organization: organization, + 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..e3d2f88 --- /dev/null +++ b/pkg/glib/directory_groups.go @@ -0,0 +1,137 @@ +/* +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 + +import ( + "context" + "sort" + "strings" + + directoryv1 "google.golang.org/api/admin/directory/v1" +) + +// 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, err + } + + groups = append(groups, response.Groups...) + + token = response.NextPageToken + if token == "" { + break + } + } + + sort.SliceStable(groups, func(i, j int) bool { + return strings.ToLower(groups[i].Name) < strings.ToLower(groups[j].Name) + }) + + 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, 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 err + } + + return nil +} + +// UpdateGroup updates the remote group with config +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, err + } + + return updatedGroup, nil +} + +// 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, err + } + + members = append(members, response.Members...) + + token = response.NextPageToken + if token == "" { + break + } + } + + sort.SliceStable(members, func(i, j int) bool { + return members[i].Email < members[j].Email + }) + + return members, nil +} + +// 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 err + } + + return nil +} + +// 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 err + } + + return nil +} + +// 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 err + } + + return nil +} diff --git a/pkg/glib/directory_orgunits.go b/pkg/glib/directory_orgunits.go new file mode 100644 index 0000000..22b09b4 --- /dev/null +++ b/pkg/glib/directory_orgunits.go @@ -0,0 +1,64 @@ +/* +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 + +import ( + "context" + "sort" + "strings" + + directoryv1 "google.golang.org/api/admin/directory/v1" +) + +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, 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 +} + +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 err + } + + return nil +} + +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 err + } + + return 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 err + } + + return nil +} diff --git a/pkg/glib/directory_users.go b/pkg/glib/directory_users.go new file mode 100644 index 0000000..cc11a2c --- /dev/null +++ b/pkg/glib/directory_users.go @@ -0,0 +1,139 @@ +/* +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 + +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/util" +) + +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, err + } + + users = append(users, response.Users...) + + token = response.NextPageToken + if token == "" { + break + } + } + + sort.SliceStable(users, func(i, j int) bool { + return users[i].PrimaryEmail < users[j].PrimaryEmail + }) + + return users, nil +} + +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) + } + + user.Password = pass + user.ChangePasswordAtNextLogin = true + + createdUser, err := ds.Users.Insert(user).Context(ctx).Do() + if err != nil { + return nil, fmt.Errorf("unable to create user: %v", 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 err + } + + return nil +} + +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, err + } + + return updatedUser, nil +} + +type aliases struct { + Aliases []struct { + Alias string `json:"alias"` + PrimaryEmail string `json:"primaryEmail"` + } `json:"aliases"` +} + +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 := util.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 *directoryv1.User, alias string) error { + newAlias := &directoryv1.Alias{ + Alias: alias, + } + + if _, err := ds.Users.Aliases.Insert(user.PrimaryEmail, newAlias).Context(ctx).Do(); err != nil { + return err + } + + return nil +} + +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 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/glib.go b/pkg/glib/glib.go deleted file mode 100644 index 801246a..0000000 --- a/pkg/glib/glib.go +++ /dev/null @@ -1,845 +0,0 @@ -// Package glib contains methods for interactions with GSuite API -package glib - -import ( - "context" - "fmt" - "io/ioutil" - "strconv" - "strings" - "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.UserConfig) 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.UserConfig) 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.UserConfig) *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.EmployeeConfig{}) { - 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.LocationConfig{}) { - 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.UserConfig { - // get emails - primaryEmail, secondaryEmail := GetUserEmails(googleUser) - - configUser := config.UserConfig{ - 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.GroupConfig) 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.GroupConfig) 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.GroupConfig) (*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.GroupConfig, 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) - } - 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) - } - - configGroup := config.GroupConfig{ - 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.MemberConfig{}, - } - - for _, m := range members { - configGroup.Members = append(configGroup.Members, config.MemberConfig{ - 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.MemberConfig) 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.MemberConfig) (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.MemberConfig) 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.MemberConfig) *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.OrgUnitConfig) 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.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() - 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.OrgUnitConfig) *admin.OrgUnit { - googleOU := &admin.OrgUnit{ - Name: ou.Name, - //OrgUnitPath: ou.OrgUnitPath, - ParentOrgUnitPath: ou.ParentOrgUnitPath, - } - if ou.Description != "" { - googleOU.Description = ou.Description - } - - return googleOU -} - -//----------------------------------------// -// 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..43457da --- /dev/null +++ b/pkg/glib/groupssettings.go @@ -0,0 +1,81 @@ +/* +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 + +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. +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: %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, 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, err + } + + return updatedSettings, nil +} diff --git a/pkg/glib/licensing.go b/pkg/glib/licensing.go new file mode 100644 index 0000000..9ad5e95 --- /dev/null +++ b/pkg/glib/licensing.go @@ -0,0 +1,182 @@ +/* +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 + +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 + + organization string + licenses []config.License + delay time.Duration +} + +// 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) + } + + 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, + organization: organization, + 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 +} + +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{} + token := "" + + for { + // 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 { + return nil, 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 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 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/sync/compare.go b/pkg/sync/compare.go new file mode 100644 index 0000000..c3d4376 --- /dev/null +++ b/pkg/sync/compare.go @@ -0,0 +1,72 @@ +/* +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 ( + "reflect" + + 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 { + converted := config.ToConfigOrgUnit(live) + + return reflect.DeepEqual(configured, converted) +} + +func userUpToDate(configured config.User, live *directoryv1.User, liveLicenses []config.License, liveAliases []string) bool { + converted, err := config.ToConfigUser(live, liveLicenses) + if err != nil { + return false + } + + if converted.Aliases == nil { + converted.Aliases = []string{} + } + + 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 { + 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 reflect.DeepEqual(configured, converted) +} + +func memberUpToDate(configured config.Member, live *directoryv1.Member) bool { + converted := config.ToConfigGroupMember(live) + + return reflect.DeepEqual(configured, converted) +} diff --git a/pkg/sync/groups.go b/pkg/sync/groups.go new file mode 100644 index 0000000..5033077 --- /dev/null +++ b/pkg/sync/groups.go @@ -0,0 +1,196 @@ +/* +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 ( + "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, +) (bool, error) { + changes := false + + log.Println("⇄ Syncing groups…") + + liveGroups, err := directorySrv.ListGroups(ctx) + if err != nil { + return changes, err + } + + liveGroupEmails := sets.NewString() + + for _, liveGroup := range liveGroups { + liveGroupEmails.Insert(liveGroup.Email) + + found := false + + for _, expectedGroup := range cfg.Groups { + if expectedGroup.Email == liveGroup.Email { + found = true + + liveMembers, err := directorySrv.ListMembers(ctx, liveGroup) + if err != nil { + return changes, fmt.Errorf("failed to fetch members: %v", err) + } + + liveSettings, err := groupsSettingsSrv.GetSettings(ctx, liveGroup.Email) + if err != nil { + return changes, fmt.Errorf("failed to fetch group settings: %v", err) + } + + if groupUpToDate(expectedGroup, liveGroup, liveMembers, liveSettings) { + // no update needed + 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, liveGroup, group) + if err != nil { + return changes, fmt.Errorf("failed to update group: %v", err) + } + + if _, err := groupsSettingsSrv.UpdateSettings(ctx, group, settings); err != nil { + return changes, fmt.Errorf("failed to update group settings: %v", err) + } + } + + if err := syncGroupMembers(ctx, directorySrv, &expectedGroup, group, liveMembers, confirm); err != nil { + return changes, fmt.Errorf("failed to sync members: %v", err) + } + } + + break + } + } + + if !found { + changes = true + log.Printf(" - %s", liveGroup.Email) + + if confirm { + if err := directorySrv.DeleteGroup(ctx, liveGroup); err != nil { + return changes, fmt.Errorf("failed to delete group: %v", err) + } + } + } + } + + for _, expectedGroup := range cfg.Groups { + if !liveGroupEmails.Has(expectedGroup.Email) { + changes = true + log.Printf(" + %s", expectedGroup.Email) + + group, settings := config.ToGSuiteGroup(&expectedGroup) + + if confirm { + group, err = directorySrv.CreateGroup(ctx, group) + if err != nil { + return changes, fmt.Errorf("failed to create group: %v", err) + } + + if _, err := groupsSettingsSrv.UpdateSettings(ctx, group, settings); err != nil { + return changes, fmt.Errorf("failed to update group settings: %v", err) + } + } + + if err := syncGroupMembers(ctx, directorySrv, &expectedGroup, group, nil, confirm); err != nil { + return changes, fmt.Errorf("failed to sync members: %v", err) + } + } + } + + return changes, 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, + expectedGroup *config.Group, + liveGroup *directoryv1.Group, + liveMembers []*directoryv1.Member, + confirm bool, +) error { + liveMemberEmails := sets.NewString() + + for _, liveMember := range liveMembers { + liveMemberEmails.Insert(liveMember.Email) + + expectedMember := getConfiguredMember(expectedGroup, liveMember) + + if expectedMember == nil { + log.Printf(" - %s", liveMember.Email) + + if confirm { + if err := directorySrv.RemoveMember(ctx, liveGroup, liveMember); err != nil { + return fmt.Errorf("unable to remove member: %v", err) + } + } + } else if !memberUpToDate(*expectedMember, liveMember) { + log.Printf(" ✎ %s", liveMember.Email) + + if confirm { + member := config.ToGSuiteGroupMember(expectedMember) + if err := directorySrv.UpdateMembership(ctx, liveGroup, member); err != nil { + return fmt.Errorf("unable to update membership: %v", err) + } + } + } + } + + for _, expectedMember := range expectedGroup.Members { + if !liveMemberEmails.Has(expectedMember.Email) { + log.Printf(" + %s", expectedMember.Email) + + if confirm { + member := config.ToGSuiteGroupMember(&expectedMember) + if err := directorySrv.AddNewMember(ctx, liveGroup, member); 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..476ee57 --- /dev/null +++ b/pkg/sync/licensing.go @@ -0,0 +1,93 @@ +/* +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 ( + "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, + expectedUser *config.User, + liveUser *directoryv1.User, + licenseStatus *glib.LicenseStatus, + confirm bool, +) error { + expectedLicenses := expectedUser.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 _, liveLicense := range liveLicenses { + if !userHasLicense(expectedUser, liveLicense) { + log.Printf(" - license %s", liveLicense.Name) + + if confirm { + if err := licenseSrv.UnassignLicense(ctx, liveUser, liveLicense); err != nil { + return fmt.Errorf("unable to assign license: %v", err) + } + } + } + } + + for _, expectedLicense := range expectedLicenses { + if !sliceContainsLicense(liveLicenses, expectedLicense) { + license := licenseSrv.GetLicenseByName(expectedLicense) + 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..655ed11 --- /dev/null +++ b/pkg/sync/orgunits.go @@ -0,0 +1,103 @@ +/* +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 ( + "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, +) (bool, error) { + changes := false + log.Println("⇄ Syncing organizational units…") + + liveOrgUnits, err := directorySrv.ListOrgUnits(ctx) + if err != nil { + return changes, err + } + + liveNames := sets.NewString() + + for _, liveOrgUnit := range liveOrgUnits { + liveNames.Insert(liveOrgUnit.Name) + + found := false + + for _, expectedOrgUnit := range cfg.OrgUnits { + if expectedOrgUnit.Name == liveOrgUnit.Name { + found = true + + if orgUnitUpToDate(expectedOrgUnit, liveOrgUnit) { + // no update needed + 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 changes, fmt.Errorf("failed to update org unit: %v", err) + } + } + } + + break + } + } + + if !found { + changes = true + log.Printf(" - %s", liveOrgUnit.Name) + + if confirm { + err := directorySrv.DeleteOrgUnit(ctx, liveOrgUnit) + if err != nil { + return changes, fmt.Errorf("failed to delete org unit: %v", err) + } + } + } + } + + 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 changes, fmt.Errorf("failed to create org unit: %v", err) + } + } + } + } + + return changes, nil +} diff --git a/pkg/sync/sync.go b/pkg/sync/sync.go deleted file mode 100644 index 48ea41d..0000000 --- a/pkg/sync/sync.go +++ /dev/null @@ -1,472 +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.UserConfig - usersToUpdate []config.UserConfig - ) - - 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.GroupConfig - membersToAdd []*config.MemberConfig - membersToRemove []*admin.Member - membersToUpdate []*config.MemberConfig -} - -// 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 - 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.GroupConfig, curGr *admin.Group) ([]*config.MemberConfig, []*admin.Member, []*config.MemberConfig) { - var memToAdd []*config.MemberConfig - var memToUpdate []*config.MemberConfig - 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.OrgUnitConfig - ouToUpdate []config.OrgUnitConfig - ) - - 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..2e08e53 --- /dev/null +++ b/pkg/sync/users.go @@ -0,0 +1,172 @@ +/* +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 ( + "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, +) (bool, error) { + changes := false + + log.Println("⇄ Syncing users…") + + liveUsers, err := directorySrv.ListUsers(ctx) + if err != nil { + return changes, err + } + + liveEmails := sets.NewString() + + for _, liveUser := range liveUsers { + liveEmails.Insert(liveUser.PrimaryEmail) + + found := false + + for _, expectedUser := range cfg.Users { + if expectedUser.PrimaryEmail == liveUser.PrimaryEmail { + found = true + + currentUserLicenses := licenseStatus.GetLicensesForUser(liveUser) + + currentAliases, err := directorySrv.GetUserAliases(ctx, liveUser) + if err != nil { + return changes, fmt.Errorf("failed to fetch aliases: %v", err) + } + + if userUpToDate(expectedUser, liveUser, currentUserLicenses, currentAliases) { + // no update needed + 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, liveUser, apiUser) + if err != nil { + return changes, fmt.Errorf("failed to update user: %v", err) + } + } + + if err := syncUserAliases(ctx, directorySrv, &expectedUser, updatedUser, currentAliases, confirm); err != nil { + return changes, fmt.Errorf("failed to sync aliases: %v", err) + } + + if err := syncUserLicenses(ctx, licensingSrv, &expectedUser, updatedUser, licenseStatus, confirm); err != nil { + return changes, fmt.Errorf("failed to sync licenses: %v", err) + } + } + + break + } + } + + if !found { + changes = true + log.Printf(" - %s", liveUser.PrimaryEmail) + + if confirm { + if err := directorySrv.DeleteUser(ctx, liveUser); err != nil { + return changes, fmt.Errorf("failed to delete user: %v", err) + } + } + } + } + + for _, expectedUser := range cfg.Users { + if !liveEmails.Has(expectedUser.PrimaryEmail) { + changes = true + log.Printf(" + %s", expectedUser.PrimaryEmail) + + var createdUser *directoryv1.User + + if confirm { + apiUser := config.ToGSuiteUser(&expectedUser) + createdUser, err = directorySrv.CreateUser(ctx, apiUser) + if err != nil { + return changes, fmt.Errorf("failed to create user: %v", err) + } + } + + if err := syncUserAliases(ctx, directorySrv, &expectedUser, createdUser, nil, confirm); err != nil { + return changes, fmt.Errorf("failed to sync aliases: %v", err) + } + + if err := syncUserLicenses(ctx, licensingSrv, &expectedUser, createdUser, licenseStatus, confirm); err != nil { + return changes, fmt.Errorf("failed to sync licenses: %v", err) + } + } + } + + return changes, nil +} + +func syncUserAliases( + ctx context.Context, + directorySrv *glib.DirectoryService, + expectedUser *config.User, + liveUser *directoryv1.User, + liveAliases []string, + confirm bool, +) error { + expectedAliases := sets.NewString(expectedUser.Aliases...) + liveAliasesSet := sets.NewString(liveAliases...) + + for _, liveAlias := range liveAliases { + if !expectedAliases.Has(liveAlias) { + log.Printf(" - alias %s", liveAlias) + + if confirm { + if err := directorySrv.DeleteUserAlias(ctx, liveUser, liveAlias); err != nil { + return fmt.Errorf("unable to delete alias: %v", err) + } + } + } + } + + for _, expectedAlias := range expectedAliases.List() { + if !liveAliasesSet.Has(expectedAlias) { + log.Printf(" + alias %s", expectedAlias) + + if confirm { + if err := directorySrv.CreateUserAlias(ctx, liveUser, expectedAlias); err != nil { + return fmt.Errorf("unable to create alias: %v", err) + } + } + } + } + + return nil +} diff --git a/pkg/util/util.go b/pkg/util/util.go index c87e76e..8d3329c 100644 --- a/pkg/util/util.go +++ b/pkg/util/util.go @@ -1,11 +1,35 @@ +/* +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 -func StringSliceContains(s []string, needle string) bool { - for _, item := range s { - if item == needle { - return true - } +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 false + return nil }