lita-gsuite 0.5

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