pgls 1.0.3

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,497 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'net/ldap'
4
+ require 'optparse'
5
+ require 'yaml'
6
+ require 'kwalify'
7
+ require 'pg'
8
+ require "pgls/logger"
9
+
10
+ module PgLdapSync
11
+ class Application
12
+ attr_accessor :config_fname
13
+ attr_accessor :groups_fname
14
+ attr_accessor :log
15
+ attr_accessor :test
16
+
17
+ def string_to_symbol(hash)
18
+ if hash.kind_of?(Hash)
19
+ return hash.inject({}) do |h, v|
20
+ raise "expected String instead of #{h.inspect}" unless v[0].kind_of?(String)
21
+ h[v[0].intern] = string_to_symbol(v[1])
22
+ h
23
+ end
24
+ else
25
+ return hash
26
+ end
27
+ end
28
+
29
+
30
+ def validate_config(config, schema, fname)
31
+ schema = YAML.load_file(schema)
32
+ validator = Kwalify::Validator.new(schema)
33
+ errors = validator.validate(config)
34
+ if errors && !errors.empty?
35
+ errors.each do |err|
36
+ log.fatal "error in #{fname}: [#{err.path}] #{err.message}"
37
+ end
38
+ raise InvalidConfig, 78 # EX_CONFIG
39
+ end
40
+ end
41
+
42
+ def read_config_file(fname)
43
+ raise "Config file #{fname.inspect} does not exist" unless File.exist?(fname)
44
+ config = YAML.load(File.read(fname))
45
+
46
+ schema_fname = File.join(File.dirname(__FILE__), '../../config/schema.yaml')
47
+ validate_config(config, schema_fname, fname)
48
+
49
+ @config = string_to_symbol(config)
50
+ end
51
+
52
+ LdapRole = Struct.new :name, :dn, :member_dns
53
+
54
+ def read_groups_from_file(fname)
55
+ groups = []
56
+ File.foreach(fname) do |line|
57
+ # Удалите пробелы и символы новой строки с помощью strip
58
+ group = line.strip
59
+ groups << group unless group.empty?
60
+ end
61
+ groups
62
+ end
63
+
64
+ def format_groups_for_ldap_users(groups, filter)
65
+ # Используйте метод map для преобразования каждой группы в соответствующий формат
66
+ formatted_groups = groups.map { |group| "(memberOf=CN=#{group},#{filter})" }
67
+ # Используйте метод join для объединения форматированных групп в одну строку с оператором "или" (|)
68
+ "(&(objectClass=organizationalPerson)(|" + formatted_groups.join + "))"
69
+ end
70
+
71
+ def format_groups_for_ldap_groups(groups)
72
+ # Используйте метод map для преобразования каждой группы в соответствующий формат
73
+ formatted_groups = groups.map { |group| "(cn=#{group})" }
74
+ # Используйте метод join для объединения форматированных групп в одну строку с оператором "или" (|)
75
+ "(|" + formatted_groups.join + ")"
76
+ end
77
+
78
+ def search_ldap_users
79
+ ldap_user_conf = @config[:ldap_users]
80
+ name_attribute = ldap_user_conf[:name_attribute]
81
+
82
+ users = []
83
+
84
+ if @groups_fname != ''
85
+ @groups = read_groups_from_file(@groups_fname)
86
+
87
+ res = @ldap.search(
88
+ base: ldap_user_conf[:base],
89
+ filter: format_groups_for_ldap_users(@groups, ldap_user_conf[:filter]),
90
+ attributes: [name_attribute, :dn]
91
+ )
92
+ else
93
+ res = @ldap.search(
94
+ base: ldap_user_conf[:base],
95
+ filter: ldap_user_conf[:filter],
96
+ attributes: [name_attribute, :dn]
97
+ )
98
+ end
99
+
100
+ res.each do |entry|
101
+ name = entry[name_attribute].first
102
+
103
+ unless name
104
+ log.warn "user attribute #{name_attribute.inspect} not defined for #{entry.dn}"
105
+ next
106
+ end
107
+ log.info "found user-dn: #{entry.dn}"
108
+
109
+ names = if ldap_user_conf[:bothcase_name]
110
+ [name, name.downcase].uniq
111
+ elsif ldap_user_conf[:lowercase_name]
112
+ [name.downcase]
113
+ elsif ldap_user_conf[:uppercase_name]
114
+ [name.upcase]
115
+ else
116
+ [name]
117
+ end
118
+
119
+ names.each do |n|
120
+ users << LdapRole.new(n, entry.dn)
121
+ end
122
+ entry.each do |attribute, values|
123
+ log.debug " #{attribute}:"
124
+ values.each do |value|
125
+ log.debug " --->#{value.inspect}"
126
+ end
127
+ end
128
+ end
129
+ raise LdapError, "LDAP: #{@ldap.get_operation_result.message}" unless res
130
+ return users
131
+ end
132
+
133
+ def retrieve_array_attribute(entry, attribute_name)
134
+ array = entry[attribute_name]
135
+ if array.empty?
136
+ # Possibly an attribute, which must be retrieved in several ranges
137
+
138
+ ranged_attr = entry.attribute_names.find { |n| n =~ /\A#{Regexp.escape(attribute_name)};range=/ }
139
+ if ranged_attr
140
+ entry_dn = entry.dn
141
+
142
+ loop do
143
+ array += entry[ranged_attr]
144
+ log.debug "retrieved attribute range #{ranged_attr.inspect} of dn #{entry_dn}"
145
+
146
+ if ranged_attr =~ /;range=\d\-\*\z/
147
+ break
148
+ end
149
+
150
+ attribute_with_range = ranged_attr.to_s.gsub(/;range=.*/, ";range=#{array.size}-*")
151
+ entry = @ldap.search(
152
+ base: entry_dn,
153
+ scope: Net::LDAP::SearchScope_BaseObject,
154
+ attributes: attribute_with_range).first
155
+
156
+ ranged_attr = entry.attribute_names.find { |n| n =~ /\A#{Regexp.escape(attribute_name)};range=/ }
157
+ end
158
+ end
159
+ else
160
+ # Values already received -> No ranged attribute
161
+ end
162
+ return array
163
+ end
164
+
165
+ def search_ldap_groups
166
+ ldap_group_conf = @config[:ldap_groups]
167
+ name_attribute = ldap_group_conf[:name_attribute]
168
+ member_attribute = ldap_group_conf[:member_attribute]
169
+
170
+ groups = []
171
+
172
+ if @groups_fname != ''
173
+ res = @ldap.search(
174
+ base: ldap_group_conf[:base],
175
+ filter: format_groups_for_ldap_groups(@groups),
176
+ attributes: [name_attribute, member_attribute, :dn]
177
+ )
178
+ else
179
+ res = @ldap.search(
180
+ base: ldap_group_conf[:base],
181
+ filter: ldap_group_conf[:filter],
182
+ attributes: [name_attribute, member_attribute, :dn]
183
+ )
184
+ end
185
+
186
+ res.each do |entry|
187
+ name = entry[name_attribute].first
188
+
189
+ unless name
190
+ log.warn "user attribute #{name_attribute.inspect} not defined for #{entry.dn}"
191
+ next
192
+ end
193
+
194
+ log.info "found group-dn: #{entry.dn}"
195
+
196
+ names = if ldap_group_conf[:bothcase_name]
197
+ [name, name.downcase].uniq
198
+ elsif ldap_group_conf[:lowercase_name]
199
+ [name.downcase]
200
+ elsif ldap_group_conf[:uppercase_name]
201
+ [name.upcase]
202
+ else
203
+ [name]
204
+ end
205
+
206
+ names.each do |n|
207
+ group_members = retrieve_array_attribute(entry, member_attribute)
208
+ groups << LdapRole.new(n, entry.dn, group_members)
209
+ end
210
+ entry.each do |attribute, values|
211
+ log.debug " #{attribute}:"
212
+ values.each do |value|
213
+ log.debug " --->#{value.inspect}"
214
+ end
215
+ end
216
+ end
217
+ raise LdapError, "LDAP: #{@ldap.get_operation_result.message}" unless res
218
+ return groups
219
+ end
220
+
221
+ PgRole = Struct.new :name, :member_names
222
+
223
+ # List of default roles taken from https://www.postgresql.org/docs/current/predefined-roles.html
224
+ PG_BUILTIN_ROLES = %w[ pg_read_all_data pg_write_all_data pg_read_all_settings pg_read_all_stats pg_stat_scan_tables pg_monitor pg_database_owner pg_signal_backend pg_read_server_files pg_write_server_files pg_execute_server_program pg_checkpoint]
225
+
226
+ def search_pg_users
227
+ pg_users_conf = @config[:pg_users]
228
+
229
+ users = []
230
+ res = pg_exec "SELECT rolname FROM pg_roles WHERE #{pg_users_conf[:filter]}"
231
+ res.each do |tuple|
232
+ user = PgRole.new tuple[0]
233
+ next if PG_BUILTIN_ROLES.include?(user.name)
234
+ log.info{ "found pg-user: #{user.name.inspect}"}
235
+ users << user
236
+ end
237
+ return users
238
+ end
239
+
240
+ def search_pg_groups
241
+ pg_groups_conf = @config[:pg_groups]
242
+
243
+ groups = []
244
+ res = pg_exec "SELECT rolname, oid FROM pg_roles WHERE #{pg_groups_conf[:filter]}"
245
+ res.each do |tuple|
246
+ res2 = pg_exec "SELECT pr.rolname FROM pg_auth_members pam JOIN pg_roles pr ON pr.oid=pam.member WHERE pam.roleid=#{@pgconn.escape_string(tuple[1])}"
247
+ member_names = res2.map{|row| row[0] }
248
+ group = PgRole.new tuple[0], member_names
249
+ next if PG_BUILTIN_ROLES.include?(group.name)
250
+ log.info{ "found pg-group: #{group.name.inspect} with members: #{member_names.inspect}"}
251
+ groups << group
252
+ end
253
+ return groups
254
+ end
255
+
256
+ def uniq_names(list)
257
+ names = {}
258
+ new_list = list.select do |entry|
259
+ name = entry.name
260
+ if names[name]
261
+ log.warn{ "duplicated group/user #{name.inspect} (#{entry.inspect})" }
262
+ next false
263
+ else
264
+ names[name] = true
265
+ next true
266
+ end
267
+ end
268
+ return new_list
269
+ end
270
+
271
+ MatchedRole = Struct.new :ldap, :pg, :name, :state, :type
272
+
273
+ def match_roles(ldaps, pgs, type)
274
+ ldap_by_name = ldaps.inject({}){|h,u| h[u.name] = u; h }
275
+ pg_by_name = pgs.inject({}){|h,u| h[u.name] = u; h }
276
+
277
+ roles = []
278
+ ldaps.each do |ld|
279
+ pg = pg_by_name[ld.name]
280
+ role = MatchedRole.new ld, pg, ld.name
281
+ roles << role
282
+ end
283
+ pgs.each do |pg|
284
+ ld = ldap_by_name[pg.name]
285
+ next if ld
286
+ role = MatchedRole.new ld, pg, pg.name
287
+ roles << role
288
+ end
289
+
290
+ roles.each do |r|
291
+ r.state = case
292
+ when r.ldap && !r.pg then :create
293
+ when !r.ldap && r.pg then :drop
294
+ when r.pg && r.ldap then :keep
295
+ else raise "invalid user #{r.inspect}"
296
+ end
297
+ r.type = type
298
+ end
299
+
300
+ log.info do
301
+ roles.each do |role|
302
+ log.debug{ "#{role.state} #{role.type}: #{role.name}" }
303
+ end
304
+ "#{type} stat: create: #{roles.count{|r| r.state==:create }} drop: #{roles.count{|r| r.state==:drop }} keep: #{roles.count{|r| r.state==:keep }}"
305
+ end
306
+ return roles
307
+ end
308
+
309
+ def try_sql(text)
310
+ begin
311
+ @pgconn.exec "SAVEPOINT try_sql;"
312
+ @pgconn.exec text
313
+ rescue PG::Error => err
314
+ @pgconn.exec "ROLLBACK TO try_sql;"
315
+
316
+ log.error{ "#{err} (#{err.class})" }
317
+ end
318
+ end
319
+
320
+ def pg_exec_modify(sql)
321
+ log.info{ "SQL: #{sql}" }
322
+ unless self.test
323
+ try_sql sql
324
+ end
325
+ end
326
+
327
+ def pg_exec(sql)
328
+ res = @pgconn.exec sql
329
+ (0...res.num_tuples).map{|t| (0...res.num_fields).map{|i| res.getvalue(t, i) } }
330
+ end
331
+
332
+ def create_pg_role(role)
333
+ pg_conf = @config[role.type==:user ? :pg_users : :pg_groups]
334
+ pg_exec_modify "CREATE ROLE \"#{role.name}\" #{pg_conf[:create_options]}"
335
+ end
336
+
337
+ def drop_pg_role(role)
338
+ pg_exec_modify "DROP ROLE \"#{role.name}\""
339
+ end
340
+
341
+ def sync_roles_to_pg(roles, for_state)
342
+ roles.sort{|a,b| a.name<=>b.name }.each do |role|
343
+ create_pg_role(role) if role.state==:create && for_state==:create
344
+ drop_pg_role(role) if role.state==:drop && for_state==:drop
345
+ end
346
+ end
347
+
348
+ MatchedMembership = Struct.new :role_name, :has_member, :state
349
+
350
+ def match_memberships(ldap_roles, pg_roles)
351
+ ldap_group_conf = @config[:ldap_groups]
352
+ hash_of_arrays = Hash.new { |h, k| h[k] = [] }
353
+ if ldap_group_conf[:ald_domain]
354
+ ldap_by_dn = ldap_roles.inject(hash_of_arrays){|h,r| h[r.name] << r; h }
355
+ else
356
+ ldap_by_dn = ldap_roles.inject(hash_of_arrays){|h,r| h[r.dn] << r; h }
357
+ end
358
+ ldap_by_m2m = ldap_roles.inject([]) do |a,r|
359
+ next a unless r.member_dns
360
+ a + r.member_dns.flat_map do |dn|
361
+ has_members = ldap_by_dn[dn]
362
+ log.warn{"ldap member with dn #{dn} is unknown"} if has_members.empty?
363
+ has_members.map do |has_member|
364
+ [r.name, has_member.name]
365
+ end
366
+ end
367
+ end
368
+
369
+ hash_of_arrays = Hash.new { |h, k| h[k] = [] }
370
+ pg_by_name = pg_roles.inject(hash_of_arrays){|h,r| h[r.name] << r; h }
371
+ pg_by_m2m = pg_roles.inject([]) do |a,r|
372
+ next a unless r.member_names
373
+ a + r.member_names.flat_map do |name|
374
+ has_members = pg_by_name[name]
375
+ log.warn{"pg member with name #{name} is unknown"} if has_members.empty?
376
+ has_members.map do |has_member|
377
+ [r.name, has_member.name]
378
+ end
379
+ end
380
+ end
381
+
382
+ memberships = (ldap_by_m2m & pg_by_m2m).map{|r,mo| MatchedMembership.new r, mo, :keep }
383
+ memberships += (ldap_by_m2m - pg_by_m2m).map{|r,mo| MatchedMembership.new r, mo, :grant }
384
+ memberships += (pg_by_m2m - ldap_by_m2m).map{|r,mo| MatchedMembership.new r, mo, :revoke }
385
+
386
+ log.info do
387
+ memberships.each do |membership|
388
+ log.debug{ "#{membership.state} #{membership.role_name} to #{membership.has_member}" }
389
+ end
390
+ "membership stat: grant: #{memberships.count{|u| u.state==:grant }} revoke: #{memberships.count{|u| u.state==:revoke }} keep: #{memberships.count{|u| u.state==:keep }}"
391
+ end
392
+ return memberships
393
+ end
394
+
395
+ def grant_membership(role_name, add_members)
396
+ pg_conf = @config[:pg_groups]
397
+ add_members_escaped = add_members.map{|m| "\"#{m}\"" }.join(",")
398
+ pg_exec_modify "GRANT \"#{role_name}\" TO #{add_members_escaped} #{pg_conf[:grant_options]}"
399
+ end
400
+
401
+ def revoke_membership(role_name, rm_members)
402
+ rm_members_escaped = rm_members.map{|m| "\"#{m}\"" }.join(",")
403
+ pg_exec_modify "REVOKE \"#{role_name}\" FROM #{rm_members_escaped}"
404
+ end
405
+
406
+ def sync_membership_to_pg(memberships, for_state)
407
+ grants = {}
408
+ memberships.select{|ms| ms.state==for_state }.each do |ms|
409
+ grants[ms.role_name] ||= []
410
+ grants[ms.role_name] << ms.has_member
411
+ end
412
+
413
+ grants.each do |role_name, members|
414
+ grant_membership(role_name, members) if for_state==:grant
415
+ revoke_membership(role_name, members) if for_state==:revoke
416
+ end
417
+ end
418
+
419
+ def start!
420
+ read_config_file(@config_fname)
421
+
422
+ ldap_conf = @config[:ldap_connection]
423
+ auth_meth = ldap_conf.dig(:auth, :method).to_s
424
+ if auth_meth == "gssapi"
425
+ begin
426
+ require 'net/ldap/auth_adapter/gssapi'
427
+ rescue LoadError => err
428
+ raise "#{err}\nTo use GSSAPI authentication please run:\n gem install net-ldap-auth_adapter-gssapi"
429
+ end
430
+ elsif auth_meth == "gss_spnego"
431
+ begin
432
+ require 'net-ldap-gss-spnego'
433
+ # This doesn't work since this file is defined in net-ldap as a placeholder:
434
+ # require 'net/ldap/auth_adapter/gss_spnego'
435
+ rescue LoadError => err
436
+ raise "#{err}\nTo use GSSAPI authentication please run:\n gem install net-ldap-gss-spnego"
437
+ end
438
+ end
439
+
440
+ # gather LDAP users and groups
441
+ @ldap = Net::LDAP.new ldap_conf
442
+ ldap_users = uniq_names search_ldap_users
443
+ ldap_groups = uniq_names search_ldap_groups
444
+
445
+ # gather PGs users and groups
446
+ @pgconn = PG.connect @config[:pg_connection]
447
+ begin
448
+ @pgconn.transaction do
449
+ pg_users = uniq_names search_pg_users
450
+ pg_groups = uniq_names search_pg_groups
451
+
452
+ # compare LDAP to PG users and groups
453
+ mroles = match_roles(ldap_users, pg_users, :user)
454
+ mroles += match_roles(ldap_groups, pg_groups, :group)
455
+
456
+ # compare LDAP to PG memberships
457
+ mmemberships = match_memberships(ldap_users+ldap_groups, pg_users+pg_groups)
458
+
459
+ # drop/revoke roles/memberships first
460
+ sync_membership_to_pg(mmemberships, :revoke)
461
+ sync_roles_to_pg(mroles, :drop)
462
+ # create/grant roles/memberships
463
+ sync_roles_to_pg(mroles, :create)
464
+ sync_membership_to_pg(mmemberships, :grant)
465
+ end
466
+ ensure
467
+ @pgconn.close
468
+ end
469
+
470
+ # Determine exitcode
471
+ if log.had_errors?
472
+ raise ErrorExit.new(1, log.first_error)
473
+ end
474
+ end
475
+
476
+ def self.run(argv)
477
+ s = self.new
478
+ s.config_fname = '/etc/pg_ldap_sync.yaml'
479
+ s.groups_fname = ''
480
+ s.log = Logger.new($stdout)
481
+ s.log.level = Logger::ERROR
482
+
483
+ OptionParser.new do |opts|
484
+ opts.version = VERSION
485
+ opts.banner = "Usage: #{$0} [options]"
486
+ opts.on("-v", "--[no-]verbose", "Increase verbose level"){|v| s.log.level += v ? -1 : 1 }
487
+ opts.on("-c", "--config FILE", "Config file [#{s.config_fname}]", &s.method(:config_fname=))
488
+ opts.on("-g", "--groups FILE", "Groups file [#{s.groups_fname}]", &s.method(:groups_fname=))
489
+ opts.on("-t", "--[no-]test", "Don't do any change in the database", &s.method(:test=))
490
+
491
+ opts.parse!(argv)
492
+ end
493
+
494
+ s.start!
495
+ end
496
+ end
497
+ end
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ class Hash
4
+ # transform_keys was added in ruby-2.5
5
+ def transform_keys
6
+ map do |k, v|
7
+ [yield(k), v]
8
+ end.to_h
9
+ end unless method_defined? :transform_keys
10
+ end
@@ -0,0 +1,28 @@
1
+ require 'logger'
2
+
3
+ module PgLdapSync
4
+ class Logger < ::Logger
5
+ def initialize(io)
6
+ super(io)
7
+ @counters = {}
8
+ end
9
+
10
+ def add(severity, *args, &block)
11
+ super
12
+ return unless [Logger::FATAL, Logger::ERROR].include?(severity)
13
+ @counters[severity] ||= block ? block.call : args.first
14
+ end
15
+
16
+ def had_logged?(severity)
17
+ !!@counters[severity]
18
+ end
19
+
20
+ def had_errors?
21
+ had_logged?(Logger::FATAL) || had_logged?(Logger::ERROR)
22
+ end
23
+
24
+ def first_error
25
+ @counters[Logger::FATAL] || @counters[Logger::ERROR]
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,3 @@
1
+ module PgLdapSync
2
+ VERSION = "1.0.3"
3
+ end
data/lib/pgls.rb ADDED
@@ -0,0 +1,23 @@
1
+ require "pgls/application"
2
+ require "pgls/compat"
3
+ require "pgls/version"
4
+
5
+ module PgLdapSync
6
+ class LdapError < RuntimeError
7
+ end
8
+
9
+ class ApplicationExit < RuntimeError
10
+ attr_reader :exitcode
11
+
12
+ def initialize(exitcode, error=nil)
13
+ super(error)
14
+ @exitcode = exitcode
15
+ end
16
+ end
17
+
18
+ class InvalidConfig < ApplicationExit
19
+ end
20
+
21
+ class ErrorExit < ApplicationExit
22
+ end
23
+ end
data/pgls.gemspec ADDED
@@ -0,0 +1,33 @@
1
+ lib = File.expand_path("../lib", __FILE__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require "pgls/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "pgls"
7
+ spec.version = PgLdapSync::VERSION
8
+ spec.authors = ["fruworg"]
9
+ spec.email = ["im@fruw.org"]
10
+
11
+ spec.summary = %q{Use LDAP permissions in PostgreSQL}
12
+ spec.homepage = "https://github.com/fruworg/pgls"
13
+ spec.license = "MIT"
14
+
15
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
16
+ f.match(%r{^(test|spec|features)/})
17
+ end
18
+ spec.bindir = "exe"
19
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
20
+ spec.require_paths = ["lib"]
21
+ spec.rdoc_options = %w[--main README.md --charset=UTF-8]
22
+ spec.required_ruby_version = ">= 2.3"
23
+
24
+ spec.add_runtime_dependency "net-ldap", "~> 0.16"
25
+ spec.add_runtime_dependency "kwalify", "~> 0.7"
26
+ spec.add_runtime_dependency "pg", ">= 0.14", "<= 1.0.0"
27
+ spec.add_runtime_dependency "net-ldap-auth_adapter-gssapi", "~> 0.2.0"
28
+ spec.add_development_dependency "ruby-ldapserver", "~> 0.3"
29
+ spec.add_development_dependency "minitest", "~> 5.0"
30
+ spec.add_development_dependency "bundler", ">= 1.16", "< 3.0"
31
+ spec.add_development_dependency "rake", "~> 13.0"
32
+ spec.add_development_dependency "minitest-hooks", "~> 1.4"
33
+ end