Skip to content

Commit ea34628

Browse files
authored
FEATURE: Allow multiple attributes for group sync and also using group full_name (#127)
This commit - 1️⃣ extends the `saml_groups_attribute` to accept an array of attributes, and - 2️⃣ adds a new setting: `saml_groups_use_full_name`. - 3️⃣ it ensures the group sync feature removes the groups from the old attributes if it exists See more here: #127
1 parent eb3af5c commit ea34628

4 files changed

Lines changed: 181 additions & 42 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,11 @@ Add the following settings to your `discourse.conf` file:
4242
### Group sync
4343

4444
- `DISCOURSE_SAML_SYNC_GROUPS`: Sync groups. Defaults to false.
45-
- `DISCOURSE_SAML_GROUPS_ATTRIBUTE`: SAML attribute to use for group sync. Defaults to `memberOf`
45+
- `DISCOURSE_SAML_GROUPS_ATTRIBUTE`: SAML attribute to use for group sync. Defaults to `memberOf`, and accepts '|' separated attributes `Country|Department`.
4646
- `DISCOURSE_SAML_GROUPS_FULLSYNC`: Should the assigned groups be completely synced including adding AND removing groups based on the IDP? Defaults to false. If set to true, `DISCOURSE_SAML_SYNC_GROUPS_LIST` and SAML attribute `groups_to_add`/`groups_to_remove` are not used.
4747
- `DISCOURSE_SAML_GROUPS_LDAP_LEAFCN`: If your IDP transmits `cn=groupname,cn=groups,dc=example,dc=com` you can set this to true to use only `groupname`. This is useful if you want to keep the standard group name length of Discourse (20 characters).
4848
- `DISCOURSE_SAML_SYNC_GROUPS_LIST`: Groups mentioned in this list are synced if they are referenced by the IDP (in `memberOf` SAML attribue). Any other groups will not be removed/updated.
49+
- `DISCOURSE_SAML_GROUPS_USE_FULL_NAME`: If set to true, will match groups based on Discourse Group `full_name` instead of the default `name`. This allows usage of group names with spaces in them, e.g. "North Africa" instead of "north_africa".
4950

5051
### Other Supported settings
5152

config/settings.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,10 @@ saml:
5959

6060
saml_sync_groups: false
6161
saml_groups_fullsync: false
62-
saml_groups_attribute: "memberOf"
62+
saml_groups_attribute:
63+
type: list
64+
default: "memberOf"
65+
saml_groups_use_full_name: false
6366
saml_groups_ldap_leafcn: false
6467
saml_sync_groups_list:
6568
type: list

lib/saml_authenticator.rb

Lines changed: 51 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ def after_authenticate(auth)
114114

115115
auth[:uid] = attributes.single("uid") || auth[:uid] if setting(:use_attributes_uid)
116116
uid = auth[:uid]
117+
previous_attributes =
118+
UserAssociatedAccount.find_by(provider_name: name, provider_uid: uid)&.extra
117119

118120
auth.info[:email] ||= uid if uid.to_s&.include?("@")
119121

@@ -141,7 +143,7 @@ def after_authenticate(auth)
141143
result.email_valid
142144
else
143145
user = result.user
144-
sync_groups(user, attributes, info)
146+
sync_groups(user, attributes, info, previous_attributes)
145147
sync_custom_fields(user, attributes, info)
146148
sync_moderator(user, attributes)
147149
sync_admin(user, attributes)
@@ -205,51 +207,69 @@ def auto_create_account(result, uid)
205207
end
206208
end
207209

208-
def sync_groups(user, attributes, info)
210+
def sync_groups(user, attributes, info, previous_attributes = nil)
209211
return if setting(:sync_groups).blank?
210212

211213
groups_fullsync = setting(:groups_fullsync)
212-
raw_group_list = attributes.multi(setting(:groups_attribute)) || []
213-
user_group_list = raw_group_list.map { |g| g.downcase.split(",") }.flatten
214+
groups_attributes = setting(:groups_attribute).split("|")
215+
group_match_column = setting(:groups_use_full_name) ? "full_name" : "name"
216+
217+
groups_from_groups_attributes =
218+
groups_attributes
219+
.flat_map { |attr| attributes.multi(attr.strip) || [] }
220+
.compact
221+
.map { |g| g.downcase.split(",") }
222+
.flatten
223+
.uniq
214224

215225
if setting(:groups_ldap_leafcn).present?
216226
# Change cn=groupname,cn=groups,dc=example,dc=com to groupname
217-
user_group_list = user_group_list.map { |group| group.split("=", 2).last }
227+
groups_from_groups_attributes =
228+
groups_from_groups_attributes.map { |group| group.split("=", 2).last }
218229
end
219230

220231
if groups_fullsync
221-
user_has_groups = user.groups.where(automatic: false).pluck(:name).map(&:downcase)
222-
groups_to_add = user_group_list - user_has_groups
223-
groups_to_remove = user_has_groups - user_group_list if user_has_groups.present?
224-
else
225-
total_group_list = setting(:sync_groups_list).split("|").map(&:downcase)
232+
user_has_groups =
233+
user.groups.where(automatic: false).pluck(group_match_column).compact.map(&:downcase)
226234

235+
groups_to_add = groups_from_groups_attributes - user_has_groups
236+
groups_to_remove = user_has_groups - groups_from_groups_attributes if user_has_groups.present?
237+
else
227238
groups_to_add = info["groups_to_add"] || attributes.multi("groups_to_add")&.join(",") || ""
228239
groups_to_add = groups_to_add.downcase.split(",")
229-
groups_to_add += user_group_list
240+
groups_to_add += groups_from_groups_attributes
230241

231242
groups_to_remove =
232243
info["groups_to_remove"] || attributes.multi("groups_to_remove")&.join(",") || ""
233244
groups_to_remove = groups_to_remove.downcase.split(",")
234245

235-
if total_group_list.present?
236-
groups_to_add = total_group_list & groups_to_add
246+
groups_from_attributes_before_sync =
247+
get_groups_from_attributes(user:, groups_attributes:, previous_attributes:)
248+
groups_to_remove += (groups_from_attributes_before_sync - groups_from_groups_attributes)
249+
250+
groups_allowlist = setting(:sync_groups_list).split("|").map(&:downcase)
251+
if groups_allowlist.present?
252+
groups_to_add = groups_allowlist & groups_to_add
237253

238254
removable_groups = groups_to_remove.dup
239-
groups_to_remove = total_group_list - groups_to_add
255+
groups_to_remove = groups_allowlist - groups_to_add
240256
groups_to_remove &= removable_groups if removable_groups.present?
241257
end
242258
end
243259

244-
return if user_group_list.blank? && groups_to_add.blank? && groups_to_remove.blank?
260+
return if groups_to_add.blank? && groups_to_remove.blank?
245261

246-
Group
247-
.where("LOWER(name) IN (?) AND NOT automatic", groups_to_add)
248-
.each { |group| group.add user }
262+
if groups_to_add.present?
263+
Group
264+
.where("LOWER(#{group_match_column}) IN (?) AND NOT automatic", groups_to_add)
265+
.each { |group| group.add user }
266+
end
249267

250-
Group
251-
.where("LOWER(name) IN (?) AND NOT automatic", groups_to_remove)
252-
.each { |group| group.remove user }
268+
if groups_to_remove.present?
269+
Group
270+
.where("LOWER(#{group_match_column}) IN (?) AND NOT automatic", groups_to_remove)
271+
.each { |group| group.remove user }
272+
end
253273
end
254274

255275
def sync_custom_fields(user, attributes, info)
@@ -355,6 +375,16 @@ def self.saml_base_url
355375

356376
private
357377

378+
def get_groups_from_attributes(user:, groups_attributes:, previous_attributes: nil)
379+
return [] unless previous_attributes.is_a?(Hash)
380+
381+
groups_attributes
382+
.flat_map { |attr| Array(previous_attributes.dig("raw_info", attr)) }
383+
.compact
384+
.uniq
385+
.map(&:downcase)
386+
end
387+
358388
def idp_cert_multi
359389
return if setting(:cert_multi).blank?
360390

spec/saml_authenticator_spec.rb

Lines changed: 124 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -330,8 +330,10 @@ def auth_hash(attributes)
330330
describe "Group Syncing" do
331331
fab!(:group1) { Fabricate(:group, name: "uno", full_name: "Group One") }
332332
fab!(:group2) { Fabricate(:group, name: "dos", full_name: "Group Two") }
333-
fab!(:group3) { Fabricate(:group, name: "tres", full_name: "Group Three") }
334-
fab!(:original_group) { Fabricate(:group, name: "original_group").tap { |g| g.add(user) } }
333+
fab!(:group_without_fullname) { Fabricate(:group, name: "tres") }
334+
fab!(:original_group) do
335+
Fabricate(:group, name: "original_group", full_name: "The Origin").tap { |g| g.add(user) }
336+
end
335337

336338
before { SiteSetting.saml_sync_groups = true }
337339

@@ -340,38 +342,45 @@ def auth_hash(attributes)
340342
hash =
341343
auth_hash(
342344
"memberOf" => [group1.name, group2.name],
343-
"groups_to_add" => [group3.name],
345+
"groups_to_add" => [group_without_fullname.name],
344346
"groups_to_remove" => [original_group.name],
345347
)
346348

347349
result = authenticator.after_authenticate(hash)
348350
expect(result.user.groups.pluck(:name)).to contain_exactly(
349351
group1.name,
350352
group2.name,
351-
group3.name,
353+
group_without_fullname.name,
352354
)
353355
end
354356

355357
it "sync users to the given groups within scope" do
356-
SiteSetting.saml_sync_groups_list = [group2.name, group3.name, original_group.name].join(
357-
"|",
358-
)
358+
SiteSetting.saml_sync_groups_list = [
359+
group2.name,
360+
group_without_fullname.name,
361+
original_group.name,
362+
].join("|")
359363
hash =
360364
auth_hash(
361365
"memberOf" => [group1.name, group2.name],
362-
"groups_to_add" => [group3.name],
366+
"groups_to_add" => [group_without_fullname.name],
363367
"groups_to_remove" => [original_group.name],
364368
)
365369

366370
result = authenticator.after_authenticate(hash)
367-
expect(result.user.groups.pluck(:name)).to contain_exactly(group2.name, group3.name)
371+
expect(result.user.groups.pluck(:name)).to contain_exactly(
372+
group2.name,
373+
group_without_fullname.name,
374+
)
368375
end
369376
end
370377

371378
describe "sync_groups with LDAP leaf cn" do
372379
let!(:group1_ldap) { "cn=#{group1.name},cn=groups,dc=example,dc=com" }
373380
let!(:group2_ldap) { "cn=#{group2.name},cn=groups,dc=example,dc=com" }
374-
let!(:group3_ldap) { "cn=#{group3.name},cn=groups,dc=example,dc=com" }
381+
let!(:group_without_fullname_ldap) do
382+
"cn=#{group_without_fullname.name},cn=groups,dc=example,dc=com"
383+
end
375384
let!(:original_group_ldap) { "cn=#{original_group.name},cn=groups,dc=example,dc=com" }
376385

377386
before { SiteSetting.saml_groups_ldap_leafcn = true }
@@ -380,7 +389,7 @@ def auth_hash(attributes)
380389
hash =
381390
auth_hash(
382391
"memberOf" => [group1_ldap, group2_ldap],
383-
"groups_to_add" => [group3.name],
392+
"groups_to_add" => [group_without_fullname.name],
384393
"groups_to_remove" => [original_group.name],
385394
)
386395

@@ -390,24 +399,29 @@ def auth_hash(attributes)
390399
expect(result.user.groups.pluck(:name)).to contain_exactly(
391400
group1.name,
392401
group2.name,
393-
group3.name,
402+
group_without_fullname.name,
394403
)
395404
end
396405

397406
it "sync users to the groups within scope" do
398-
SiteSetting.saml_sync_groups_list = [group2.name, group3.name, original_group.name].join(
399-
"|",
400-
)
407+
SiteSetting.saml_sync_groups_list = [
408+
group2.name,
409+
group_without_fullname.name,
410+
original_group.name,
411+
].join("|")
401412

402413
hash =
403414
auth_hash(
404415
"memberOf" => [group1_ldap, group2_ldap],
405-
"groups_to_add" => [group3.name],
416+
"groups_to_add" => [group_without_fullname.name],
406417
"groups_to_remove" => [original_group.name],
407418
)
408419

409420
result = authenticator.after_authenticate(hash)
410-
expect(result.user.groups.pluck(:name)).to contain_exactly(group2.name, group3.name)
421+
expect(result.user.groups.pluck(:name)).to contain_exactly(
422+
group2.name,
423+
group_without_fullname.name,
424+
)
411425
end
412426
end
413427

@@ -425,20 +439,111 @@ def auth_hash(attributes)
425439
end
426440

427441
it "full sync, ignoring values in group list and groups_to_add/groups_to_remove" do
428-
SiteSetting.saml_sync_groups_list = [group2.name, group3.name].join("|")
442+
SiteSetting.saml_sync_groups_list = [group2.name, group_without_fullname.name].join("|")
429443

430444
hash =
431445
auth_hash(
432446
"memberOf" => [group1.name, group2.name],
433447
"groups_to_add" => [original_group.name],
434-
"groups_to_remove" => [group3.name],
448+
"groups_to_remove" => [group_without_fullname.name],
435449
)
436450

437451
result = authenticator.after_authenticate(hash)
438452

439453
expect(result.user.groups.pluck(:name)).to contain_exactly(group1.name, group2.name)
440454
end
441455
end
456+
457+
describe "saml_groups_attribute" do
458+
it "syncs groups from the saml_groups_attribute setting" do
459+
SiteSetting.saml_groups_attribute = "notTheDefault"
460+
hash = auth_hash("notTheDefault" => [group1.name, group2.name])
461+
462+
result = authenticator.after_authenticate(hash)
463+
expect(result.user.groups.pluck(:name)).to contain_exactly(
464+
original_group.name,
465+
group1.name,
466+
group2.name,
467+
)
468+
end
469+
470+
it "removes groups from the previously saved saml_groups_attributes in raw_info" do
471+
SiteSetting.saml_groups_attribute = "notTheDefault"
472+
473+
# user's existing group associations and user_associated_account
474+
group1.add(user)
475+
user.user_associated_accounts.create!(
476+
provider_name: "saml",
477+
provider_uid: uid,
478+
user:,
479+
extra: {
480+
raw_info: {
481+
"memberOf" => [group_without_fullname.name], # this is ignored as it is not the correct attribute
482+
"notTheDefault" => [group1.name], # this is the correct attribute
483+
},
484+
},
485+
)
486+
487+
# new auth hash with a different group
488+
hash = auth_hash("notTheDefault" => [group2.name])
489+
490+
result = authenticator.after_authenticate(hash)
491+
expect(result.user.groups.pluck(:name)).to contain_exactly(
492+
# group1 should be removed
493+
original_group.name,
494+
group2.name,
495+
)
496+
end
497+
498+
it "allows the attribute to specify an array, and assigns groups from those attributes" do
499+
SiteSetting.saml_groups_attribute = "Country|Hemisphere"
500+
hash =
501+
auth_hash(
502+
"Country" => [group1.name, group2.name],
503+
"Hemisphere" => [group_without_fullname.name],
504+
)
505+
506+
result = authenticator.after_authenticate(hash)
507+
expect(result.user.groups.pluck(:name)).to contain_exactly(
508+
original_group.name,
509+
group1.name,
510+
group2.name,
511+
group_without_fullname.name,
512+
)
513+
end
514+
end
515+
516+
describe "saml_groups_use_full_name" do
517+
before { SiteSetting.saml_groups_use_full_name = true }
518+
519+
it "adds users to groups based on group's case insensitive full_names" do
520+
SiteSetting.saml_groups_attribute = "oneAttribute|twoAttribute" # ensure compat
521+
SiteSetting.saml_sync_groups_list = [group1.full_name, group2.full_name].join("|") # ensure compat
522+
523+
hash =
524+
auth_hash(
525+
"oneAttribute" => [group1.full_name.upcase, "I don't exist"],
526+
"twoAttribute" => [group2.full_name],
527+
)
528+
529+
result = authenticator.after_authenticate(hash)
530+
expect(result.user.groups.pluck(:name)).to contain_exactly(
531+
group1.name,
532+
group2.name,
533+
original_group.name,
534+
)
535+
end
536+
537+
it "is compatible with full_sync" do
538+
SiteSetting.saml_groups_use_full_name = true
539+
SiteSetting.saml_groups_fullsync = true
540+
541+
hash = auth_hash("memberOf" => [group1.full_name])
542+
543+
result = authenticator.after_authenticate(hash)
544+
expect(result.user.groups.pluck(:name)).to contain_exactly(group1.name)
545+
end
546+
end
442547
end
443548

444549
describe "set moderator" do

0 commit comments

Comments
 (0)