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