terraorg 0.0.7

Sign up to get free protection for your applications and to get access to all the features.
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: []