terraorg 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8ab3d94ce6dcc408a54773d2922f6939cb3ecb2c1f712c15e6bf38d5b4991262
4
+ data.tar.gz: 697c67ca943cc95ea5aad8a569968f204d0c6ed54e5747b4e99a55090c8faa6c
5
+ SHA512:
6
+ metadata.gz: eea771dfd0e78861b6bc4d40bd6a55a978ee05a7a2739948bfa1ce7890dd26bd59a534395973b28fa9caff76c06d143cf482bb9d22278f89fb4ba9b92cb06172
7
+ data.tar.gz: a2199d855c507de8ed37a22a62a20d65a36796637ff7ed55003fd205380655ffb2eebcbbc51720eec747ea46252705a00fa9a528581cc4f5756dab5d27bb1f2c
data/LICENSE.txt ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2020- Joshua Kwan
2
+ Copyright 2020 LiveRamp Holdings, Inc.
3
+
4
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
5
+ this software and associated documentation files (the "Software"), to deal in
6
+ the Software without restriction, including without limitation the rights to
7
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
8
+ of the Software, and to permit persons to whom the Software is furnished to do
9
+ so, subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all
12
+ copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,124 @@
1
+ [![Build Status](https://travis-ci.com/joshk0/terraorg.svg?token=KqPPGKhNmsFv3uhyxvvf&branch=master)](https://travis-ci.com/joshk0/terraorg)
2
+
3
+ # terraorg
4
+
5
+ This tool helps define organizational structure using flat JSON files as a
6
+ source of truth for who is a member of an organization. Traditionally, there
7
+ are multiple sources of truth that need to be separately maintained. These JSON
8
+ files are processed into Terraform files which are meant to create nested
9
+ groups representing the organization that are usable in Okta and G Suite.
10
+
11
+ Additionally, the tool facilitates using this source of truth to generate
12
+ Markdown documentation about the org as well.
13
+
14
+ Based on the org that this tool was originally designed for, orgs are expected
15
+ to have three levels:
16
+
17
+ * *squads*: the base unit of team-dom, containing people, who may be in
18
+ different geographical regions.
19
+ * *platoons*: a unit which contains squads and exceptional people who are
20
+ members of the platoon, but not part of any squad
21
+ * *org*: The whole organization, including its manager, any exceptional squads
22
+ not organized into a platoon, and any exceptional people not part of any
23
+ squad at all but still part of the org.
24
+
25
+ The tool generates groups for each granular unit of organization in Okta and G
26
+ Suite in Terraform. With patching, it could be possible for more organizational
27
+ systems to be supported.
28
+
29
+ ## How it works
30
+
31
+ Firstly, take your entire existing organization and define it using the
32
+ constructs of squads, platoons and organization. Groups will be generated;
33
+ *use these groups to assign access to resources* rather than individually
34
+ assigning access.
35
+
36
+ Whenever a person joins your organization, it should be the responsibility of
37
+ managers in the organization, or their delegates, to update the organizational
38
+ JSON files. If done correctly, updating the file, re-running terraorg to
39
+ generate source code, and applying the resulting source code would instantly
40
+ grant the person access to required services. Similarly, if a person leaves,
41
+ remove that person from the file and run the tool again.
42
+
43
+ ## Model definitions
44
+
45
+ See `examples/` directory for examples of `squads.json`, `platoons.json` and
46
+ `org.json`.
47
+
48
+ ## Running the tool
49
+
50
+ ### Environment variables
51
+
52
+ The tool expects the following environment variables to be defined and fails if
53
+ they are missing:
54
+
55
+ * `OKTA_API_TOKEN`: An API token with read/write admin access to your Okta
56
+ organization.
57
+ * `OKTA_ORG_NAME`: Name of the Okta organization.
58
+ * `OKTA_BASE_URL`: either `okta.com` or `oktapreview.com` depending on your
59
+ Okta configuration.
60
+ * `GSUITE_DOMAIN`: The G Suite domain suffix e.g. `yourcompany.com` which
61
+ is used by Google Groups.
62
+
63
+ The following environment variables are optional; if not specified, ensure
64
+ that your runtime setup matches the defaults.
65
+
66
+ * `TERRAORG_SQUADS`: defaulting to `squads.json`, location of the squads
67
+ definition.
68
+ * `TERRAORG_PLATOONS`: defaulting to `platoons.json`, location of the platoons
69
+ definition.
70
+ * `TERRAORG_ROOT`: defaulting to `org.json`, location of the root
71
+ organizational unit.
72
+ * `TERRAORG_OKTA_CACHE`: defaulting to `okta_cache.json`, location of the Okta
73
+ name, email and user id lookup cache. Delete this file to force a refresh.
74
+ * `SLACK_DOMAIN`: defaulting to `yourdomain.slack.com`, prefix of your Slack
75
+ domain. This is only required for doc generation and not for Terraform.
76
+
77
+ ### Subcommands
78
+
79
+ When running `terraorg`, pass a second argument containing a subcommand.
80
+ Valid subcommands:
81
+
82
+ * `fmt`: Idiomatic formatting for all of your org files which will be
83
+ written back to the JSON files. Similar behavior to `terraform fmt`.
84
+ `auto.XXXX.tf`.
85
+ * `generate-platoons-md`: Generate Markdown documentation for Squads.
86
+ * `generate-squads-md`: Generate Markdown documentation for Squads.
87
+ * `generate-tf`: Generate Terraform files. Output files will be of the form
88
+ * `validate`: Ensure the input JSON files represent a valid, self-consistent
89
+ organization.
90
+
91
+ ## Combining with terraform
92
+
93
+ See `examples/` directory for how to set up your Terraform workspace for plans
94
+ and applies of the files generated by this tool. At a minimum, you will need to
95
+ furnish [articulate/terraform-provider-okta] and
96
+ [DeviaVir/terraform-provider-gsuite], and enable those as providers.
97
+
98
+ Please consult the respective Terraform modules' home pages for more
99
+ information on how to configure the providers.
100
+
101
+ [articulate/terraform-provider-okta]: https://github.com/articulate/terraform-provider-okta
102
+ [DeviaVir/terraform-provider-gsuite]: https://github.com/DeviaVir/terraform-provider-gsuite
103
+
104
+ ## Suggested process
105
+
106
+ At [LiveRamp], a pull request based workflow leveraging [Atlantis] is used to
107
+ interact with terraorg. The repository containing LiveRamp's engineering
108
+ organizational structure is locked down and requires approval from
109
+ organizational leadership for any change.
110
+
111
+ Once approved, Atlantis leverages its API access to Okta and G Suite in order
112
+ to effect the group changes.
113
+
114
+ Similar process can be defined using CI tools such as Jenkins and Circle CI.
115
+ Implementation of such process is left as an exercise to the reader.
116
+
117
+ [LiveRamp]: https://github.com/LiveRamp/
118
+ [Atlantis]: https://www.runatlantis.io/
119
+
120
+ ## Future
121
+
122
+ * terraorg could generate and push GitHub Teams automatically that line up
123
+ with existing squad structure.
124
+ * Terraform and Markdown generation could be factored out to ERB.
data/bin/terraorg ADDED
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'json'
4
+ require 'neatjson'
5
+ require 'oktakit'
6
+
7
+ require 'terraorg/model/org'
8
+ require 'terraorg/model/people'
9
+ require 'terraorg/model/platoons'
10
+ require 'terraorg/model/squads'
11
+
12
+ ACTIONS = [
13
+ 'fmt',
14
+ 'generate-platoons-md',
15
+ 'generate-squads-md',
16
+ 'generate-tf',
17
+ 'validate'
18
+ ].freeze
19
+
20
+ SQUADS_FILE = ENV.fetch('TERRAORG_SQUADS', 'squads.json')
21
+ PLATOONS_FILE = ENV.fetch('TERRAORG_PLATOONS', 'platoons.json')
22
+ ORG_FILE = ENV.fetch('TERRAORG_ROOT', 'org.json')
23
+ CACHE_FILE = ENV.fetch('TERRAORG_OKTA_CACHE', 'okta_cache.json')
24
+ GSUITE_DOMAIN = ENV.fetch('GSUITE_DOMAIN')
25
+ SLACK_DOMAIN = ENV.fetch('SLACK_DOMAIN', 'yourcompany.slack.com')
26
+ OKTA_ORG_NAME = ENV.fetch('OKTA_ORG_NAME')
27
+ OKTA_API_TOKEN = ENV.fetch('OKTA_API_TOKEN')
28
+ OKTA_BASE_URL = ENV.fetch('OKTA_BASE_URL', 'okta.com')
29
+
30
+ action = ARGV[0]
31
+ raise "Invalid action: '#{action}' (valid: #{ACTIONS})" unless ACTIONS.member?(action)
32
+
33
+ ### Actions that can run without Okta
34
+ case action
35
+ when 'fmt'
36
+ # This People object is created with neither an okta cache nor a okta client.
37
+ # It accepts any user for the purposes of format validation.
38
+ people = People.new
39
+ squads_data = File.read(SQUADS_FILE)
40
+ squads = Squads.new(JSON.parse(squads_data), people, GSUITE_DOMAIN, SLACK_DOMAIN)
41
+ platoons_data = File.read(PLATOONS_FILE)
42
+ platoons = Platoons.new(JSON.parse(platoons_data), squads, people, GSUITE_DOMAIN)
43
+ org_data = File.read(ORG_FILE)
44
+ org = Org.new(JSON.parse(org_data), platoons, squads, people, GSUITE_DOMAIN)
45
+
46
+ options = {wrap: true, sort: true, after_colon: 1, after_comma: 1}
47
+ new_squads_data = JSON.neat_generate(squads.to_h, **options) + "\n"
48
+ new_platoons_data = JSON.neat_generate(platoons.to_h, **options) + "\n"
49
+ new_org_data = JSON.neat_generate(org.to_h, **options) + "\n"
50
+
51
+ if new_squads_data != squads_data
52
+ puts SQUADS_FILE
53
+ File.write(SQUADS_FILE, new_squads_data)
54
+ end
55
+
56
+ if new_platoons_data != platoons_data
57
+ puts PLATOONS_FILE
58
+ File.write(PLATOONS_FILE, new_platoons_data)
59
+ end
60
+
61
+ if new_org_data != org_data
62
+ puts ORG_FILE
63
+ File.write(ORG_FILE, new_org_data)
64
+ end
65
+
66
+ exit
67
+ end
68
+
69
+ ### Actions that require Okta credentials
70
+ if OKTA_BASE_URL == 'oktapreview.com'
71
+ okta = Oktakit.new(token: OKTA_API_TOKEN, api_endpoint: "https://#{OKTA_ORG_NAME}.#{OKTA_BASE_URL}/api/v1")
72
+ else
73
+ okta = Oktakit.new(token: OKTA_API_TOKEN, organization: OKTA_ORG_NAME)
74
+ end
75
+
76
+ people = People.new(okta: okta, cache_file: CACHE_FILE)
77
+ squads_data = File.read(SQUADS_FILE)
78
+ squads = Squads.new(JSON.parse(squads_data), people, GSUITE_DOMAIN, SLACK_DOMAIN)
79
+ platoons_data = File.read(PLATOONS_FILE)
80
+ platoons = Platoons.new(JSON.parse(platoons_data), squads, people, GSUITE_DOMAIN)
81
+ org_data = File.read(ORG_FILE)
82
+ org = Org.new(JSON.parse(org_data), platoons, squads, people, GSUITE_DOMAIN)
83
+
84
+ org.validate!
85
+
86
+ case action
87
+ when 'generate-squads-md'
88
+ # Generate CSV file mirroring the 'Engineering Squads' Squads sheet.
89
+ puts org.get_squads_md
90
+ when 'generate-platoons-md'
91
+ # Generate CSV file mirroring the 'Engineering Squads' Platoons sheet.
92
+ puts org.get_platoons_md
93
+ when 'generate-tf'
94
+ # Generate Terraform code to apply to Okta.
95
+ org.generate_tf
96
+ when 'validate'
97
+ # Validation is a prerequisite, so just output success. It would have
98
+ # failed earlier otherwise
99
+ puts 'Organization is valid!'
100
+ end
@@ -0,0 +1,231 @@
1
+ require 'terraorg/model/util'
2
+
3
+ class Org
4
+ MAX_SQUADS_PER_PERSON = 2
5
+ SCHEMA_VERSION = 'v1'.freeze
6
+
7
+ def initialize(parsed_data, platoons, squads, people, gsuite_domain)
8
+ @people = people
9
+ @id = parsed_data.fetch('id')
10
+ @name = parsed_data.fetch('name')
11
+ @metadata = parsed_data.fetch('metadata', {})
12
+ @manager = @people.get_or_create!(parsed_data.fetch('manager'))
13
+ @manager_location = parsed_data.fetch('manager_location')
14
+ # @member_exception_people is a Hash of Squad::Teams, like the teams attribute in a Squad.
15
+ @member_exception_people = Hash[parsed_data.fetch('exception_people').map { |p|
16
+ [p.fetch('location'), Squad::Team.new(p, @people)]
17
+ }]
18
+ @member_exception_squad_names = []
19
+ @member_exception_squads = parsed_data.fetch('exception_squads').map do |s|
20
+ @member_exception_squad_names.push(s)
21
+ squads.lookup!(s)
22
+ end
23
+ @member_platoon_names = []
24
+ @member_platoons = parsed_data.fetch('platoons').map do |p|
25
+ @member_platoon_names.push(p)
26
+ platoons.lookup!(p)
27
+ end
28
+
29
+ # used to generate a group
30
+ @gsuite_domain = gsuite_domain
31
+
32
+ # used for validate!
33
+ @platoons = platoons
34
+ @squads = squads
35
+ end
36
+
37
+ def validate!
38
+ # Do not allow the JSON files to contain any people who have left.
39
+ raise "Users have left the company: #{@people.inactive.map(&:id).join(', ')}" unless @people.inactive.empty?
40
+
41
+ # Do not allow the org to be totally empty.
42
+ raise 'Org has no platoons or exception squads' if @member_platoons.size + @member_exception_squads.size == 0
43
+
44
+ # Require all platoons to be part of the org.
45
+ platoon_diff = Set.new(@platoons.all_names) - Set.new(@member_platoon_names)
46
+ unless platoon_diff.empty?
47
+ raise "Platoons are not used in the org: #{platoon_diff.to_a.sort}"
48
+ end
49
+
50
+ # Require all squads to be used in the org.
51
+ squad_diff = Set.new(@squads.all_names) - Set.new(@platoons.all_squad_names) - Set.new(@member_exception_squad_names)
52
+ unless squad_diff.empty?
53
+ raise "Squad(s) are not used in the org: #{squad_diff.to_a.sort}"
54
+ end
55
+
56
+ all_squads = (@member_platoons.map(&:member_squads) + @member_exception_squads).flatten
57
+ seen_squads = {}
58
+
59
+ # Validate that a squad is not part of more than one platoon
60
+ all_squads.map(&:id).each do |id|
61
+ seen_squads[id] = seen_squads.fetch(id, 0) + 1
62
+ end
63
+ more_than_one_platoon = seen_squads.select do |squad, count|
64
+ count > 1
65
+ end
66
+ if !more_than_one_platoon.empty?
67
+ raise "Squads are part of more than one platoon: #{more_than_one_platoon}"
68
+ end
69
+
70
+ # Validate that a squad member belongs to at most two squads in the entire org
71
+ squad_count = {}
72
+ all_squads.map(&:teams).flatten.map(&:values).flatten.map(&:members).flatten.each do |member|
73
+ squad_count[member.id] = squad_count.fetch(member.id, 0) + 1
74
+ end
75
+ more_than_max_squads = squad_count.select do |member, count|
76
+ count > MAX_SQUADS_PER_PERSON
77
+ end
78
+ if !more_than_max_squads.empty?
79
+ # TODO(joshk): Enforce after consulting with Sean
80
+ $stderr.puts "WARNING: Members are part of more than #{MAX_SQUADS_PER_PERSON} squads: #{more_than_max_squads}"
81
+ end
82
+
83
+ # Validate that a squad member is not also an org exception
84
+ exceptions = Set.new(@member_exception_people.map { |_, t| t.members }.flatten.map(&:id))
85
+ exception_and_squad_member = squad_count.keys.select do |member|
86
+ exceptions.member? member
87
+ end
88
+ if !exception_and_squad_member.empty?
89
+ raise "Exception members are also squad members: #{exception_and_squad_member}"
90
+ end
91
+ end
92
+
93
+ def members
94
+ Set.new([@manager] + (@member_platoons + @member_exception_squads).map(&:members).flatten + @member_exception_people.map { |_, t| t.members }.flatten).to_a
95
+ end
96
+
97
+ def get_acl_groups(attr: :id)
98
+ # Return a LIST_NAME => [MEMBER1, MEMBER2...] hash of ACL groups
99
+ { unique_name => members.map(&attr).sort }.
100
+ merge(get_platoon_acl_groups(attr: attr)).
101
+ merge(get_exception_squad_acl_groups(attr: attr))
102
+ end
103
+
104
+ def get_platoon_acl_groups(attr: :id)
105
+ @member_platoons.map { |p| p.get_acl_groups(@id, attr: attr) }.reduce({}, :merge)
106
+ end
107
+
108
+ def get_exception_squad_acl_groups(attr: :id)
109
+ @member_exception_squads.map { |p| p.get_acl_groups(@id) }.map(&attr).reduce({}, :merge)
110
+ end
111
+
112
+ def unique_name
113
+ "#{@id}-all"
114
+ end
115
+
116
+ def get_platoons_md
117
+ # 90 degree rotated version of the Platoons page of the legacy Engineering Squads sheet
118
+ # Format:
119
+ # Platoon,Total,Members
120
+ # [ORG_HUMAN_NAME],[COUNT]
121
+ # [PLAT1_HUMAN_NAME],[PLAT1_COUNT],[PLAT1_MEMBER1],[PLAT1_MEMBER2],...
122
+ # [PLAT2_HUMAN_NAME],[PLAT2_COUNT],[PLAT2_MEMBER1],[PLAT2_MEMBER2],...
123
+ md_lines = [
124
+ '# Engineering Platoons List',
125
+ '',
126
+ '|Platoon|Manager|Total|Members|',
127
+ '|---|---|---|---|',
128
+ "|_#{@name} Total_|#{@manager.name}|#{members.size}|",
129
+ ]
130
+ md_lines += @member_platoons.map(&:get_platoons_psv_row)
131
+
132
+ raise "Users have left the company: #{@people.inactive.map(&:id).join(', ')}" unless @people.inactive.empty?
133
+ md_lines.join("\n")
134
+ end
135
+
136
+ def get_squads_md
137
+ # Format:
138
+ # platoon name, squad name, PM, email list, SME, slack, # people, squad manager, eng product owner, members
139
+ md_lines = [
140
+ '# Engineering Squads List',
141
+ '',
142
+ '|Platoon|Squad|PM|Mailing list|TS SME|Slack|# Engineers|Squad Manager|Eng Product Owner|Members|',
143
+ '|---|---|---|---|---|---|---|---|---|---|',
144
+ ]
145
+ md_lines += @member_platoons.map { |s| s.get_squads_psv_rows(@id) }
146
+ md_lines += @member_exception_squads.map { |s| s.to_md('_No Platoon_', @id) }
147
+ md_lines.push "|_No Platoon_|_No Squad_|||||#{@member_exception_people.size}|||#{@member_exception_people.values.map(&:to_md).join(' / ')}|"
148
+
149
+ raise "Users have left the company: #{@people.inactive.map(&:id).join(', ')}" unless @people.inactive.empty?
150
+ md_lines.join("\n")
151
+ end
152
+
153
+ def generate_tf
154
+ tf = @member_platoons.map { |p| p.generate_tf(@id) }.join("\n")
155
+ File.write('auto.platoons.tf', tf)
156
+
157
+ tf = @member_exception_squads.map { |s| s.generate_tf(@id) }.join("\n")
158
+ File.write('auto.exception_squads.tf', tf)
159
+
160
+ # Roll all platoons and exception squads into the org.
161
+ roll_up_to_org = \
162
+ @member_exception_squads.map { |s| s.unique_name(@id, nil) } + \
163
+ @member_platoons.map { |p| p.unique_name(@id) }
164
+
165
+ # Generate the org, which contains:
166
+ # - exception people (added manually to group)
167
+ # - the org manager (added manually to group)
168
+ # - all the platoons (via rule)
169
+ # - all exception squads (via rule)
170
+ org_condition = roll_up_to_org.map {
171
+ |n| "\\\"${okta_group.#{n}.id}\\\""
172
+ }.join(',')
173
+
174
+ description = "#{@name} organization worldwide members (terraorg)"
175
+ tf = <<-EOF
176
+ # Okta for Organization: #{@name}
177
+ resource "okta_group" "#{unique_name}" {
178
+ name = "#{unique_name}"
179
+ description = "#{description}"
180
+ users = #{Util.persons_tf(members)}
181
+ }
182
+
183
+ #{Util.gsuite_group_tf(unique_name, @gsuite_domain, members, description)}
184
+ EOF
185
+
186
+ # Generate a special group for all org members grouped by country
187
+ all_squads = (@member_platoons.map(&:member_squads) + @member_exception_squads).flatten
188
+ all_locations = {}
189
+ (all_squads.map(&:teams).flatten + [@member_exception_people]).each do |team|
190
+ team.each do |location, subteam|
191
+ all_locations[location] = all_locations.fetch(location, Set.new).merge(subteam.members)
192
+ end
193
+ end
194
+
195
+ # Manually add the manager to a specific location
196
+ all_locations[@manager_location] = all_locations.fetch(@manager_location, Set.new).add(@manager)
197
+
198
+ all_locations.each do |l, m|
199
+ name = "#{unique_name}-#{l.downcase}"
200
+ tf += <<-EOF
201
+ resource "okta_group" "#{name}" {
202
+ name = "#{name}"
203
+ description = "#{@name} organization members based in #{l} (terraorg)"
204
+ users = #{Util.persons_tf(m)}
205
+ }
206
+ EOF
207
+ end
208
+
209
+ File.write('auto.org.tf', tf)
210
+ end
211
+
212
+ # Output a canonical (sorted, formatted) version of this organization.
213
+ # - Sort the platoon names lexically
214
+ # - Sort the exception squad ids lexically
215
+ # - Sort the exception people ids lexically
216
+ def to_h
217
+ obj = { 'version' => SCHEMA_VERSION, 'id' => @id, 'name' => @name, 'manager_location' => @manager_location, 'manager' => @manager.id }
218
+ obj['platoons'] = @platoons.all.map(&:id).sort
219
+ if @metadata
220
+ obj['metadata'] = @metadata
221
+ end
222
+ if @member_exception_people
223
+ obj['exception_people'] = @member_exception_people.values.sort_by(&:location).map(&:to_h)
224
+ end
225
+ if @member_exception_squads
226
+ obj['exception_squads'] = @member_exception_squads.map(&:id).sort
227
+ end
228
+
229
+ obj
230
+ end
231
+ end
@@ -0,0 +1,44 @@
1
+ require 'terraorg/model/person'
2
+
3
+ class People
4
+ attr_accessor :inactive
5
+
6
+ def initialize(okta: nil, cache_file: nil)
7
+ @okta = okta
8
+ @people = {}
9
+ @inactive = []
10
+ @cache_file = cache_file
11
+
12
+ load_cache! if @cache_file
13
+ end
14
+
15
+ def get_or_create!(id)
16
+ p = @people[id]
17
+ return p if p
18
+
19
+ p = Person.new(id, okta: @okta)
20
+ if p.active?
21
+ @people[id] = p
22
+ else
23
+ @people.delete(id)
24
+ @inactive.push p
25
+ end
26
+ save_cache! if @cache_file
27
+ return p
28
+ end
29
+
30
+ def load_cache!
31
+ return unless File.exist?(@cache_file)
32
+
33
+ JSON.parse(File.read(@cache_file)).each do |id, cache|
34
+ @people[id] = Person.new(id, cached: cache)
35
+ end
36
+ end
37
+
38
+ def save_cache!
39
+ # Atomic write cache file
40
+ cf_new = "#{@cache_file}.new"
41
+ File.write(cf_new, @people.to_json)
42
+ File.rename(cf_new, @cache_file)
43
+ end
44
+ end
@@ -0,0 +1,63 @@
1
+ require 'faraday'
2
+
3
+ class Person
4
+ ACTIVE_USER_STATUSES = ['ACTIVE', 'PROVISIONED'].freeze
5
+
6
+ attr_accessor :id, :name, :okta_id, :email, :status
7
+
8
+ def initialize(uid, okta: nil, cached: nil)
9
+ @id = uid
10
+
11
+ if cached
12
+ @name = cached.fetch('name')
13
+ @okta_id = cached.fetch('okta_id')
14
+ @email = cached.fetch('email')
15
+ @status = cached.fetch('status')
16
+
17
+ return
18
+ elsif !okta
19
+ # We could just be running in fmt mode, so lie about everything
20
+ @name = "real name of #{@id}"
21
+ @okta_id = "fake okta id for #{@id}"
22
+ @email = "#{@id}@my.domain"
23
+ @status = 'PROVISIONED'
24
+
25
+ return
26
+ end
27
+
28
+ # Retrieve from okta
29
+ tries = 1
30
+ total_tries = 5
31
+
32
+ begin
33
+ o = okta.get_user(uid)
34
+ rescue Faraday::ConnectionFailed => e
35
+ if tries <= total_tries
36
+ puts "looking up user #{uid}: #{e} (try #{tries}/#{total_tries})"
37
+ tries += 1
38
+ retry
39
+ end
40
+ raise
41
+ end
42
+
43
+ if tries > 1
44
+ puts "looking up user #{uid}: success!"
45
+ end
46
+
47
+ # NOTE: allows users in states other than ACTIVE
48
+ # if you want to check that, do it outside of here
49
+ obj = o[0].to_hash
50
+ @name = obj.fetch(:profile).fetch(:displayName)
51
+ @okta_id = obj.fetch(:id)
52
+ @email = obj.fetch(:profile).fetch(:email)
53
+ @status = obj.fetch(:status)
54
+ end
55
+
56
+ def active?
57
+ ACTIVE_USER_STATUSES.member?(@status)
58
+ end
59
+
60
+ def to_json(options = nil)
61
+ {'id' => @id, 'name' => @name, 'okta_id' => @okta_id, 'email' => @email, 'status' => @status}.to_json
62
+ end
63
+ end
@@ -0,0 +1,98 @@
1
+ require 'terraorg/model/util'
2
+
3
+ class Platoon
4
+ attr_accessor :id, :name, :member_exceptions, :member_squads
5
+
6
+ def initialize(parsed_data, squads, people, gsuite_domain)
7
+ @id = parsed_data.fetch('id')
8
+ @metadata = parsed_data.fetch('metadata', {})
9
+ @name = parsed_data.fetch('name')
10
+ @manager = people.get_or_create!(parsed_data.fetch('manager'))
11
+ @member_exceptions = parsed_data.fetch('exceptions', []).map do |n|
12
+ people.get_or_create!(n)
13
+ end
14
+ @member_squad_names = []
15
+ @member_squads = parsed_data.fetch('squads').map do |s|
16
+ @member_squad_names.push(s)
17
+ squads.lookup!(s)
18
+ end
19
+ @gsuite_domain = gsuite_domain
20
+ end
21
+
22
+ def validate!
23
+ raise 'Platoon has no squads' if @member_squads.size == 0
24
+ end
25
+
26
+ def squad_names
27
+ @member_squad_names
28
+ end
29
+
30
+ def members
31
+ Set.new([@manager] + @member_squads.map(&:members).flatten + @member_exceptions).to_a
32
+ end
33
+
34
+ def unique_name(org_id)
35
+ "#{org_id}-platoon-#{@id}"
36
+ end
37
+
38
+ def get_acl_groups(org_id, platoon: true)
39
+ if platoon
40
+ rv = { unique_name(org_id) => {'name' => "#{@name} platoon members worldwide", 'members' => members} }
41
+ else
42
+ rv = {}
43
+ end
44
+
45
+ @member_squads.map { |s| s.get_acl_groups(org_id) }.reduce(rv, :merge)
46
+ end
47
+
48
+ def get_platoons_psv_row
49
+ "|#{@name}|#{@manager.name}|#{members.size}|#{members.map(&:name).sort.join(', ')}|"
50
+ end
51
+
52
+ def get_squads_psv_rows(org_id)
53
+ @member_squads.map { |s| s.to_md(@name, org_id) }
54
+ end
55
+
56
+ def generate_tf(org_id)
57
+ tf_id = unique_name(org_id)
58
+
59
+ # tf formatted, comma separated references to the group ids for the
60
+ # squads in this platoon
61
+ squads_condition = get_acl_groups(org_id, platoon: false).map {
62
+ |n, _| "\\\"${okta_group.#{n}.id}\\\""
63
+ }.join(',')
64
+
65
+ # tf containing the platoon declaration
66
+ description = "#{@name} platoon members (terraorg)"
67
+ rv = <<-EOF
68
+ # Platoon: #{@name}
69
+ # Squads: #{squad_names.join(', ')}
70
+
71
+ resource "okta_group" "#{tf_id}" {
72
+ name = "#{tf_id}"
73
+ description = "#{description}"
74
+ users = #{Util.persons_tf(members)}
75
+ }
76
+
77
+ #{Util.gsuite_group_tf(tf_id, @gsuite_domain, members, description)}
78
+ EOF
79
+
80
+ # tf containing squads and their members
81
+ rv += @member_squads.map { |s| s.generate_tf(org_id) }.join("\n")
82
+ end
83
+
84
+ # Output a canonical (sorted, formatted) version of this object.
85
+ # - Sort the squad ids lexically
86
+ # - Sort the exceptions lexically
87
+ def to_h
88
+ obj = { 'id' => @id, 'name' => @name, 'manager' => @manager.id, 'squads' => @member_squads.map(&:id) }
89
+ unless @member_exceptions.empty?
90
+ obj['exceptions'] = @member_exceptions.map(&:id)
91
+ end
92
+ unless @metadata.empty?
93
+ obj['metadata'] = @metadata
94
+ end
95
+
96
+ obj
97
+ end
98
+ end
@@ -0,0 +1,43 @@
1
+ require 'terraorg/model/platoon'
2
+
3
+ class Platoons
4
+ SCHEMA_VERSION = 'v1'.freeze
5
+
6
+ def initialize(parsed_data, squads, people, gsuite_domain)
7
+ version = parsed_data.fetch('version')
8
+ raise "Unsupported schema version: #{version}" if version != SCHEMA_VERSION
9
+
10
+ @platoons = {}
11
+ parsed_data.fetch('platoons').each do |platoon_raw|
12
+ p = Platoon.new(platoon_raw, squads, people, gsuite_domain)
13
+ @platoons[p.id] = p
14
+ end
15
+ end
16
+
17
+ def lookup!(name)
18
+ @platoons.fetch(name)
19
+ end
20
+
21
+ def validate!
22
+ end
23
+
24
+ def all
25
+ @platoons.values
26
+ end
27
+
28
+ def all_squad_names
29
+ @platoons.values.map(&:squad_names).flatten
30
+ end
31
+
32
+ def all_names
33
+ @platoons.keys
34
+ end
35
+
36
+ def members
37
+ @platoons.map(&:members).flatten
38
+ end
39
+
40
+ def to_h
41
+ { 'version' => SCHEMA_VERSION , 'platoons' => @platoons.values.sort_by(&:id).map(&:to_h) }
42
+ end
43
+ end
@@ -0,0 +1,129 @@
1
+ require 'terraorg/model/people'
2
+ require 'terraorg/model/util'
3
+
4
+ class Squad
5
+ attr_accessor :id, :name, :metadata, :teams
6
+
7
+ class Team
8
+ attr_accessor :location, :members
9
+
10
+ def initialize(parsed_data, people)
11
+ @location = parsed_data.fetch('location')
12
+ @members = parsed_data.fetch('members', []).map do |n|
13
+ people.get_or_create!(n)
14
+ end
15
+ end
16
+
17
+ # Output a canonical (sorted, formatted) version of this Team.
18
+ # - Sort the members in each team
19
+ def to_h
20
+ { 'location' => @location, 'members' => @members.map(&:id).sort }
21
+ end
22
+
23
+ def to_md
24
+ "**#{@location}**: #{@members.map(&:name).sort.join(', ')}"
25
+ end
26
+ end
27
+
28
+ def initialize(id, parsed_data, people, gsuite_domain, slack_domain)
29
+ @gsuite_domain = gsuite_domain
30
+ @slack_domain = slack_domain
31
+ @id = id
32
+ @metadata = parsed_data.fetch('metadata', {})
33
+ @name = parsed_data.fetch('name')
34
+ @people = people
35
+
36
+ teams_arr = parsed_data.fetch('team', []).map do |t|
37
+ Team.new(t, people)
38
+ end
39
+ @teams = Hash[teams_arr.map { |t| [t.location, t] }]
40
+ end
41
+
42
+ def members(location: nil)
43
+ @teams.select { |l, t|
44
+ location == nil || l == location
45
+ }.map { |l, t|
46
+ t.members
47
+ }.flatten
48
+ end
49
+
50
+ def get_acl_groups(org_id)
51
+ # each geographically located subteam
52
+ groups = Hash[@teams.map { |location, team|
53
+ [unique_name(org_id, location), {'name' => "#{@name} squad members based in #{location}", 'members' => team.members}]
54
+ }]
55
+
56
+ # combination of all subteams
57
+ groups[unique_name(org_id, nil)] = {'name' => "#{@name} squad worldwide members", 'members' => members}
58
+
59
+ groups
60
+ end
61
+
62
+ def unique_name(org_id, location)
63
+ if location
64
+ "#{org_id}-squad-#{@id}-#{location.downcase}"
65
+ else
66
+ "#{org_id}-squad-#{@id}"
67
+ end
68
+ end
69
+
70
+ def validate!
71
+ raise 'Squad has no members' if members.size == 0
72
+ end
73
+
74
+ def to_md(platoon_name, org_id)
75
+ pm = @metadata.fetch('pm', [])
76
+ pm = pm.map { |p| @people.get_or_create!(p).name }.join(', ')
77
+
78
+ sme = @metadata.fetch('sme', '')
79
+ if !sme.empty?
80
+ sme = @people.get_or_create!(sme).name
81
+ end
82
+
83
+ epo = @metadata.fetch('epo', '')
84
+ if !epo.empty?
85
+ epo = @people.get_or_create!(epo).name
86
+ end
87
+
88
+ manager = @metadata.fetch('manager', '')
89
+ if !manager.empty?
90
+ manager = @people.get_or_create!(manager).name
91
+ end
92
+
93
+ subteam_members = @teams.values.map(&:to_md).join(' / ')
94
+ email = "#{unique_name(org_id, nil)}@#{@gsuite_domain}"
95
+ slack = @metadata.fetch('slack', '')
96
+ if slack
97
+ slack = "[#{slack}](https://#{@slack_domain}/app_redirect?channel=#{slack.gsub(/^#/, '')})"
98
+ end
99
+ # platoon name, squad name, PM, email list, SME, slack, # people, squad manager, eng product owner, members
100
+ "|#{platoon_name}|#{@name}|#{pm}|[#{email}](#{email})|#{sme}|#{slack}|#{members.size}|#{manager}|#{epo}|#{subteam_members}|"
101
+ end
102
+
103
+ def generate_tf(org_id)
104
+ groups = get_acl_groups(org_id)
105
+
106
+ groups.map { |id, group|
107
+ description = "#{group.fetch('name')} (terraorg)"
108
+ <<-EOF
109
+ resource "okta_group" "#{id}" {
110
+ name = "#{id}"
111
+ description = "#{description}"
112
+ users = #{Util.persons_tf(group.fetch('members'))}
113
+ }
114
+
115
+ #{Util.gsuite_group_tf(id, @gsuite_domain, group.fetch('members'), description)}
116
+ EOF
117
+ }.join("\n\n")
118
+ end
119
+
120
+ def to_h
121
+ # Output a canonical (sorted, formatted) version of this Squad.
122
+ # - Subteams are sorted by location lexically
123
+ obj = { 'id' => @id, 'name' => @name }
124
+ obj['team'] = @teams.values.sort_by { |t| t.location }.map(&:to_h)
125
+ obj['metadata'] = @metadata if @metadata
126
+
127
+ obj
128
+ end
129
+ end
@@ -0,0 +1,28 @@
1
+ require 'terraorg/model/squad'
2
+
3
+ class Squads
4
+ SCHEMA_VERSION = 'v1'.freeze
5
+
6
+ def initialize(parsed_data, people, gsuite_domain, slack_domain)
7
+ version = parsed_data.fetch('version')
8
+ raise "Unsupported squads schema version: #{version}" if version != SCHEMA_VERSION
9
+
10
+ @squads = {}
11
+ parsed_data.fetch('squads').each do |squad|
12
+ id = squad.fetch('id')
13
+ @squads[id] = Squad.new(id, squad, people, gsuite_domain, slack_domain)
14
+ end
15
+ end
16
+
17
+ def all_names
18
+ @squads.keys
19
+ end
20
+
21
+ def lookup!(name)
22
+ @squads.fetch(name)
23
+ end
24
+
25
+ def to_h
26
+ { 'version' => SCHEMA_VERSION, 'squads' => @squads.values.sort_by(&:id).map(&:to_h) }
27
+ end
28
+ end
@@ -0,0 +1,49 @@
1
+ class Util
2
+ # Take a list of Persons and turn it into a newline delimited, comma
3
+ # separated array definition suitable for inclusion in terraform. Each line
4
+ # contains an okta id and a comment indicating the person's name.
5
+ def self.persons_tf(persons)
6
+ "[\n" + persons.map { |p| " \"#{p.okta_id}\", # #{p.name}" }.join("\n") + "\n]\n"
7
+ end
8
+
9
+ # Take a list of Persons and generate a gsuite_group containing all of those
10
+ # members with expected organizational settings.
11
+ def self.gsuite_group_tf(name, domain, persons, description)
12
+ email = "#{name}@#{domain}"
13
+ tf = <<-TERRAFORM
14
+ # G Suite group for #{email}
15
+ resource "gsuite_group" "#{name}" {
16
+ email = "#{email}"
17
+ name = "#{name}"
18
+ description = "#{description}"
19
+ }
20
+
21
+ resource "gsuite_group_settings" "#{name}" {
22
+ email = gsuite_group.#{name}.email
23
+ who_can_discover_group = "ALL_IN_DOMAIN_CAN_DISCOVER"
24
+ who_can_view_membership = "ALL_IN_DOMAIN_CAN_VIEW"
25
+ who_can_leave_group = "NONE_CAN_LEAVE"
26
+ who_can_join = "INVITED_CAN_JOIN"
27
+ who_can_post_message = "ALL_IN_DOMAIN_CAN_POST"
28
+ }
29
+
30
+ resource "gsuite_group_members" "#{name}" {
31
+ group_email = gsuite_group.#{name}.email
32
+ TERRAFORM
33
+
34
+ # Add a member block for everyone
35
+ # downcase is used as internal G Suite representation is always lowercase
36
+ # this avoids unnecessary state churn
37
+ persons.each do |p|
38
+ tf += <<-TERRAFORM
39
+ member {
40
+ email = "#{p.email.downcase}"
41
+ role = "MEMBER"
42
+ }
43
+ TERRAFORM
44
+ end
45
+
46
+ tf += "\n}"
47
+ tf
48
+ end
49
+ end
@@ -0,0 +1,3 @@
1
+ module Terraorg
2
+ VERSION = '0.0.7'
3
+ end
metadata ADDED
@@ -0,0 +1,97 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: terraorg
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.7
5
+ platform: ruby
6
+ authors:
7
+ - Joshua Kwan
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2020-03-04 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: faraday
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: neatjson
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.9'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.9'
41
+ - !ruby/object:Gem::Dependency
42
+ name: oktakit
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '0.2'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '0.2'
55
+ description: Manage an organizational structure with Okta and G-Suite using Terraform
56
+ email: joshk@triplehelix.org
57
+ executables:
58
+ - terraorg
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - LICENSE.txt
63
+ - README.md
64
+ - bin/terraorg
65
+ - lib/terraorg/model/org.rb
66
+ - lib/terraorg/model/people.rb
67
+ - lib/terraorg/model/person.rb
68
+ - lib/terraorg/model/platoon.rb
69
+ - lib/terraorg/model/platoons.rb
70
+ - lib/terraorg/model/squad.rb
71
+ - lib/terraorg/model/squads.rb
72
+ - lib/terraorg/model/util.rb
73
+ - lib/terraorg/version.rb
74
+ homepage: https://github.com/LiveRamp/terraorg
75
+ licenses:
76
+ - MIT
77
+ metadata: {}
78
+ post_install_message:
79
+ rdoc_options: []
80
+ require_paths:
81
+ - lib
82
+ required_ruby_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '2.3'
87
+ required_rubygems_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: '0'
92
+ requirements: []
93
+ rubygems_version: 3.0.3
94
+ signing_key:
95
+ specification_version: 4
96
+ summary: terraorg
97
+ test_files: []