lita-gsuite 0.5

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.
@@ -0,0 +1,56 @@
1
+ require 'bigdecimal'
2
+
3
+ module Lita
4
+ module Commands
5
+ class TwoFactorStats
6
+
7
+ def name
8
+ 'two-factor-stats'
9
+ end
10
+
11
+ def run(robot, target, gateway, opts = {})
12
+ msg = build_msg(gateway)
13
+ robot.send_message(target, msg) if msg
14
+ robot.send_message(target, "No stats found") if msg.nil? && opts[:negative_ack]
15
+ end
16
+
17
+ private
18
+
19
+ def build_msg(gateway)
20
+ users = active_users(gateway)
21
+ if users.any?
22
+ users_with_tfa = users.select { |user| user.two_factor_enabled? }
23
+ ou_paths = users.group_by(&:ou_path).keys.sort
24
+
25
+ msg = "Active users with Two Factor Authentication enabled:\n\n"
26
+ ou_paths.each do |ou_path|
27
+ msg += ou_msg(ou_path, users) + "\n"
28
+ end
29
+ msg + "- Overall #{users_with_tfa.size}/#{users.size} (#{percentage(users_with_tfa.size, users.size)}%)"
30
+ end
31
+ end
32
+
33
+ def ou_msg(ou_path, users)
34
+ ou_users = users.select { |user| user.ou_path == ou_path }
35
+ with_tfa = ou_users.select { |user| user.two_factor_enabled? }
36
+ "- #{ou_path} #{with_tfa.size}/#{ou_users.size} (#{percentage(with_tfa.size, ou_users.size)}%)"
37
+ end
38
+
39
+ def percentage(num, denom)
40
+ if denom == 0
41
+ result = BigDecimal.new(0)
42
+ else
43
+ result = BigDecimal.new(num) / BigDecimal.new(denom) * 100
44
+ end
45
+ result.round(2).to_s("F")
46
+ end
47
+
48
+ def active_users(gateway)
49
+ gateway.users.reject { |user|
50
+ user.suspended?
51
+ }
52
+ end
53
+
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,50 @@
1
+ module Lita
2
+ class GoogleActivity
3
+ attr_reader :time, :actor, :ip, :name, :params
4
+
5
+ def self.from_api(item)
6
+ item.events.map { |event|
7
+ GoogleActivity.new(
8
+ time: item.id.time,
9
+ actor: item.actor.email,
10
+ ip: item.ip_address,
11
+ name: event.name,
12
+ params: event.parameters.inject({}) { |accum, param|
13
+ accum[param.name] = param.value
14
+ accum
15
+ }
16
+ )
17
+ }
18
+ end
19
+
20
+ def initialize(time:, actor:, ip:, name:, params:)
21
+ @time = time
22
+ @actor = actor
23
+ @ip = ip
24
+ @name = name
25
+ @params = params
26
+ end
27
+
28
+ def to_s
29
+ @actor
30
+ end
31
+
32
+ def to_msg
33
+ <<~EOF
34
+ Date: #{@time.httpdate}
35
+ Admin User: #{@actor}
36
+ Action: #{@name.capitalize.gsub('_', ' ')}
37
+ #{values}
38
+ EOF
39
+ end
40
+
41
+ private
42
+
43
+ def values
44
+ @params.map do |key, value|
45
+ "#{key.gsub('_', ' ')}: #{value}"
46
+ end.join("\n")
47
+ end
48
+
49
+ end
50
+ end
@@ -0,0 +1,29 @@
1
+ module Lita
2
+ # A google email group
3
+ class GoogleGroup
4
+ attr_reader :id, :email, :name, :member_count, :description
5
+
6
+ def self.from_api(group)
7
+ GoogleGroup.new(
8
+ id: group.id,
9
+ email: group.email,
10
+ name: group.name,
11
+ description: group.description,
12
+ member_count: group.direct_members_count,
13
+ )
14
+ end
15
+
16
+ def initialize(id:, email:, name:, member_count:, description:)
17
+ @id, @email, @name, @description = id, email, name, description
18
+ @member_count = member_count.to_i
19
+ end
20
+
21
+ def ==(other)
22
+ @id == other.id && @email == other.email
23
+ end
24
+
25
+ def to_s
26
+ @email
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,16 @@
1
+ module Lita
2
+ # A google OrganisationUnit, contains zero or more users
3
+ class GoogleOrganisationUnit
4
+ attr_reader :name, :path
5
+
6
+ def self.from_api(item)
7
+ GoogleOrganisationUnit.new(
8
+ name: item.name, path: item.org_unit_path
9
+ )
10
+ end
11
+
12
+ def initialize(name:, path:)
13
+ @name, @path = name, path
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,69 @@
1
+ module Lita
2
+ # A google apps user
3
+ class GoogleUser
4
+ attr_reader :id, :full_name, :email, :created_at, :last_login_at, :ou_path
5
+
6
+ def self.from_api_user(user)
7
+ GoogleUser.new(
8
+ id: user.id,
9
+ full_name: user.name.full_name,
10
+ email: user.primary_email,
11
+ suspended: user.suspended,
12
+ created_at: user.creation_time,
13
+ last_login_at: user.last_login_time,
14
+ ou_path: user.org_unit_path,
15
+ admin: user.is_admin,
16
+ delegated_admin: user.is_delegated_admin,
17
+ two_factor_enabled: user.is_enrolled_in2_sv,
18
+ two_factor_enforced: user.is_enforced_in2_sv,
19
+ )
20
+ end
21
+
22
+ def initialize(id:, full_name:, email:, suspended:, created_at:, last_login_at:, ou_path:, admin:, delegated_admin:, two_factor_enabled:, two_factor_enforced:)
23
+ @id = id
24
+ @email = email
25
+ @full_name = full_name
26
+ @suspended = suspended
27
+ @created_at = created_at
28
+ @last_login_at = last_login_at
29
+ @ou_path = ou_path
30
+ @admin = admin
31
+ @delegated_admin = delegated_admin
32
+ @two_factor_enabled = two_factor_enabled
33
+ @two_factor_enforced = two_factor_enforced
34
+ end
35
+
36
+ def to_s
37
+ @email
38
+ end
39
+
40
+ def admin?
41
+ @admin
42
+ end
43
+
44
+ def delegated_admin?
45
+ @delegated_admin
46
+ end
47
+
48
+ def path
49
+ if @ou_path.end_with?("/")
50
+ "#{@ou_path}#{@email}"
51
+ else
52
+ "#{@ou_path}/#{@email}"
53
+ end
54
+ end
55
+
56
+ def suspended?
57
+ @suspended
58
+ end
59
+
60
+ def two_factor_enabled?
61
+ @two_factor_enabled
62
+ end
63
+
64
+ def two_factor_enforced?
65
+ @two_factor_enforced
66
+ end
67
+
68
+ end
69
+ end
@@ -0,0 +1,373 @@
1
+ require "lita"
2
+ require "lita-timing"
3
+ require 'googleauth'
4
+ require 'securerandom'
5
+
6
+ module Lita
7
+ class Gsuite < Handler
8
+ TIMER_INTERVAL = 60
9
+ OOB_OAUTH_URI = 'urn:ietf:wg:oauth:2.0:oob'
10
+
11
+ COMMANDS = [
12
+ Commands::ListAdmins,
13
+ Commands::EmptyGroups,
14
+ Commands::NoOrgUnit,
15
+ Commands::TwoFactorOff,
16
+ Commands::TwoFactorStats,
17
+ Commands::SuspensionCandidates,
18
+ Commands::DeletionCandidates,
19
+ ].map { |cmd|
20
+ [cmd.new.name, cmd]
21
+ }.to_h
22
+
23
+ WINDOW_COMMANDS = [
24
+ Commands::ListActivities
25
+ ].map { |cmd|
26
+ [cmd.new.name, cmd]
27
+ }.to_h
28
+
29
+ config :oauth_client_id
30
+ config :oauth_client_secret
31
+
32
+ # Authentication commands - each user is required to run these before they can interact with
33
+ # the Google API
34
+ route(/^gsuite auth$/, :start_auth, command: true, help: {"gsuite auth" => "Initiate the first of two steps required to authorise the current user wth Google"})
35
+ route(/^gsuite set-token (.+)$/, :set_token, command: true, help: {"gsuite set-token <token>" => "The second and final step required to authorise the current user with Google. Run 'gsuite auth' first"})
36
+
37
+ # Instant queries. Authenticated users can run these commands and the result will be returned
38
+ # immediately
39
+ route(/^gsuite list-admins$/, :list_admins, command: true, help: {"gsuite list-admins" => "List active admins"})
40
+ route(/^gsuite suspension-candidates$/, :suspension_candidates, command: true, help: {"gsuite suspension-candidates" => "List active users that habven't signed in for a while"})
41
+ route(/^gsuite deletion-candidates$/, :deletion_candidates, command: true, help: {"gsuite deletion-candidates" => "List suspended users that habven't signed in for a while"})
42
+ route(/^gsuite no-ou$/, :no_org_unit, command: true, help: {"gsuite no-ou" => "List users that aren't assigned to an Organisation Unit"})
43
+ route(/^gsuite empty-groups$/, :empty_groups, command: true, help: {"gsuite empty-groups" => "List groups with no users"})
44
+ route(/^gsuite two-factor-stats$/, :two_factor_stats, command: true, help: {"gsuite two-factor-stats" => "Display stats on option of two factor authentication"})
45
+ route(/^gsuite two-factor-off (.+)$/, :two_factor_off, command: true, help: {"gsuite two-factor-off <OU path>" => "List users from the OU path with two factor authentication off"})
46
+
47
+ # Control a schedule of automated commands to run in specific channels
48
+ route(/^gsuite schedule list$/, :schedule_list, command: true, help: {"gsuite schedule list" => "Print the list of scheduled gsuite commands for the current channel"})
49
+ route(/^gsuite schedule commands$/, :schedule_commands, command: true, help: {"gsuite schedule commands" => "Print the list of commands available for scheduling"})
50
+ route(/^gsuite schedule add-weekly (.+) (\d\d:\d\d) (.+)$/, :schedule_add_weekly, command: true, help: {"gsuite schedule add-weekly <day> <HH:MM> <cmd>" => "Add a new weekly scheduled command. Run 'gsuite schedule commands' to see the available commands"})
51
+ route(/^gsuite schedule add-window (.+)$/, :schedule_add_window, command: true, help: {"gsuite schedule add-window <cmd>" => "Add a new scheduled window command"})
52
+ route(/^gsuite schedule del (.+)$/, :schedule_delete, command: true, help: {"gsuite schedule del <cmd-id>" => "Delete a scheduled command. Requires a command ID, which is printed in 'gsuite schedule list' output"})
53
+
54
+ on :loaded, :start_timers
55
+
56
+ def start_timers(payload)
57
+ weekly_commands_timer
58
+ window_commands_timer
59
+ end
60
+
61
+ def deletion_candidates(response)
62
+ return unless confirm_user_authenticated(response)
63
+
64
+ Commands::DeletionCandidates.new.run(
65
+ robot,
66
+ Source.new(room: response.room),
67
+ gateway(response.user),
68
+ negative_ack: true
69
+ )
70
+ end
71
+
72
+ def empty_groups(response)
73
+ return unless confirm_user_authenticated(response)
74
+
75
+ Commands::EmptyGroups.new.run(
76
+ robot,
77
+ Source.new(room: response.room),
78
+ gateway(response.user),
79
+ negative_ack: true
80
+ )
81
+ end
82
+
83
+ def list_admins(response)
84
+ return unless confirm_user_authenticated(response)
85
+
86
+ Commands::ListAdmins.new.run(
87
+ robot,
88
+ Source.new(room: response.room),
89
+ gateway(response.user),
90
+ negative_ack: true
91
+ )
92
+ end
93
+
94
+ def no_org_unit(response)
95
+ return unless confirm_user_authenticated(response)
96
+
97
+ Commands::NoOrgUnit.new.run(
98
+ robot,
99
+ Source.new(room: response.room),
100
+ gateway(response.user),
101
+ negative_ack: true
102
+ )
103
+ end
104
+
105
+ def suspension_candidates(response)
106
+ return unless confirm_user_authenticated(response)
107
+
108
+ Commands::SuspensionCandidates.new.run(
109
+ robot,
110
+ Source.new(room: response.room),
111
+ gateway(response.user),
112
+ negative_ack: true
113
+ )
114
+ end
115
+
116
+ def two_factor_off(response)
117
+ return unless confirm_user_authenticated(response)
118
+
119
+ ou_path = response.match_data[1].to_s
120
+ Commands::TwoFactorOff.new(ou_path).run(
121
+ robot,
122
+ Source.new(room: response.room),
123
+ gateway(response.user),
124
+ negative_ack: true
125
+ )
126
+ end
127
+
128
+ def two_factor_stats(response)
129
+ return unless confirm_user_authenticated(response)
130
+
131
+ Commands::TwoFactorStats.new.run(
132
+ robot,
133
+ Source.new(room: response.room),
134
+ gateway(response.user),
135
+ negative_ack: true
136
+ )
137
+ end
138
+
139
+ def start_auth(response)
140
+ credentials = google_credentials_for_user(response.user)
141
+ url = google_authorizer.get_authorization_url(base_url: OOB_OAUTH_URI)
142
+ if credentials.nil?
143
+ response.reply "Open the following URL in your browser and enter the resulting code via the 'gsuite set-token <foo>' command:\n\n#{url}"
144
+ else
145
+ response.reply "#{response.user.name} is already authorized with Google. To re-authorize, open the following URL in your browser and enter the resulting code via the 'gsuite set-token <foo>' command:\n\n#{url}"
146
+ end
147
+ rescue StandardError => e
148
+ response.reply("Error: #{e.class} #{e.message}")
149
+ end
150
+
151
+ def set_token(response)
152
+ auth_code = response.match_data[1].to_s
153
+
154
+ google_authorizer.get_and_store_credentials_from_code(user_id: response.user.id, code: auth_code, base_url: OOB_OAUTH_URI)
155
+ response.reply("#{response.user.name} now authorized")
156
+ rescue StandardError => e
157
+ response.reply("Error: #{e.class} #{e.message}")
158
+ end
159
+
160
+ def schedule_list(response)
161
+ room_commands = (weekly_commands_for_room(response.room.name) + window_commands_for_room(response.room.name)).select { |cmd|
162
+ cmd.room_id == response.room.id
163
+ }
164
+ if room_commands.any?
165
+ room_commands.each do |cmd|
166
+ response.reply("#{cmd.human}")
167
+ end
168
+ else
169
+ response.reply("no scheduled commands for this room")
170
+ end
171
+ rescue StandardError => e
172
+ response.reply("Error: #{e.class} #{e.message}")
173
+ end
174
+
175
+ def schedule_commands(response)
176
+ msg = "The following commands are available for scheduling weekly:\n\n"
177
+ COMMANDS.each do |cmd_name, cmd_klass|
178
+ msg += "- #{cmd_name}\n"
179
+ end
180
+ msg += "The following commands are available for scheduling for sliding windows:\n\n"
181
+ WINDOW_COMMANDS.each do |cmd_name, cmd_klass|
182
+ msg += "- #{cmd_name}\n"
183
+ end
184
+ response.reply(msg)
185
+ rescue StandardError => e
186
+ response.reply("Error: #{e.class} #{e.message}")
187
+ end
188
+
189
+ def schedule_add_weekly(response)
190
+ return unless confirm_user_authenticated(response)
191
+
192
+ _, day, time, cmd_name = *response.match_data
193
+
194
+ schedule = WeeklySchedule.new(
195
+ id: SecureRandom.hex(3),
196
+ day: day,
197
+ time: time,
198
+ cmd: COMMANDS.fetch(cmd_name.downcase, nil),
199
+ user_id: response.user.id,
200
+ room_id: response.room.id,
201
+ )
202
+ if schedule.valid?
203
+ redis.hmset("weekly-schedule", schedule.id, schedule.to_json)
204
+ response.reply("scheduled command")
205
+ else
206
+ response.reply("invalid command")
207
+ end
208
+ rescue StandardError => e
209
+ response.reply("Error: #{e.class} #{e.message}")
210
+ end
211
+
212
+ def schedule_add_window(response)
213
+ return unless confirm_user_authenticated(response)
214
+
215
+ cmd_name = response.match_data[1].to_s
216
+ schedule = WindowSchedule.new(
217
+ id: SecureRandom.hex(3),
218
+ cmd: WINDOW_COMMANDS.fetch(cmd_name.downcase, nil),
219
+ user_id: response.user.id,
220
+ room_id: response.room.id,
221
+ )
222
+ if schedule.valid?
223
+ redis.hmset("window-schedule", schedule.id, schedule.to_json)
224
+ response.reply("scheduled command")
225
+ else
226
+ response.reply("invalid command")
227
+ end
228
+ rescue StandardError => e
229
+ response.reply("Error: #{e.class} #{e.message}")
230
+ end
231
+
232
+ def schedule_delete(response)
233
+ cmd_id = response.match_data[1].to_s
234
+
235
+ count = redis.hdel("weekly-schedule", cmd_id)
236
+ count += redis.hdel("window-schedule", cmd_id)
237
+ if count > 0
238
+ response.reply("scheduled command #{cmd_id} deleted")
239
+ else
240
+ response.reply("no scheduled command with ID #{cmd_id} found")
241
+ end
242
+ rescue StandardError => e
243
+ response.reply("Error: #{e.class} #{e.message}")
244
+ end
245
+
246
+ private
247
+
248
+ def confirm_user_authenticated(response)
249
+ credentials = google_credentials_for_user(response.user)
250
+ if credentials.nil?
251
+ response.reply("#{response.user.name} not authorized with Google yet. Use the 'gsuite auth' command to initiate authorization")
252
+ false
253
+ else
254
+ true
255
+ end
256
+ end
257
+
258
+ def google_credentials_for_user(user)
259
+ google_authorizer.get_credentials(user.id)
260
+ end
261
+
262
+ def google_authorizer
263
+ @google_authorizer ||= begin
264
+ client_id = Google::Auth::ClientId.new(
265
+ config.oauth_client_id,
266
+ config.oauth_client_secret
267
+ )
268
+ token_store = RedisTokenStore.new(redis)
269
+ Google::Auth::UserAuthorizer.new(client_id, GsuiteGateway::OAUTH_SCOPES, token_store)
270
+ end
271
+ end
272
+
273
+ def weekly_commands_timer
274
+ every_with_logged_errors(TIMER_INTERVAL) do |timer|
275
+ weekly_commands.each do |cmd|
276
+ weekly_at(cmd.time, cmd.day, "#{cmd.id}-#{cmd.name}") do
277
+ target = Source.new(
278
+ room: Lita::Room.create_or_update(cmd.room_id)
279
+ )
280
+ user = Lita::User.find_by_id(cmd.user_id)
281
+ cmd.run(robot, target, gateway(user))
282
+ end
283
+ end
284
+ end
285
+ end
286
+
287
+ def window_commands_timer
288
+ every_with_logged_errors(TIMER_INTERVAL) do |timer|
289
+ window_commands.each do |cmd|
290
+ target = Source.new(
291
+ room: Lita::Room.create_or_update(cmd.room_id)
292
+ )
293
+ user = Lita::User.find_by_id(cmd.user_id)
294
+ sliding_window ||= Lita::Timing::SlidingWindow.new("#{cmd.id}-#{cmd.name}", redis)
295
+ sliding_window.advance(duration_minutes: cmd.duration_minutes, buffer_minutes: cmd.buffer_minutes) do |window_start, window_end|
296
+ cmd.run(robot, target, gateway(user), window_start, window_end)
297
+ end
298
+ end
299
+ end
300
+ end
301
+
302
+ def weekly_commands_for_room(room_id)
303
+ weekly_commands.select { |cmd|
304
+ cmd.room_id == room_id
305
+ }
306
+ end
307
+
308
+ def window_commands_for_room(room_id)
309
+ window_commands.select { |cmd|
310
+ cmd.room_id == room_id
311
+ }
312
+ end
313
+
314
+ def weekly_commands
315
+ redis.hgetall("weekly-schedule").map { |_id, data|
316
+ JSON.parse(data)
317
+ }.map { |data|
318
+ WeeklySchedule.new(
319
+ id: data.fetch("id", "foo"),
320
+ day: data.fetch("day", nil),
321
+ time: data.fetch("time", "12:00"),
322
+ room_id: data.fetch("room_id", nil),
323
+ user_id: data.fetch("user_id", nil),
324
+ cmd: COMMANDS.fetch(data.fetch("cmd", nil), nil),
325
+ )
326
+ }.select { |schedule|
327
+ schedule.valid?
328
+ }
329
+ end
330
+
331
+ def window_commands
332
+ redis.hgetall("window-schedule").map { |_id, data|
333
+ JSON.parse(data)
334
+ }.map { |data|
335
+ WindowSchedule.new(
336
+ id: data.fetch("id", nil),
337
+ room_id: data.fetch("room_id", nil),
338
+ user_id: data.fetch("user_id", nil),
339
+ cmd: WINDOW_COMMANDS.fetch(data.fetch("cmd", nil), nil),
340
+ )
341
+ }.select { |schedule|
342
+ schedule.valid?
343
+ }
344
+ end
345
+
346
+ def every_with_logged_errors(interval, &block)
347
+ every(interval) do
348
+ logged_errors do
349
+ yield
350
+ end
351
+ end
352
+ end
353
+
354
+ def logged_errors(&block)
355
+ yield
356
+ rescue StandardError => e
357
+ $stderr.puts "Error in timer loop: #{e.class} #{e.message} #{e.backtrace.first}"
358
+ end
359
+
360
+ def weekly_at(time, day, name, &block)
361
+ Lita::Timing::Scheduled.new(name, redis).weekly_at(time, day, &block)
362
+ end
363
+
364
+ def gateway(user)
365
+ Lita::GsuiteGateway.new(
366
+ user_authorization: google_authorizer.get_credentials(user.id)
367
+ )
368
+ end
369
+
370
+ Lita.register_handler(self)
371
+
372
+ end
373
+ end