Post

Automating Google Workspace with Ansible

Using Google Education for a school to automate users, groups, drives,… creation with Ansible + new to GCPs overwhelming possibilties and not so consistent API designs was a bit of a bumpy ride.

At the end though everything is working as it should be and here I want to present a small example which might be useful to others.

Google Workspace/GCP

First thing was to make the authentication working. A good starting point is the Google Workspace for Developers documentation.

I created a service account with domain-wide delegation.

For the API requests a JSON Web Token is necessary. This small ruby script jwt.rb will do the work:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#!/usr/bin/env ruby
# frozen_string_literal: true

require 'jwt'
require 'optparse'

options = {}
OptionParser.new do |opts|
  opts.banner = 'Usage: jwt.rb [options]'

  opts.on('--iss ISS', 'Issuer') do |iss|
    options[:iss] = iss
  end
  opts.on('--sub SUB', 'Subject') do |sub|
    options[:sub] = sub
  end
  opts.on('--scope SCOPE', 'API Scopes') do |scope|
    options[:scope] = scope
  end
  opts.on('--kid KID', 'Key id') do |kid|
    options[:kid] = kid
  end
  opts.on('--pkey PKEY', 'Key') do |pkey|
    options[:pkey] = pkey
  end
end.parse!

iat = Time.now.to_i
exp = iat + 900 # token is 900s valid

payload = { iss: options[:iss].to_s,
            sub: options[:sub].to_s,
            scope: options[:scope].to_s,
            aud: 'https://oauth2.googleapis.com/token',
            kid: options[:kid].to_s,
            exp: exp,
            iat: iat }

pkey = options[:pkey].to_s

priv_key = OpenSSL::PKey::RSA.new(pkey)

token = JWT.encode(payload, priv_key, 'RS256')

puts token

Ansible

Now it’s time to make the first API request.

JSON Web Token

At the beginning a token needs to be retrieved from GCP.

The JWT needs to be told which endpoints he can access via the scopes. The necessary scopes are listed in the API ref, like that for the Shared Contacts API we use for our example:

1
2
3
4
5
6
7
# defaults/main.yml
---
gsuite_domain: 'skynet.com'
gsuite_oauth2_api_scopes:
  - 'https://www.google.com/m8/feeds/contacts'

gsuite_oauth2_grant_type: "urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer"

We retrieve the token from GCP and then set is a fact for subsequent calls.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# tasks/get_token.yml
---
- name: 'create jwt'
  command: >
    env ruby "{{ role_path }}"/files/jwt.rb --iss "{{ vault_gsuite_service_account['iss'] }}"
    --sub "{{ vault_gsuite_service_account['sub'] }}" --scope "{{ gsuite_oauth2_api_scopes | join(' ') }}"
    --kid "{{ vault_gsuite_service_account['private_key_id'] }}"
    --pkey "{{ vault_gsuite_service_account['private_key'] }}"
  args: { chdir: '/usr/bin/' }
  changed_when: false
  no_log: true
  register: jwt

- name: 'get access token from google oauth2'
  uri:
    url: 'https://oauth2.googleapis.com/token'
    method: POST
    body: "grant_type={{ gsuite_oauth2_grant_type }}&assertion={{ jwt.stdout }}"
    return_content: true
  register: get_token
  no_log: true

- name: 'set access token as fact'
  set_fact:
    gsuite_access_token: "{{ get_token.json.access_token }}"
  no_log: true

Create Domain Shared Contacts

Two simple contacts will be created which will be visible to all users of the workspace domain:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# contacts.yml
---
gsuite_contacts:
  - name: 'arnold.schwarzenegger@skynet.com'
    description: 'T-800'
    first_name: 'arnold'
    last_name: 'schwarzenegger'
    options:
      phones: [{ type: 'mobile', value: '1800' }]

  - name: 'robert.patrick@skynet'
    description: 'T-1000'
    first_name: 'robert'
    last_name: 'patrick'
    options:
      organizations: { name: 'Skynet', description: 'Shapeshifting android assassin' }
      phones: [{ type: 'mobile', value: '1100' }, { type: 'work', value: '1100-2' }]

Here I want to show one part about inconsistency - the other one is the varying response structure between the APIs.

While the majority of the endpoints is fine with JSON this requires XML (Atom). It’s not a big thing but I didn’t expect it and fiddling with XML was a long time ago…

The first task will retrieve all shared contacts as json - default would be XML.

Second will create the contact sif email address isn’t in the output of get_contacts.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# task/configure_contacts.yml
---
- name: 'get contacts'
  uri:
    url: "{{ gsuite_api_contacts_url }}/{{ gsuite_domain }}/full?alt=json"
    return_content: true
    headers:
      authorization: "Bearer {{ gsuite_access_token }}"
  register: get_contacts

- name: 'create contacts'
  uri:
    url: "{{ gsuite_api_contacts_url }}/{{ gsuite_domain }}/full"
    method: POST
    body:
      <entry xmlns='http://www.w3.org/2005/Atom' xmlns:gd='http://schemas.google.com/g/2005'>
        <category scheme='http://schemas.google.com/g/2005#kind' term='http://schemas.google.com/contact/2008#contact'/>
        <gd:email rel='http://schemas.google.com/g/2005#work' address='{{ item.name }}'/>
        <gd:name>
          <gd:givenName>{{ item.first_name | title }}</gd:givenName>
          <gd:familyName>{{ item.last_name | title }}</gd:familyName>
        </gd:name>
      </entry>
    return_content: true
    status_code: 201
    headers:
      authorization: "Bearer {{ gsuite_access_token }}"
      GData-Version: "3.0"
      Content-Type: "application/atom+xml"
  loop: "{{ gsuite_contacts }}"
  loop_control: { label: "{{ item.name }}" }
  changed_when: item is not skipped
  notify: 'get contacts'
  when:
    - item.ensure is not defined
    - item.name not in get_contacts.content

- meta: flush_handlers # retrieve get_contacts again if new one has been created

To update or delete a shared contact you need the full url to the contact which is presented in the API response. The interesting part of the response (href) looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
  "json": {
    "encoding": "UTF-8",
    "feed": {
      "entry": [
        {
          "gd$email": [
            {
              "address": "arnold.schwarzenegger@skynet.com",
              "rel": "http://schemas.google.com/g/2005#work"
            }
          ],
          "link": [
            {
              "href": "https://www.google.com/m8/feeds/contacts/skynet.com/full/3512e4940ebdd491/1634995798300409",
              "rel": "edit",
              "type": "application/atom+xml"
            }
          ]
        }
      ]
    }
  }
}

This is a proper job for the json_query filter.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
- name: 'configure contacts'
  uri:
    url: "{{ get_contacts | json_query('json.feed.entry[?\"gd$email\"[?address==`' ~ item.name ~ '`]][link[?rel==`edit`]][][].href | [0]' ) }}"
    method: PUT
    body:
      <entry xmlns='http://www.w3.org/2005/Atom' xmlns:gd='http://schemas.google.com/g/2005'>
        <category scheme='http://schemas.google.com/g/2005#kind' term='http://schemas.google.com/contact/2008#contact'/>
        <content>{{ item.description | default('') }}</content>
        <gd:name>
          <gd:givenName>{{ item.first_name | title }}</gd:givenName>
          <gd:familyName>{{ item.last_name | title }}</gd:familyName>
        </gd:name>
        <gd:email rel='http://schemas.google.com/g/2005#work' address='{{ item.name }}'/>
        <gd:phoneNumber rel='http://schemas.google.com/g/2005#mobile'>{{ item | json_query('options.phones[?type==`mobile`].value | [0]') }}</gd:phoneNumber>
        <gd:phoneNumber rel='http://schemas.google.com/g/2005#work'>{{ item | json_query('options.phones[?type==`work`].value | [0]') }}</gd:phoneNumber>
        <gd:organization rel="http://schemas.google.com/g/2005#work" label="Work" primary="true">
          <gd:orgName>{{ item.options.organizations.name | default('')}}</gd:orgName>
          <gd:orgJobDescription>{{ item.options.organizations.description | default('') }}</gd:orgJobDescription>
        </gd:organization>
      </entry>
    return_content: true
    status_code: 200
    headers:
      authorization: "Bearer {{ gsuite_access_token }}"
      GData-Version: "3.0"
      Content-Type: "application/atom+xml"
  loop: "{{ gsuite_contacts }}"
  loop_control: { label: "{{ item.name }}" }
  when:
    - item.ensure is not defined
    - item.name in get_contacts.content


- name: 'delete contacts'
  uri:
    url: "{{ get_contacts | json_query('json.feed.entry[?\"gd$email\"[?address==`' ~ item.name ~ '`]][link[?rel==`edit`]][][].href | [0]' ) }}"
    return_content: true
    method: DELETE
    headers:
      authorization: "Bearer {{ gsuite_access_token }}"
  loop: "{{ gsuite_contacts }}"
  loop_control: { label: "{{ item.name }}" }
  when:
    - item.ensure is defined
    - item.name in get_contacts.content

That’s it. Cheers.


Used with:

  • Ansible 2.9
This post is licensed under CC BY 4.0 by the author.