pg-ldap-sync 0.1.0

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,23 @@
1
+ # -*- ruby -*-
2
+
3
+ require 'autotest/restart'
4
+
5
+ # Autotest.add_hook :initialize do |at|
6
+ # at.extra_files << "../some/external/dependency.rb"
7
+ #
8
+ # at.libs << ":../some/external"
9
+ #
10
+ # at.add_exception 'vendor'
11
+ #
12
+ # at.add_mapping(/dependency.rb/) do |f, _|
13
+ # at.files_matching(/test_.*rb$/)
14
+ # end
15
+ #
16
+ # %w(TestA TestB).each do |klass|
17
+ # at.extra_class_map[klass] = "test/test_misc.rb"
18
+ # end
19
+ # end
20
+
21
+ # Autotest.add_hook :run_command do |at|
22
+ # system "rake build"
23
+ # end
@@ -0,0 +1,4 @@
1
+ === 0.1.0 / 2011-07-13
2
+
3
+ * Birthday!
4
+
@@ -0,0 +1,15 @@
1
+ .autotest
2
+ History.txt
3
+ Manifest.txt
4
+ README.rdoc
5
+ Rakefile
6
+ bin/pg_ldap_sync
7
+ config/sample-config.yaml
8
+ config/sample-config2.yaml
9
+ config/schema.yaml
10
+ lib/pg_ldap_sync.rb
11
+ lib/pg_ldap_sync/application.rb
12
+ test/fixtures/config-ldapdb.yaml
13
+ test/fixtures/ldapdb.yaml
14
+ test/ldap_server.rb
15
+ test/test_pg_ldap_sync.rb
@@ -0,0 +1,102 @@
1
+ = Use LDAP permissions in PostgreSQL
2
+
3
+ * http://github.com/larskanis/pg-ldap-sync
4
+
5
+ == DESCRIPTION:
6
+
7
+ LDAP is often used to do a centralized user and role management
8
+ in an enterprise environment. PostgreSQL offers different
9
+ authentication methods, like LDAP, SSPI, GSSAPI or SSL.
10
+ However, for any method the user must already exist in the database,
11
+ before the authentication can be used. There is currently
12
+ no authorization of database users directly based on LDAP.
13
+
14
+ This program helps to solve the issue by synchronizing users,
15
+ groups and their memberships from LDAP to PostgreSQL.
16
+ Access to LDAP is read-only. <tt>pg_ldap_sync</tt> issues proper
17
+ CREATE ROLE, DROP ROLE, GRANT and REVOKE commands to synchronize
18
+ users and groups.
19
+
20
+ It is meant to be started as a cron job.
21
+
22
+ == FEATURES:
23
+
24
+ * Configurable per YAML config file
25
+ * Can use Active Directory as LDAP-Server
26
+ * Nested groups/roles supported
27
+ * Runs with pg.gem (C-library) or postgres-pr.gem (pure Ruby)
28
+ * Test mode which doesn't do any changes to the DBMS
29
+
30
+ == REQUIREMENTS:
31
+
32
+ * Installed Ruby and rubygems
33
+ * LDAP- and PostgreSQL-Server
34
+
35
+ == INSTALL:
36
+
37
+ Install Ruby and rubygems:
38
+ * on Windows: http://rubyinstaller.org
39
+ * on Debian/Ubuntu: <tt>apt-get install ruby rubygems</tt>
40
+
41
+ Install pg-ldap-sync and a database connector for PostgreSQL:
42
+ gem install pg-ldap-sync pg
43
+ You may also use the pure ruby postgres-connector which is less mature,
44
+ but doesn't need compilation:
45
+ gem install pg-ldap-sync postgres-pr
46
+
47
+ === Install from Git:
48
+ git clone https://github.com/larskanis/pg-ldap-sync.git
49
+ cd pg-ldap-sync
50
+ gem install hoe
51
+ rake install_gem
52
+
53
+ == USAGE:
54
+
55
+ Create a config file based on <tt>config/sample-config.yaml</tt> .
56
+ Run in test-mode:
57
+
58
+ pg_ldap_sync -c my_config.yaml -vv -t
59
+
60
+ Run in modify-mode:
61
+
62
+ pg_ldap_sync -c my_config.yaml -vv
63
+
64
+
65
+ == TEST:
66
+ There is a small test suite in the <tt>test</tt> directory that runs
67
+ against an internal ruby-ldapserver and PostgreSQL server. Ensure gem
68
+ <tt>ruby-ldapserver</tt> is installed and <tt>pg_ctl</tt>, <tt>initdb</tt> and <tt>psql</tt>
69
+ commands are in the <tt>PATH</tt>. Then:
70
+
71
+ cd pg-ldap-sync
72
+ rake test
73
+
74
+ == ISSUES:
75
+ * There is currently no way to set certain user attributes in PG
76
+ based on individual attributes in LDAP (expiration date etc.)
77
+
78
+
79
+ == LICENSE:
80
+
81
+ (The MIT License)
82
+
83
+ Copyright (c) 2011 FIX
84
+
85
+ Permission is hereby granted, free of charge, to any person obtaining
86
+ a copy of this software and associated documentation files (the
87
+ 'Software'), to deal in the Software without restriction, including
88
+ without limitation the rights to use, copy, modify, merge, publish,
89
+ distribute, sublicense, and/or sell copies of the Software, and to
90
+ permit persons to whom the Software is furnished to do so, subject to
91
+ the following conditions:
92
+
93
+ The above copyright notice and this permission notice shall be
94
+ included in all copies or substantial portions of the Software.
95
+
96
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
97
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
98
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
99
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
100
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
101
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
102
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,18 @@
1
+ # -*- ruby -*-
2
+
3
+ require 'rubygems'
4
+ require 'hoe'
5
+
6
+ Hoe.spec 'pg-ldap-sync' do
7
+ developer('Lars Kanis', 'kanis@comcard.de')
8
+
9
+ extra_deps << ['net-ldap', '>= 0.2']
10
+ extra_deps << ['kwalify', '>= 0.7']
11
+ extra_dev_deps << ['ruby-ldapserver', '>= 0.3']
12
+
13
+ self.readme_file = 'README.rdoc'
14
+ spec_extras[:rdoc_options] = ['--main', readme_file, "--charset=UTF-8"]
15
+ self.extra_rdoc_files << self.readme_file
16
+ end
17
+
18
+ # vim: syntax=ruby
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'rubygems'
4
+ require 'pg_ldap_sync/application'
5
+
6
+ PgLdapSync::Application.run(ARGV)
@@ -0,0 +1,54 @@
1
+ # With this sample config the distinction between PG groups and users is
2
+ # done by the LOGIN/NOLOGIN attribute. Any non-superuser account
3
+ # is considered as LDAP-synchronized.
4
+
5
+ # Connection parameters to LDAP server
6
+ # see also: http://net-ldap.rubyforge.org/Net/LDAP.html#method-c-new
7
+ ldap_connection:
8
+ host: localhost
9
+ port: 389
10
+ auth:
11
+ method: :simple
12
+ username: CN=username,OU=!Serviceaccounts,OU=company,DC=company,DC=de
13
+ password: secret
14
+
15
+ # Search parameters for LDAP users which should be synchronized
16
+ ldap_users:
17
+ base: OU=company,OU=company,DC=company,DC=de
18
+ # LDAP filter (according to RFC 2254)
19
+ # defines to users in LDAP to be synchronized
20
+ filter: (&(objectClass=person)(objectClass=organizationalPerson)(givenName=*)(sn=*))
21
+ # this attribute is used as PG role name
22
+ name_attribute: sAMAccountName
23
+
24
+ # Search parameters for LDAP groups which should be synchronized
25
+ ldap_groups:
26
+ base: OU=company,OU=company,DC=company,DC=de
27
+ filter: (|(cn=group1)(cn=group2)(cn=group3))
28
+ # this attribute is used as PG role name
29
+ name_attribute: cn
30
+ # this attribute must reference to all member DN's of the given group
31
+ member_attribute: member
32
+
33
+ # Connection parameters to PostgreSQL server
34
+ # see also: http://rubydoc.info/gems/pg/0.11.0/PGconn#initialize-instance_method
35
+ pg_connection:
36
+ host:
37
+ dbname: postgres
38
+ user: db-username
39
+ password:
40
+
41
+ pg_users:
42
+ # Filter for identifying LDAP generated users in the database.
43
+ # It's the WHERE-condition to "SELECT rolname, oid FROM pg_roles"
44
+ filter: rolcanlogin AND NOT rolsuper
45
+ # Options for CREATE RULE statements
46
+ create_options: LOGIN
47
+
48
+ pg_groups:
49
+ # Filter for identifying LDAP generated groups in the database.
50
+ # It's the WHERE-condition to "SELECT rolname, oid FROM pg_roles"
51
+ filter: NOT rolcanlogin AND NOT rolsuper
52
+ # Options for CREATE RULE statements
53
+ create_options: NOLOGIN
54
+ grant_options:
@@ -0,0 +1,54 @@
1
+ # With this sample config the distinction between LDAP-synchronized
2
+ # groups/users from is done by the membership to ldap_user and
3
+ # ldap_group. These two roles has to be defined manally.
4
+
5
+ # Connection parameters to LDAP server
6
+ # see also: http://net-ldap.rubyforge.org/Net/LDAP.html#method-c-new
7
+ ldap_connection:
8
+ host: ldapserver
9
+ port: 389
10
+ auth:
11
+ method: :simple
12
+ username: CN=username,OU=!Serviceaccounts,OU=company,DC=company,DC=de
13
+ password: secret
14
+
15
+ # Search parameters for LDAP users which should be synchronized
16
+ ldap_users:
17
+ base: OU=company,DC=company,DC=prod
18
+ # LDAP filter (according to RFC 2254)
19
+ # defines to users in LDAP to be synchronized
20
+ filter: (&(objectClass=person)(objectClass=organizationalPerson)(givenName=*)(sn=*)(sAMAccountName=*))
21
+ # this attribute is used as PG role name
22
+ name_attribute: sAMAccountName
23
+
24
+ # Search parameters for LDAP groups which should be synchronized
25
+ ldap_groups:
26
+ base: OU=company,DC=company,DC=prod
27
+ filter: (cn=company.*)
28
+ # this attribute is used as PG role name
29
+ name_attribute: cn
30
+ # this attribute must reference to all member DN's of the given group
31
+ member_attribute: member
32
+
33
+ # Connection parameters to PostgreSQL server
34
+ # see also: http://rubydoc.info/gems/pg/0.11.0/PGconn#initialize-instance_method
35
+ pg_connection:
36
+ host:
37
+ dbname: postgres
38
+ user:
39
+ password:
40
+
41
+ pg_users:
42
+ # Filter for identifying LDAP generated users in the database.
43
+ # It's the WHERE-condition to "SELECT rolname, oid FROM pg_roles"
44
+ filter: oid IN (SELECT pam.member FROM pg_auth_members pam JOIN pg_roles pr ON pr.oid=pam.roleid WHERE pr.rolname='ldap_users')
45
+ # Options for CREATE RULE statements
46
+ create_options: LOGIN IN ROLE ldap_users
47
+
48
+ pg_groups:
49
+ # Filter for identifying LDAP generated groups in the database.
50
+ # It's the WHERE-condition to "SELECT rolname, oid FROM pg_roles"
51
+ filter: oid IN (SELECT pam.member FROM pg_auth_members pam JOIN pg_roles pr ON pr.oid=pam.roleid WHERE pr.rolname='ldap_groups')
52
+ # Options for CREATE RULE statements
53
+ create_options: NOLOGIN IN ROLE ldap_groups
54
+ grant_options:
@@ -0,0 +1,62 @@
1
+ type: map
2
+ mapping:
3
+ "ldap_connection":
4
+ type: any
5
+ required: yes
6
+
7
+ "ldap_users":
8
+ type: map
9
+ required: yes
10
+ mapping:
11
+ "base":
12
+ type: str
13
+ required: yes
14
+ "filter":
15
+ type: str
16
+ required: yes
17
+ "name_attribute":
18
+ type: str
19
+ required: yes
20
+
21
+ "ldap_groups":
22
+ type: map
23
+ required: yes
24
+ mapping:
25
+ "base":
26
+ type: str
27
+ required: yes
28
+ "filter":
29
+ type: str
30
+ required: yes
31
+ "name_attribute":
32
+ type: str
33
+ required: yes
34
+ "member_attribute":
35
+ type: str
36
+ required: yes
37
+
38
+ "pg_connection":
39
+ type: any
40
+ required: yes
41
+
42
+ "pg_users":
43
+ type: map
44
+ required: yes
45
+ mapping:
46
+ "filter":
47
+ type: str
48
+ required: yes
49
+ "create_options":
50
+ type: str
51
+
52
+ "pg_groups":
53
+ type: map
54
+ required: yes
55
+ mapping:
56
+ "filter":
57
+ type: str
58
+ required: yes
59
+ "create_options":
60
+ type: str
61
+ "grant_options":
62
+ type: str
@@ -0,0 +1,3 @@
1
+ module PgLdapSync
2
+ VERSION = '0.1.0'
3
+ end
@@ -0,0 +1,354 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'rubygems'
4
+ require 'net/ldap'
5
+ require 'optparse'
6
+ require 'yaml'
7
+ require 'logger'
8
+ require 'kwalify'
9
+
10
+ begin
11
+ require 'pg'
12
+ rescue LoadError => e
13
+ begin
14
+ require 'postgres'
15
+ class PGconn
16
+ alias initialize_before_hash_change initialize
17
+ def initialize(*args)
18
+ arg = args.first
19
+ if args.length==1 && arg.kind_of?(Hash)
20
+ initialize_before_hash_change(arg[:host], arg[:port], nil, nil, arg[:dbname], arg[:user], arg[:password])
21
+ else
22
+ initialize_before_hash_change(*args)
23
+ end
24
+ end
25
+ end
26
+ rescue LoadError
27
+ raise e
28
+ end
29
+ end
30
+
31
+ require 'pg_ldap_sync'
32
+
33
+ module PgLdapSync
34
+ class Application
35
+ class LdapError < RuntimeError; end
36
+ attr_accessor :config_fname
37
+ attr_accessor :log
38
+ attr_accessor :test
39
+
40
+ def string_to_symbol(hash)
41
+ if hash.kind_of?(Hash)
42
+ return hash.inject({}){|h, v|
43
+ raise "expected String instead of #{h.inspect}" unless v[0].kind_of?(String)
44
+ h[v[0].intern] = string_to_symbol(v[1])
45
+ h
46
+ }
47
+ else
48
+ return hash
49
+ end
50
+ end
51
+
52
+
53
+ def validate_config(config, schema, fname)
54
+ schema = YAML.load_file(schema)
55
+ validator = Kwalify::Validator.new(schema)
56
+ errors = validator.validate(config)
57
+ if errors && !errors.empty?
58
+ errors.each do |err|
59
+ log.fatal "error in #{fname}: [#{err.path}] #{err.message}"
60
+ end
61
+ exit(-1)
62
+ end
63
+ end
64
+
65
+ def read_config_file(fname)
66
+ raise "Config file #{fname.inspect} does not exist" unless File.exist?(fname)
67
+ config = YAML.load(File.read(fname))
68
+
69
+ schema_fname = File.join(File.dirname(__FILE__), '../../config/schema.yaml')
70
+ validate_config(config, schema_fname, fname)
71
+
72
+ @config = string_to_symbol(config)
73
+ end
74
+
75
+ LdapRole = Struct.new :name, :dn, :member_dns
76
+
77
+ def search_ldap_users
78
+ ldap_user_conf = @config[:ldap_users]
79
+
80
+ users = []
81
+ res = @ldap.search(:base => ldap_user_conf[:base], :filter => ldap_user_conf[:filter]) do |entry|
82
+ name = entry[ldap_user_conf[:name_attribute]].first
83
+
84
+ unless name
85
+ log.warn "user attribute #{ldap_user_conf[:name_attribute].inspect} not defined for #{entry.dn}"
86
+ next
87
+ end
88
+
89
+ log.info "found user-dn: #{entry.dn}"
90
+ user = LdapRole.new name, entry.dn
91
+ users << user
92
+ entry.each do |attribute, values|
93
+ log.debug " #{attribute}:"
94
+ values.each do |value|
95
+ log.debug " --->#{value.inspect}"
96
+ end
97
+ end
98
+ end
99
+ raise LdapError, "LDAP: #{@ldap.get_operation_result.message}" unless res
100
+ return users
101
+ end
102
+
103
+ def search_ldap_groups
104
+ ldap_group_conf = @config[:ldap_groups]
105
+
106
+ groups = []
107
+ res = @ldap.search(:base => ldap_group_conf[:base], :filter => ldap_group_conf[:filter]) do |entry|
108
+ name = entry[ldap_group_conf[:name_attribute]].first
109
+
110
+ unless name
111
+ log.warn "user attribute #{ldap_group_conf[:name_attribute].inspect} not defined for #{entry.dn}"
112
+ next
113
+ end
114
+
115
+ log.info "found group-dn: #{entry.dn}"
116
+ group = LdapRole.new name, entry.dn, entry[ldap_group_conf[:member_attribute]]
117
+ groups << group
118
+ entry.each do |attribute, values|
119
+ log.debug " #{attribute}:"
120
+ values.each do |value|
121
+ log.debug " --->#{value.inspect}"
122
+ end
123
+ end
124
+ end
125
+ raise LdapError, "LDAP: #{@ldap.get_operation_result.message}" unless res
126
+ return groups
127
+ end
128
+
129
+ PgRole = Struct.new :name, :member_names
130
+
131
+ def search_pg_users
132
+ pg_users_conf = @config[:pg_users]
133
+
134
+ users = []
135
+ res = pg_exec "SELECT rolname FROM pg_roles WHERE #{pg_users_conf[:filter]}"
136
+ res.each do |tuple|
137
+ user = PgRole.new tuple[0]
138
+ log.info{ "found pg-user: #{user.name.inspect}"}
139
+ users << user
140
+ end
141
+ return users
142
+ end
143
+
144
+ def search_pg_groups
145
+ pg_groups_conf = @config[:pg_groups]
146
+
147
+ groups = []
148
+ res = pg_exec "SELECT rolname, oid FROM pg_roles WHERE #{pg_groups_conf[:filter]}"
149
+ res.each do |tuple|
150
+ 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(tuple[1])}"
151
+ member_names = res2.map{|row| row[0] }
152
+ group = PgRole.new tuple[0], member_names
153
+ log.info{ "found pg-group: #{group.name.inspect} with members: #{member_names.inspect}"}
154
+ groups << group
155
+ end
156
+ return groups
157
+ end
158
+
159
+ def uniq_names(list)
160
+ names = {}
161
+ new_list = list.select do |entry|
162
+ name = entry.name
163
+ if names[name]
164
+ log.warn{ "duplicated group/user #{name.inspect} (#{entry.inspect})" }
165
+ next false
166
+ else
167
+ names[name] = true
168
+ next true
169
+ end
170
+ end
171
+ return new_list
172
+ end
173
+
174
+ MatchedRole = Struct.new :ldap, :pg, :name, :state, :type
175
+
176
+ def match_roles(ldaps, pgs, type)
177
+ ldap_by_name = ldaps.inject({}){|h,u| h[u.name] = u; h }
178
+ pg_by_name = pgs.inject({}){|h,u| h[u.name] = u; h }
179
+
180
+ roles = []
181
+ ldaps.each do |ld|
182
+ pg = pg_by_name[ld.name]
183
+ role = MatchedRole.new ld, pg, ld.name
184
+ roles << role
185
+ end
186
+ pgs.each do |pg|
187
+ ld = ldap_by_name[pg.name]
188
+ next if ld
189
+ role = MatchedRole.new ld, pg, pg.name
190
+ roles << role
191
+ end
192
+
193
+ roles.each do |r|
194
+ r.state = case
195
+ when r.ldap && !r.pg then :create
196
+ when !r.ldap && r.pg then :drop
197
+ when r.pg && r.ldap then :keep
198
+ else raise "invalid user #{r.inspect}"
199
+ end
200
+ r.type = type
201
+ end
202
+
203
+ log.info{
204
+ roles.each do |role|
205
+ log.debug{ "#{role.state} #{role.type}: #{role.name}" }
206
+ end
207
+ "#{type} stat: create: #{roles.count{|r| r.state==:create }} drop: #{roles.count{|r| r.state==:drop }} keep: #{roles.count{|r| r.state==:keep }}"
208
+ }
209
+ return roles
210
+ end
211
+
212
+ def pg_exec_modify(sql)
213
+ log.info{ "SQL: #{sql}" }
214
+ unless self.test
215
+ res = @pgconn.exec sql
216
+ end
217
+ end
218
+
219
+ def pg_exec(sql)
220
+ res = @pgconn.exec sql
221
+ (0...res.num_tuples).map{|t| (0...res.num_fields).map{|i| res.getvalue(t, i) } }
222
+ end
223
+
224
+ def create_pg_role(role)
225
+ pg_conf = @config[role.type==:user ? :pg_users : :pg_groups]
226
+ pg_exec_modify "CREATE ROLE \"#{role.name}\" #{pg_conf[:create_options]}"
227
+ end
228
+
229
+ def drop_pg_role(role)
230
+ pg_exec_modify "DROP ROLE \"#{role.name}\""
231
+ end
232
+
233
+ def sync_roles_to_pg(roles, for_state)
234
+ roles.sort{|a,b| a.name<=>b.name }.each do |role|
235
+ create_pg_role(role) if role.state==:create && for_state==:create
236
+ drop_pg_role(role) if role.state==:drop && for_state==:drop
237
+ end
238
+ end
239
+
240
+ MatchedMembership = Struct.new :role_name, :has_member, :state
241
+
242
+ def match_memberships(ldap_roles, pg_roles)
243
+ ldap_by_dn = ldap_roles.inject({}){|h,r| h[r.dn] = r; h }
244
+ ldap_by_m2m = ldap_roles.inject([]){|a,r|
245
+ next a unless r.member_dns
246
+ a + r.member_dns.map{|dn|
247
+ if has_member=ldap_by_dn[dn]
248
+ [r.name, has_member.name]
249
+ else
250
+ log.warn{"ldap member with dn #{dn} is unknown"}
251
+ nil
252
+ end
253
+ }.compact
254
+ }
255
+
256
+ pg_by_name = pg_roles.inject({}){|h,r| h[r.name] = r; h }
257
+ pg_by_m2m = pg_roles.inject([]){|a,r|
258
+ next a unless r.member_names
259
+ a + r.member_names.map{|name|
260
+ if has_member=pg_by_name[name]
261
+ [r.name, has_member.name]
262
+ else
263
+ log.warn{"pg member with name #{name} is unknown"}
264
+ nil
265
+ end
266
+ }.compact
267
+ }
268
+
269
+ memberships = (ldap_by_m2m & pg_by_m2m).map{|r,mo| MatchedMembership.new r, mo, :keep }
270
+ memberships += (ldap_by_m2m - pg_by_m2m).map{|r,mo| MatchedMembership.new r, mo, :grant }
271
+ memberships += (pg_by_m2m - ldap_by_m2m).map{|r,mo| MatchedMembership.new r, mo, :revoke }
272
+
273
+ log.info{
274
+ memberships.each do |membership|
275
+ log.debug{ "#{membership.state} #{membership.role_name} to #{membership.has_member}" }
276
+ end
277
+ "membership stat: grant: #{memberships.count{|u| u.state==:grant }} revoke: #{memberships.count{|u| u.state==:revoke }} keep: #{memberships.count{|u| u.state==:keep }}"
278
+ }
279
+ return memberships
280
+ end
281
+
282
+ def grant_membership(role_name, add_members)
283
+ pg_conf = @config[:pg_groups]
284
+ add_members_escaped = add_members.map{|m| "\"#{m}\"" }.join(",")
285
+ pg_exec_modify "GRANT \"#{role_name}\" TO #{add_members_escaped} #{pg_conf[:grant_options]}"
286
+ end
287
+
288
+ def revoke_membership(role_name, rm_members)
289
+ rm_members_escaped = rm_members.map{|m| "\"#{m}\"" }.join(",")
290
+ pg_exec_modify "REVOKE \"#{role_name}\" FROM #{rm_members_escaped}"
291
+ end
292
+
293
+ def sync_membership_to_pg(memberships, for_state)
294
+ grants = {}
295
+ memberships.select{|ms| ms.state==for_state }.each do |ms|
296
+ grants[ms.role_name] ||= []
297
+ grants[ms.role_name] << ms.has_member
298
+ end
299
+
300
+ grants.each do |role_name, members|
301
+ grant_membership(role_name, members) if for_state==:grant
302
+ revoke_membership(role_name, members) if for_state==:revoke
303
+ end
304
+ end
305
+
306
+ def start!
307
+ read_config_file(@config_fname)
308
+
309
+ # gather LDAP users and groups
310
+ @ldap = Net::LDAP.new @config[:ldap_connection]
311
+ ldap_users = uniq_names search_ldap_users
312
+ ldap_groups = uniq_names search_ldap_groups
313
+
314
+ # gather PGs users and groups
315
+ @pgconn = PGconn.connect @config[:pg_connection]
316
+ pg_users = uniq_names search_pg_users
317
+ pg_groups = uniq_names search_pg_groups
318
+
319
+ # compare LDAP to PG users and groups
320
+ mroles = match_roles(ldap_users, pg_users, :user)
321
+ mroles += match_roles(ldap_groups, pg_groups, :group)
322
+
323
+ # compare LDAP to PG memberships
324
+ mmemberships = match_memberships(ldap_users+ldap_groups, pg_users+pg_groups)
325
+
326
+ # drop/revoke roles/memberships first
327
+ sync_membership_to_pg(mmemberships, :revoke)
328
+ sync_roles_to_pg(mroles, :drop)
329
+ # create/grant roles/memberships
330
+ sync_roles_to_pg(mroles, :create)
331
+ sync_membership_to_pg(mmemberships, :grant)
332
+
333
+ @pgconn.close
334
+ end
335
+
336
+ def self.run(argv)
337
+ s = self.new
338
+ s.config_fname = '/etc/pg_ldap_sync.yaml'
339
+ s.log = Logger.new(STDOUT)
340
+ s.log.level = Logger::ERROR
341
+
342
+ OptionParser.new do |opts|
343
+ opts.banner = "Usage: #{$0} [options]"
344
+ opts.on("-v", "--[no-]verbose", "Increase verbose level"){ s.log.level-=1 }
345
+ opts.on("-c", "--config FILE", "Config file [#{s.config_fname}]", &s.method(:config_fname=))
346
+ opts.on("-t", "--[no-]test", "Don't do any change in the database", &s.method(:test=))
347
+
348
+ opts.parse!(argv)
349
+ end
350
+
351
+ s.start!
352
+ end
353
+ end
354
+ end
@@ -0,0 +1,32 @@
1
+ ---
2
+ ldap_connection:
3
+ host: localhost
4
+ port: 1389
5
+
6
+ ldap_users:
7
+ base: dc=example,dc=com
8
+ filter: (&(cn=*)(sAMAccountName=*))
9
+ name_attribute: sAMAccountName
10
+
11
+ ldap_groups:
12
+ base: dc=example,dc=com
13
+ filter: (member=*)
14
+ name_attribute: cn
15
+ member_attribute: member
16
+
17
+ pg_connection:
18
+ dbname: postgres
19
+ # needed for postgres-pr:
20
+ # host: localhost
21
+ # port: 54321
22
+ # user: insert_your_username_here
23
+ # password:
24
+
25
+ pg_users:
26
+ filter: rolcanlogin AND NOT rolsuper
27
+ create_options: LOGIN
28
+
29
+ pg_groups:
30
+ filter: NOT rolcanlogin
31
+ create_options: NOLOGIN
32
+ grant_options:
@@ -0,0 +1,38 @@
1
+ ---
2
+ dc=example,dc=com:
3
+ cn:
4
+ - Top object
5
+ cn=Fred Flintstone,dc=example,dc=com:
6
+ cn:
7
+ - Fred Flintstone
8
+ mail:
9
+ - fred@bedrock.org
10
+ - fred.flintstone@bedrock.org
11
+ sn:
12
+ - Flintstone
13
+ sAMAccountName:
14
+ - fred
15
+ cn=Wilma Flintstone,dc=example,dc=com:
16
+ cn:
17
+ - Wilma Flintstone
18
+ mail:
19
+ - wilma@bedrock.org
20
+ sAMAccountName:
21
+ - wilma
22
+ cn=Flintstones,dc=example,dc=com:
23
+ cn:
24
+ - Flintstones
25
+ member:
26
+ - cn=Fred Flintstone,dc=example,dc=com
27
+ - cn=Wilma Flintstone,dc=example,dc=com
28
+ cn=Wilmas,dc=example,dc=com:
29
+ cn:
30
+ - Wilmas
31
+ member:
32
+ - cn=Wilma Flintstone,dc=example,dc=com
33
+ cn=All Users,dc=example,dc=com:
34
+ cn:
35
+ - All Users
36
+ member:
37
+ - cn=Wilmas,dc=example,dc=com
38
+ - cn=Fred Flintstone,dc=example,dc=com
@@ -0,0 +1,41 @@
1
+ #!/usr/local/bin/ruby -w
2
+
3
+ # This is a trivial LDAP server which just stores directory entries in RAM.
4
+ # It does no validation or authentication. This is intended just to
5
+ # demonstrate the API, it's not for real-world use!!
6
+
7
+ require 'rubygems'
8
+ require 'ldap/server'
9
+
10
+ # We subclass the Operation class, overriding the methods to do what we need
11
+
12
+ class HashOperation < LDAP::Server::Operation
13
+ def initialize(connection, messageID, hash)
14
+ super(connection, messageID)
15
+ @hash = hash # an object reference to our directory data
16
+ end
17
+
18
+ def search(basedn, scope, deref, filter)
19
+ basedn.downcase!
20
+
21
+ case scope
22
+ when LDAP::Server::BaseObject
23
+ # client asked for single object by DN
24
+ obj = @hash[basedn]
25
+ raise LDAP::ResultError::NoSuchObject unless obj
26
+ send_SearchResultEntry(basedn, obj) if LDAP::Server::Filter.run(filter, obj)
27
+
28
+ when LDAP::Server::WholeSubtree
29
+ @hash.each do |dn, av|
30
+ next unless dn.index(basedn, -basedn.length) # under basedn?
31
+ next unless LDAP::Server::Filter.run(filter, av) # attribute filter?
32
+ send_SearchResultEntry(dn, av)
33
+ end
34
+
35
+ else
36
+ raise LDAP::ResultError::UnwillingToPerform, "OneLevel not implemented"
37
+
38
+ end
39
+ end
40
+ end
41
+
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "test/unit"
4
+ require "pg_ldap_sync/application"
5
+ require 'yaml'
6
+ require 'test/ldap_server'
7
+ require 'fileutils'
8
+
9
+ class TestPgLdapSync < Test::Unit::TestCase
10
+ def log_and_run( *cmd )
11
+ puts cmd.join(' ')
12
+ system( *cmd )
13
+ raise "Command failed: [%s]" % [cmd.join(' ')] unless $?.success?
14
+ end
15
+
16
+ def start_ldap_server
17
+ yaml_fname = File.join(File.dirname(__FILE__), "fixtures/ldapdb.yaml")
18
+ @directory = File.open(yaml_fname){|f| YAML::load(f.read) }
19
+
20
+ # Listen for incoming LDAP connections. For each one, create a Connection
21
+ # object, which will invoke a HashOperation object for each request.
22
+
23
+ @ldap_server = LDAP::Server.new(
24
+ :port => 1389,
25
+ :nodelay => true,
26
+ :listen => 10,
27
+ # :ssl_key_file => "key.pem",
28
+ # :ssl_cert_file => "cert.pem",
29
+ # :ssl_on_connect => true,
30
+ :operation_class => HashOperation,
31
+ :operation_args => [@directory]
32
+ )
33
+ @ldap_server.run_tcpserver
34
+ end
35
+
36
+ def stop_ldap_server
37
+ @ldap_server.stop
38
+ end
39
+
40
+ def start_pg_server
41
+ @port = 54321
42
+ ENV['PGPORT'] = @port.to_s
43
+ ENV['PGHOST'] = 'localhost'
44
+ unless File.exist?('temp/pg_data')
45
+ FileUtils.mkdir_p 'temp/pg_data'
46
+ log_and_run 'initdb', '-D', 'temp/pg_data'
47
+ end
48
+ log_and_run 'pg_ctl', '-w', '-o', "-k.", '-D', 'temp/pg_data', 'start'
49
+ log_and_run 'psql', '-e', '-c', "DROP ROLE IF EXISTS fred, wilma, \"Flintstones\", \"Wilmas\", \"All Users\"", 'postgres'
50
+ end
51
+
52
+ def stop_pg_server
53
+ log_and_run 'pg_ctl', '-w', '-o', "-k.", '-D', 'temp/pg_data', 'stop'
54
+ end
55
+
56
+ def setup
57
+ start_ldap_server
58
+ start_pg_server
59
+ end
60
+
61
+ def teardown
62
+ stop_ldap_server
63
+ stop_pg_server
64
+ end
65
+
66
+ def psqlre(*args)
67
+ /^\s*#{args[0]}[ |]*#{args[1]}[ |\{"]*#{args[2..-1].join('[", ]+')}["\}\s]*$/
68
+ end
69
+
70
+ def test_sanity
71
+ PgLdapSync::Application.run(%w[-c test/fixtures/config-ldapdb.yaml -vv])
72
+
73
+ ENV['LC_MESSAGES'] = 'C'
74
+ psql_du = `psql -c \\\\du postgres`
75
+ puts psql_du
76
+
77
+ assert_match(psqlre('All Users','Cannot login'), psql_du)
78
+ assert_match(psqlre('Flintstones','Cannot login'), psql_du)
79
+ assert_match(psqlre('Wilmas','Cannot login','All Users'), psql_du)
80
+ assert_match(psqlre('fred','','All Users','Flintstones'), psql_du)
81
+ assert_match(psqlre('wilma','','Flintstones','Wilmas'), psql_du)
82
+
83
+ # revoke membership of 'wilma' to 'Flintstones'
84
+ @directory['cn=Flintstones,dc=example,dc=com']['member'].pop
85
+
86
+ PgLdapSync::Application.run(%w[-c test/fixtures/config-ldapdb.yaml -vv])
87
+ psql_du = `psql -c \\\\du postgres`
88
+ puts psql_du
89
+
90
+ assert_match(psqlre('All Users','Cannot login'), psql_du)
91
+ assert_match(psqlre('Flintstones','Cannot login'), psql_du)
92
+ assert_match(psqlre('Wilmas','Cannot login','All Users'), psql_du)
93
+ assert_match(psqlre('fred','','All Users','Flintstones'), psql_du)
94
+ assert_match(psqlre('wilma','','Wilmas'), psql_du)
95
+
96
+ # rename role 'wilma'
97
+ @directory['cn=Wilma Flintstone,dc=example,dc=com']['sAMAccountName'] = ['Wilma Flintstone']
98
+ # re-add 'Wilma' to 'Flintstones'
99
+ @directory['cn=Flintstones,dc=example,dc=com']['member'] << 'cn=Wilma Flintstone,dc=example,dc=com'
100
+
101
+ PgLdapSync::Application.run(%w[-c test/fixtures/config-ldapdb.yaml -vv])
102
+ psql_du = `psql -c \\\\du postgres`
103
+ puts psql_du
104
+
105
+ assert_match(psqlre('All Users','Cannot login'), psql_du)
106
+ assert_match(psqlre('Flintstones','Cannot login'), psql_du)
107
+ assert_match(psqlre('Wilmas','Cannot login','All Users'), psql_du)
108
+ assert_match(psqlre('fred','','All Users','Flintstones'), psql_du)
109
+ assert_no_match(/wilma/, psql_du)
110
+ assert_match(psqlre('Wilma Flintstone','','Flintstones','Wilmas'), psql_du)
111
+ end
112
+ end
metadata ADDED
@@ -0,0 +1,160 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: pg-ldap-sync
3
+ version: !ruby/object:Gem::Version
4
+ hash: 27
5
+ prerelease: false
6
+ segments:
7
+ - 0
8
+ - 1
9
+ - 0
10
+ version: 0.1.0
11
+ platform: ruby
12
+ authors:
13
+ - Lars Kanis
14
+ autorequire:
15
+ bindir: bin
16
+ cert_chain: []
17
+
18
+ date: 2011-07-13 00:00:00 +02:00
19
+ default_executable:
20
+ dependencies:
21
+ - !ruby/object:Gem::Dependency
22
+ name: net-ldap
23
+ prerelease: false
24
+ requirement: &id001 !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ hash: 15
30
+ segments:
31
+ - 0
32
+ - 2
33
+ version: "0.2"
34
+ type: :runtime
35
+ version_requirements: *id001
36
+ - !ruby/object:Gem::Dependency
37
+ name: kwalify
38
+ prerelease: false
39
+ requirement: &id002 !ruby/object:Gem::Requirement
40
+ none: false
41
+ requirements:
42
+ - - ">="
43
+ - !ruby/object:Gem::Version
44
+ hash: 5
45
+ segments:
46
+ - 0
47
+ - 7
48
+ version: "0.7"
49
+ type: :runtime
50
+ version_requirements: *id002
51
+ - !ruby/object:Gem::Dependency
52
+ name: ruby-ldapserver
53
+ prerelease: false
54
+ requirement: &id003 !ruby/object:Gem::Requirement
55
+ none: false
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ hash: 13
60
+ segments:
61
+ - 0
62
+ - 3
63
+ version: "0.3"
64
+ type: :development
65
+ version_requirements: *id003
66
+ - !ruby/object:Gem::Dependency
67
+ name: hoe
68
+ prerelease: false
69
+ requirement: &id004 !ruby/object:Gem::Requirement
70
+ none: false
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ hash: 47
75
+ segments:
76
+ - 2
77
+ - 8
78
+ - 0
79
+ version: 2.8.0
80
+ type: :development
81
+ version_requirements: *id004
82
+ description: |-
83
+ LDAP is often used to do a centralized user and role management
84
+ in an enterprise environment. PostgreSQL offers different
85
+ authentication methods, like LDAP, SSPI, GSSAPI or SSL.
86
+ However, for any method the user must already exist in the database,
87
+ before the authentication can be used. There is currently
88
+ no authorization of database users directly based on LDAP.
89
+
90
+ This program helps to solve the issue by synchronizing users,
91
+ groups and their memberships from LDAP to PostgreSQL.
92
+ Access to LDAP is read-only. <tt>pg_ldap_sync</tt> issues proper
93
+ CREATE ROLE, DROP ROLE, GRANT and REVOKE commands to synchronize
94
+ users and groups.
95
+
96
+ It is meant to be started as a cron job.
97
+ email:
98
+ - kanis@comcard.de
99
+ executables:
100
+ - pg_ldap_sync
101
+ extensions: []
102
+
103
+ extra_rdoc_files:
104
+ - History.txt
105
+ - Manifest.txt
106
+ - README.rdoc
107
+ files:
108
+ - .autotest
109
+ - History.txt
110
+ - Manifest.txt
111
+ - README.rdoc
112
+ - Rakefile
113
+ - bin/pg_ldap_sync
114
+ - config/sample-config.yaml
115
+ - config/sample-config2.yaml
116
+ - config/schema.yaml
117
+ - lib/pg_ldap_sync.rb
118
+ - lib/pg_ldap_sync/application.rb
119
+ - test/fixtures/config-ldapdb.yaml
120
+ - test/fixtures/ldapdb.yaml
121
+ - test/ldap_server.rb
122
+ - test/test_pg_ldap_sync.rb
123
+ has_rdoc: true
124
+ homepage: http://github.com/larskanis/pg-ldap-sync
125
+ licenses: []
126
+
127
+ post_install_message:
128
+ rdoc_options:
129
+ - --main
130
+ - README.rdoc
131
+ - --charset=UTF-8
132
+ require_paths:
133
+ - lib
134
+ required_ruby_version: !ruby/object:Gem::Requirement
135
+ none: false
136
+ requirements:
137
+ - - ">="
138
+ - !ruby/object:Gem::Version
139
+ hash: 3
140
+ segments:
141
+ - 0
142
+ version: "0"
143
+ required_rubygems_version: !ruby/object:Gem::Requirement
144
+ none: false
145
+ requirements:
146
+ - - ">="
147
+ - !ruby/object:Gem::Version
148
+ hash: 3
149
+ segments:
150
+ - 0
151
+ version: "0"
152
+ requirements: []
153
+
154
+ rubyforge_project: pg-ldap-sync
155
+ rubygems_version: 1.3.7
156
+ signing_key:
157
+ specification_version: 3
158
+ summary: LDAP is often used to do a centralized user and role management in an enterprise environment
159
+ test_files:
160
+ - test/test_pg_ldap_sync.rb