diff --git a/Gemfile.lock b/Gemfile.lock index ab9e1c6d698a..9b06035dc45b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -155,6 +155,11 @@ PATH specs: openproject-job_status (1.0.0) +PATH + remote: modules/ldap_departments + specs: + openproject-ldap_departments (1.0.0) + PATH remote: modules/ldap_groups specs: @@ -1683,6 +1688,7 @@ DEPENDENCIES openproject-github_integration! openproject-gitlab_integration! openproject-job_status! + openproject-ldap_departments! openproject-ldap_groups! openproject-meeting! openproject-octicons (~> 19.35.0)! @@ -1849,7 +1855,6 @@ CHECKSUMS browser (6.2.0) sha256=281d5295788825c9396427c292c2d2be0a5c91875c93c390fde6e5d61a5ace2d budgets (1.0.0) builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f - bundler (4.0.14) sha256=d09a0a965cf772266a7e49e83610be7c2f4e49e61134c42a56804bb383cc24b8 byebug (13.0.0) sha256=d2263efe751941ca520fa29744b71972d39cbc41839496706f5d9b22e92ae05d capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef capybara-screenshot (1.0.27) sha256=afa1896cc23df77be1774e8d3b3ce3953bf060aeaa04ff87607b5daf689174f2 @@ -2065,6 +2070,7 @@ CHECKSUMS openproject-github_integration (1.0.0) openproject-gitlab_integration (3.0.0) openproject-job_status (1.0.0) + openproject-ldap_departments (1.0.0) openproject-ldap_groups (1.0.0) openproject-meeting (1.0.0) openproject-octicons (19.35.0) sha256=a5033550d0961b4a8cb0993512a899716d633e17c2b5147bc6a9ed74f3952b38 diff --git a/Gemfile.modules b/Gemfile.modules index 985db5b08824..2423e3469f60 100644 --- a/Gemfile.modules +++ b/Gemfile.modules @@ -27,6 +27,7 @@ group :opf_plugins do gem 'openproject-github_integration', path: 'modules/github_integration' gem 'openproject-gitlab_integration', path: 'modules/gitlab_integration' gem 'openproject-ldap_groups', path: 'modules/ldap_groups' + gem 'openproject-ldap_departments', path: 'modules/ldap_departments' gem 'openproject-recaptcha', path: 'modules/recaptcha' gem 'openproject-job_status', path: 'modules/job_status' diff --git a/app/components/admin/departments/change_parent_dialog_component.rb b/app/components/admin/departments/change_parent_dialog_component.rb index c9adbc875f5c..655c0378eec5 100644 --- a/app/components/admin/departments/change_parent_dialog_component.rb +++ b/app/components/admin/departments/change_parent_dialog_component.rb @@ -92,8 +92,12 @@ def item_attributes(dept) end def disabled_ids + # Disallow the department itself, its current parent and descendants (circular), as well as + # any department managed by LDAP (the sync owns its sub-tree). @disabled_ids ||= Set.new( - [@department.id, @department.parent_id].compact + descendant_ids(@department.id) + [@department.id, @department.parent_id].compact + + descendant_ids(@department.id) + + @departments.select(&:ldap_managed?).map(&:id) ) end diff --git a/app/components/admin/departments/department_row_component.rb b/app/components/admin/departments/department_row_component.rb index 40559961be56..0bafd0ab65ab 100644 --- a/app/components/admin/departments/department_row_component.rb +++ b/app/components/admin/departments/department_row_component.rb @@ -46,13 +46,17 @@ def call end row.with_column do - render(Primer::Alpha::ActionMenu.new) do |menu| - menu.with_show_button( - icon: "kebab-horizontal", - scheme: :invisible, - "aria-label": I18n.t(:label_actions) - ) - menu_items(menu) + if @department.ldap_managed? + render(Primer::Beta::Label.new(scheme: :accent)) { I18n.t(:label_managed_by_ldap) } + else + render(Primer::Alpha::ActionMenu.new) do |menu| + menu.with_show_button( + icon: "kebab-horizontal", + scheme: :invisible, + "aria-label": I18n.t(:label_actions) + ) + menu_items(menu) + end end end end diff --git a/app/components/admin/departments/detail_blankslate_component.rb b/app/components/admin/departments/detail_blankslate_component.rb index d0512272a44f..3b14bdb34229 100644 --- a/app/components/admin/departments/detail_blankslate_component.rb +++ b/app/components/admin/departments/detail_blankslate_component.rb @@ -33,13 +33,30 @@ module Departments class DetailBlankslateComponent < ApplicationComponent include OpPrimer::ComponentHelpers + def initialize(group: nil) + super() + @group = group + end + def call render(Primer::Beta::Blankslate.new(border: false)) do |component| - component.with_visual_icon(icon: :people, size: :medium) - component.with_heading(tag: :h2) { t("departments.detail_blankslate.heading") } - component.with_description { t("departments.detail_blankslate.description") } + if managed? + component.with_visual_icon(icon: :lock, size: :medium) + component.with_heading(tag: :h2) { t("departments.detail_blankslate.managed_heading") } + component.with_description { t("departments.detail_blankslate.managed_description") } + else + component.with_visual_icon(icon: :people, size: :medium) + component.with_heading(tag: :h2) { t("departments.detail_blankslate.heading") } + component.with_description { t("departments.detail_blankslate.description") } + end end end + + private + + def managed? + @group&.ldap_managed? + end end end end diff --git a/app/components/admin/departments/detail_component.html.erb b/app/components/admin/departments/detail_component.html.erb index ed24540b0a10..07fd1c4ac2f9 100644 --- a/app/components/admin/departments/detail_component.html.erb +++ b/app/components/admin/departments/detail_component.html.erb @@ -40,7 +40,11 @@ See COPYRIGHT and LICENSE files for more details. end end - if group + if group&.ldap_managed? + header.with_column do + render(Primer::Beta::Label.new(scheme: :accent)) { I18n.t(:label_managed_by_ldap) } + end + elsif group header.with_column do render( Primer::Beta::IconButton.new( @@ -62,7 +66,7 @@ See COPYRIGHT and LICENSE files for more details. end elsif show_department_empty_state? && !add_user? && !add_subgroup? box.with_row do - render(Admin::Departments::DetailBlankslateComponent.new) + render(Admin::Departments::DetailBlankslateComponent.new(group:)) end else child_groups.each do |child| diff --git a/app/components/admin/departments/hierarchy_layout_component.html.erb b/app/components/admin/departments/hierarchy_layout_component.html.erb index f58c86944845..4d286d272077 100644 --- a/app/components/admin/departments/hierarchy_layout_component.html.erb +++ b/app/components/admin/departments/hierarchy_layout_component.html.erb @@ -27,30 +27,31 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<%# helpers.content_controller "admin--departments-page" %> - <%= component_wrapper(tag: "turbo-frame", class: "admin-groups-tree-page--wrapper", refresh: :morph, data: { turbo_action: :advance }) do render(Primer::Alpha::Layout.new(stacking_breakpoint: :md, overflow: :hidden, h: :full, classes: "admin-groups-tree-page")) do |content| content.with_main(overflow: :auto) do flex_layout do |main| - main.with_row do - render(Primer::OpenProject::SubHeader.new) do |subheader| - subheader.with_action_menu(leading_icon: :plus, trailing_icon: :"triangle-down", label: I18n.t(:button_add), button_arguments: { scheme: :primary, "aria-label": I18n.t(:button_add) }) do |menu| - if active_group + # Managed departments are owned by the LDAP sync: no manual add of members or sub-departments. + unless active_group&.ldap_managed? + main.with_row do + render(Primer::OpenProject::SubHeader.new) do |subheader| + subheader.with_action_menu(leading_icon: :plus, trailing_icon: :"triangle-down", label: I18n.t(:button_add), button_arguments: { scheme: :primary, "aria-label": I18n.t(:button_add) }) do |menu| + if active_group + menu.with_item( + label: I18n.t("departments.add_user"), + tag: :a, + href: new_user_admin_department_path(active_group), + content_arguments: { data: { turbo_frame: Admin::Departments::DetailComponent.wrapper_key } } + ) + end menu.with_item( - label: I18n.t("departments.add_user"), + label: I18n.t("departments.add_department"), tag: :a, - href: new_user_admin_department_path(active_group), + href: new_department_admin_departments_path(parent_id: active_group&.id), content_arguments: { data: { turbo_frame: Admin::Departments::DetailComponent.wrapper_key } } ) end - menu.with_item( - label: I18n.t("departments.add_department"), - tag: :a, - href: new_department_admin_departments_path(parent_id: active_group&.id), - content_arguments: { data: { turbo_frame: Admin::Departments::DetailComponent.wrapper_key } } - ) end end end diff --git a/app/components/admin/departments/move_user_dialog_component.html.erb b/app/components/admin/departments/move_user_dialog_component.html.erb index 88cbef2812ae..50fead62d7f6 100644 --- a/app/components/admin/departments/move_user_dialog_component.html.erb +++ b/app/components/admin/departments/move_user_dialog_component.html.erb @@ -27,33 +27,55 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<%= - render( - Primer::OpenProject::DangerDialog.new( - id: DIALOG_ID, - title: t("departments.move_user_dialog.title"), - confirm_button_text: t("departments.move_user_dialog.confirm"), - size: :medium_portrait, - form_arguments: { - action: add_user_admin_department_path(to_department), - method: :post - } - ) - ) do |dialog| - dialog.with_confirmation_message do |message| - message.with_heading(tag: :h2) { t("departments.move_user_dialog.heading") } - message.with_description do - t( - "departments.move_user_dialog.description", - user: moved_user.name, - from_department: from_department.name - ) +<% if from_department.ldap_managed? %> + <%= + render( + Primer::OpenProject::FeedbackDialog.new( + id: DIALOG_ID, + title: t("departments.move_user_dialog.managed_title") + ) + ) do |dialog| + dialog.with_feedback_message(icon_arguments: { icon: :lock, color: :muted }) do |message| + message.with_heading(tag: :h2) { t("departments.move_user_dialog.managed_heading") } + message.with_description do + t( + "departments.move_user_dialog.managed_description", + user: moved_user.name, + from_department: from_department.name + ) + end end end + %> +<% else %> + <%= + render( + Primer::OpenProject::DangerDialog.new( + id: DIALOG_ID, + title: t("departments.move_user_dialog.title"), + confirm_button_text: t("departments.move_user_dialog.confirm"), + size: :medium_portrait, + form_arguments: { + action: add_user_admin_department_path(to_department), + method: :post + } + ) + ) do |dialog| + dialog.with_confirmation_message do |message| + message.with_heading(tag: :h2) { t("departments.move_user_dialog.heading") } + message.with_description do + t( + "departments.move_user_dialog.description", + user: moved_user.name, + from_department: from_department.name + ) + end + end - dialog.with_additional_details do - concat(hidden_field_tag(:user_id, moved_user.id)) - concat(hidden_field_tag(:remove_from_previous_department, "true")) + dialog.with_additional_details do + concat(hidden_field_tag(:user_id, moved_user.id)) + concat(hidden_field_tag(:remove_from_previous_department, "true")) + end end - end -%> + %> +<% end %> diff --git a/app/components/admin/departments/organization_name_component.html.erb b/app/components/admin/departments/organization_name_component.html.erb index c677b49a6b17..8860c7022285 100644 --- a/app/components/admin/departments/organization_name_component.html.erb +++ b/app/components/admin/departments/organization_name_component.html.erb @@ -31,7 +31,9 @@ See COPYRIGHT and LICENSE files for more details. component_wrapper do flex_layout(align_items: :center, justify_content: :space_between) do |container| container.with_column do - render(Primer::Beta::Heading.new(tag: :h4)) { organization_name } + render(Primer::Beta::Link.new(href: admin_departments_path, underline: false, color: :default)) do + render(Primer::Beta::Heading.new(tag: :h4)) { organization_name } + end end container.with_column do diff --git a/app/components/admin/departments/page_header_component.html.erb b/app/components/admin/departments/page_header_component.html.erb index 0b7596dc7c76..8b0f610fa5b8 100644 --- a/app/components/admin/departments/page_header_component.html.erb +++ b/app/components/admin/departments/page_header_component.html.erb @@ -32,7 +32,7 @@ See COPYRIGHT and LICENSE files for more details. <%= render(Primer::OpenProject::PageHeader.new) do |header| header.with_title { t(:label_departments) } header.with_description do - link_translate(:label_departments_description_html, links: { ldap_docs_article: "#" }) + link_translate(:label_departments_description_html, links: { ldap_docs_article: ldap_departments_synchronized_trees_path }) end header.with_breadcrumbs( [ diff --git a/app/components/admin/departments/user_row_component.rb b/app/components/admin/departments/user_row_component.rb index c0b9a3c6f2bd..61952a1e6b6a 100644 --- a/app/components/admin/departments/user_row_component.rb +++ b/app/components/admin/departments/user_row_component.rb @@ -46,26 +46,28 @@ def call render(Users::AvatarComponent.new(user: @user, size: "mini")) end - row.with_column do - render(Primer::Alpha::ActionMenu.new) do |menu| - menu.with_show_button( - icon: "kebab-horizontal", - scheme: :invisible, - "aria-label": I18n.t(:label_actions) - ) - menu.with_item( - label: I18n.t(:button_remove), - scheme: :danger, - tag: :a, - href: remove_user_admin_department_path(@group, @user.id), - content_arguments: { - data: { - turbo_confirm: I18n.t(:text_are_you_sure), - turbo_method: :delete, - turbo_frame: "_top" + unless @group&.ldap_managed? + row.with_column do + render(Primer::Alpha::ActionMenu.new) do |menu| + menu.with_show_button( + icon: "kebab-horizontal", + scheme: :invisible, + "aria-label": I18n.t(:label_actions) + ) + menu.with_item( + label: I18n.t(:button_remove), + scheme: :danger, + tag: :a, + href: remove_user_admin_department_path(@group, @user.id), + content_arguments: { + data: { + turbo_confirm: I18n.t(:text_are_you_sure), + turbo_method: :delete, + turbo_frame: "_top" + } } - } - ) + ) + end end end end diff --git a/app/contracts/groups/base_contract.rb b/app/contracts/groups/base_contract.rb index c54b69527a1c..ddc71758e2b5 100644 --- a/app/contracts/groups/base_contract.rb +++ b/app/contracts/groups/base_contract.rb @@ -41,9 +41,26 @@ class BaseContract < ::ModelContract validate :validate_unique_users validate :validate_users_not_in_other_department + validate :validate_not_ldap_managed + validate :validate_parent_not_ldap_managed private + # Departments synchronized from LDAP are read-only: their name, parent and members are owned by + # the sync. The sync itself bypasses this through Groups::SyncUpdateContract. + def validate_not_ldap_managed + errors.add(:base, :ldap_managed) if model.ldap_managed? + end + + # A department managed by LDAP owns its sub-tree, so new or moved departments may not be nested + # underneath one. The sync bypasses this through its permissive contracts. + def validate_parent_not_ldap_managed + return if model.parent_id.blank? + + parent = Group.find_by(id: model.parent_id) + errors.add(:parent_id, :parent_ldap_managed) if parent&.ldap_managed? + end + # Validating on the group_users since those are dealt with in the # corresponding services. def validate_unique_users diff --git a/app/contracts/groups/delete_contract.rb b/app/contracts/groups/delete_contract.rb index 75f55b83cb23..6c76cb9afbde 100644 --- a/app/contracts/groups/delete_contract.rb +++ b/app/contracts/groups/delete_contract.rb @@ -31,5 +31,15 @@ module Groups class DeleteContract < ::DeleteContract delete_permission(:admin) + + validate :validate_not_ldap_managed + + private + + # Departments synchronized from LDAP cannot be deleted manually; they are removed only once + # their organizational unit disappears from LDAP and the sync drops the mapping. + def validate_not_ldap_managed + errors.add(:base, :ldap_managed) if model.ldap_managed? + end end end diff --git a/app/contracts/groups/sync_create_contract.rb b/app/contracts/groups/sync_create_contract.rb new file mode 100644 index 000000000000..9b02f3207a3f --- /dev/null +++ b/app/contracts/groups/sync_create_contract.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Groups + # Used by the LDAP department synchronization to create managed departments, including nesting + # them under other managed departments. It therefore skips the read-only / managed-parent guards + # that block interactive admins in Groups::BaseContract. + class SyncCreateContract < CreateContract + private + + def validate_not_ldap_managed; end + + def validate_parent_not_ldap_managed; end + end +end diff --git a/app/contracts/groups/sync_update_contract.rb b/app/contracts/groups/sync_update_contract.rb new file mode 100644 index 000000000000..4b142483f086 --- /dev/null +++ b/app/contracts/groups/sync_update_contract.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Groups + # Used by the LDAP department synchronization to update managed departments (name, parent and + # members). It is the only path allowed to mutate a group that reports `ldap_managed?`, so it + # skips the read-only lock enforced for interactive admins by Groups::BaseContract. + class SyncUpdateContract < UpdateContract + private + + def validate_not_ldap_managed; end + + def validate_parent_not_ldap_managed; end + end +end diff --git a/app/forms/groups/form.rb b/app/forms/groups/form.rb index 652baaabeb25..8d356da54364 100644 --- a/app/forms/groups/form.rb +++ b/app/forms/groups/form.rb @@ -49,7 +49,11 @@ class Form < ApplicationForm ) do |list| parent_candidates.each do |group| prefix = "\u00A0\u00A0" * (group.hierarchy_depth || 0) - list.option(label: "#{prefix}#{group.name}", value: group.id, selected: model.parent_id == group.id) + # Departments managed by LDAP own their sub-tree and cannot be chosen as a manual parent. + list.option(label: "#{prefix}#{group.name}", + value: group.id, + selected: model.parent_id == group.id, + disabled: group.ldap_managed?) end end diff --git a/app/models/group.rb b/app/models/group.rb index 91ac56e2f6c5..ac33c07df6f4 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -56,6 +56,20 @@ def self.synchronized_group_partials @synchronized_group_partials ||= [] end + # Register a predicate that determines whether a group is managed by an external synchronization + # (e.g. the LDAP department sync). Modules register their own check so the core stays agnostic of + # them; when no module is loaded a group is never considered managed. + # + # @yieldparam group[Group] the group to check + # @yieldreturn [Boolean] whether the group is managed by that source + def self.register_ldap_managed_check(&block) + ldap_managed_checks.push(block) + end + + def self.ldap_managed_checks + @ldap_managed_checks ||= [] + end + has_many :group_users, autosave: true, dependent: :destroy @@ -89,6 +103,12 @@ def self.synchronized_group_partials scopes :visible, :containing_user, :organizational_units + # Whether this group is managed by an external synchronization (e.g. LDAP department sync). + # Managed departments are read-only in the admin UI and may only be changed by the sync itself. + def ldap_managed? + self.class.ldap_managed_checks.any? { |check| check.call(self) } + end + # Columns required for formatting the group's name. def self.columns_for_name(_formatter = nil) [:lastname] @@ -156,10 +176,18 @@ def self.scim_queryable_attributes private def uniqueness_of_name - groups_with_name = Group.where("lastname = ? AND id <> ?", name, id || 0).count - if groups_with_name > 0 - errors.add :name, :taken - end + scope = Group.where(lastname: name).where.not(id: id || 0) + + # Regular groups must be globally unique. Organizational units (departments) only need to be + # unique among their siblings: LDAP directories routinely repeat the same OU name on different + # branches (e.g. OU=Support under both IT and HR), so we scope uniqueness to the parent. + scope = if organizational_unit? + scope.where_detail(organizational_unit: true, parent_id:) + else + scope.where_detail(organizational_unit: false) + end + + errors.add(:name, :taken) if scope.exists? end def fail_add diff --git a/app/services/departments/add_user_service.rb b/app/services/departments/add_user_service.rb index 2e4cc76ad7f0..924791ddc03e 100644 --- a/app/services/departments/add_user_service.rb +++ b/app/services/departments/add_user_service.rb @@ -51,7 +51,10 @@ def persist(call) end def handle_existing_membership(existing_department, user_id, call) - if params[:remove_from_previous_department] + if existing_department.ldap_managed? + # The user is locked into an LDAP-managed department and cannot be moved out manually. + reject_move_from_managed(existing_department, call) + elsif params[:remove_from_previous_department] move_user(from: existing_department, to: model, user_id:, call:) else call.success = false @@ -59,6 +62,12 @@ def handle_existing_membership(existing_department, user_id, call) end end + def reject_move_from_managed(existing_department, call) + call.success = false + call.result = existing_department + call.errors.add(:base, :user_in_ldap_managed_department) + end + def find_existing_department(user_id) GroupUser .joins(:group) diff --git a/config/constants/settings/definition.rb b/config/constants/settings/definition.rb index c0e5eeaa2871..7c0bae17be0a 100644 --- a/config/constants/settings/definition.rb +++ b/config/constants/settings/definition.rb @@ -709,6 +709,10 @@ class Definition description: "Deactivate regular synchronization job for groups in case scheduled as a separate cronjob", default: false }, + ldap_departments_disable_sync_job: { + description: "Deactivate regular synchronization job for departments in case scheduled as a separate cronjob", + default: false + }, ldap_users_disable_sync_job: { description: "Deactivate user attributes synchronization from LDAP", default: false diff --git a/config/locales/en.yml b/config/locales/en.yml index aea3d9e537ff..8eab1b2a9397 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -787,9 +787,11 @@ en: a hierarchy below it, to navigate and create sub-department inside a hierarchy click on the created item. add_button: "Add" detail_blankslate: - heading: "This department doesn’t have any hierarchy level below" - description: "Add departments or users to create sub-items inside another one." add_button: "Add" + description: "Add departments or users to create sub-items inside another one." + heading: "This department doesn’t have any hierarchy level below" + managed_description: "Its sub-departments and members are kept in sync with LDAP and cannot be added manually." + managed_heading: "This department is managed by LDAP synchronization" add_department_form: name_label: "Department name" name_placeholder: "Enter department name" @@ -798,6 +800,9 @@ en: heading: "Move user to this department?" description: "%{user} is currently a member of %{from_department}. Moving them will remove them from that department." confirm: "Move user" + managed_title: "User managed by LDAP" + managed_heading: "This user cannot be moved" + managed_description: "%{user} belongs to %{from_department}, which is managed by LDAP synchronization. Their department membership is controlled by LDAP and cannot be changed here." context_menu: add_sub_department: "Add sub-department" add_user: "Add user" @@ -2233,6 +2238,7 @@ en: invalid_url: "is not a valid URL." invalid_url_scheme: "is not a supported protocol (allowed: %{allowed_schemes})." is_for_all_cannot_modify: "is for all projects and can therefore not be modified." + ldap_managed: "This department is managed by LDAP synchronization and cannot be changed manually." less_than_or_equal_to: "must be less than or equal to %{count}." not_available: "is not available due to a system configuration." not_before_start_date: "must not be before the start date." @@ -2269,6 +2275,7 @@ en: url_not_secure_context: > is not providing a "Secure Context". Either use HTTPS or a loopback address, such as localhost. user_already_in_department: "User %{user_id} is already a member of department %{department_id}." + user_in_ldap_managed_department: "This user belongs to a department managed by LDAP synchronization and cannot be moved." wrong_length: "is the wrong length (should be %{count} characters)." models: group: @@ -2276,6 +2283,7 @@ en: parent_id: circular_dependency: "would create a circular group hierarchy." organizational_unit_mismatch: "must have the same organizational unit setting as the group." + parent_ldap_managed: "is managed by LDAP synchronization and cannot have departments added manually." ldap_auth_source: attributes: tls_certificate_string: @@ -4287,12 +4295,13 @@ en: label_departments_description_html: > Define your company’s structure by creating departments and sub-departments in a hierarchical way. This allows you to reflect reporting lines and maintain a clear, structured overview of your organization within OpenProject. You - can also import an existing organization structure through [LDAP group synchronisation](ldap_docs_article). + can also import an existing organization structure through [LDAP department synchronization](ldap_docs_article). label_logout: "Sign out" label_mapping_for: "Mapping for: %{attribute}" label_main_menu: "Side Menu" label_manage: "Manage" label_manage_groups: "Manage groups" + label_managed_by_ldap: "Managed by LDAP synchronization" label_managed_repositories_vendor: "Managed %{vendor} repositories" label_mathematical_operators: "Mathematical operators" label_max_size: "Maximum size" diff --git a/db/migrate/20260622144833_relax_group_lastname_uniqueness.rb b/db/migrate/20260622144833_relax_group_lastname_uniqueness.rb new file mode 100644 index 000000000000..ce16bc134e27 --- /dev/null +++ b/db/migrate/20260622144833_relax_group_lastname_uniqueness.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class RelaxGroupLastnameUniqueness < ActiveRecord::Migration[8.1] + # Group name uniqueness is now enforced in the application (Group#uniqueness_of_name): + # globally for regular groups, but only among siblings for organizational units (departments), + # since LDAP directories repeat the same OU name across branches. The database-level unique + # index can therefore no longer span all groups, so we restrict it to placeholder users. + # + # Indexes on the (potentially large) users table are built/removed concurrently to avoid locking. + disable_ddl_transaction! + + def up + remove_index :users, name: "unique_lastname_for_groups_and_placeholder_users", algorithm: :concurrently + add_index :users, + %i[lastname type], + name: "unique_lastname_for_placeholder_users", + unique: true, + where: "(type = 'PlaceholderUser')", + algorithm: :concurrently + end + + def down + remove_index :users, name: "unique_lastname_for_placeholder_users", algorithm: :concurrently + add_index :users, + %i[lastname type], + name: "unique_lastname_for_groups_and_placeholder_users", + unique: true, + where: "(type = 'Group' OR type = 'PlaceholderUser')", + algorithm: :concurrently + end +end diff --git a/db/migrate/20260623113435_nullify_group_details_parent_on_delete.rb b/db/migrate/20260623113435_nullify_group_details_parent_on_delete.rb new file mode 100644 index 000000000000..3bbabe53e8ce --- /dev/null +++ b/db/migrate/20260623113435_nullify_group_details_parent_on_delete.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class NullifyGroupDetailsParentOnDelete < ActiveRecord::Migration[8.1] + # Deleting a department that is the parent of another one used to fail because the + # group_details.parent_id foreign key restricted it. Nullify children's parent instead, so the + # children become top-level departments. + def up + remove_foreign_key :group_details, column: :parent_id + add_foreign_key :group_details, :users, column: :parent_id, on_delete: :nullify + end + + def down + remove_foreign_key :group_details, column: :parent_id + add_foreign_key :group_details, :users, column: :parent_id + end +end diff --git a/modules/ldap_departments/Gemfile b/modules/ldap_departments/Gemfile new file mode 100644 index 000000000000..1e5f620b5b83 --- /dev/null +++ b/modules/ldap_departments/Gemfile @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +source "https://rubygems.org", cooldown: 7 + +gemspec diff --git a/modules/ldap_departments/app/components/ldap_departments/synchronized_departments/delete_dialog_component.html.erb b/modules/ldap_departments/app/components/ldap_departments/synchronized_departments/delete_dialog_component.html.erb new file mode 100644 index 000000000000..35d5339a1014 --- /dev/null +++ b/modules/ldap_departments/app/components/ldap_departments/synchronized_departments/delete_dialog_component.html.erb @@ -0,0 +1,10 @@ +<%= + render(Primer::OpenProject::DangerDialog.new(title:, form_arguments:)) do |dialog| + dialog.with_confirmation_message do |message| + message.with_heading(tag: :h2) { heading } + message.with_description do + safe_join([content_tag(:p, confirmation_text), content_tag(:p, info_text)]) + end + end + end +%> diff --git a/modules/ldap_departments/app/components/ldap_departments/synchronized_departments/delete_dialog_component.rb b/modules/ldap_departments/app/components/ldap_departments/synchronized_departments/delete_dialog_component.rb new file mode 100644 index 000000000000..b81556a00c68 --- /dev/null +++ b/modules/ldap_departments/app/components/ldap_departments/synchronized_departments/delete_dialog_component.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module LdapDepartments + module SynchronizedDepartments + class DeleteDialogComponent < ApplicationComponent + include OpTurbo::Streamable + + def initialize(department:) + super() + @department = department + end + + private + + attr_reader :department + + def name + department.group&.name + end + + def form_arguments + { + action: ldap_departments_synchronized_department_path(department_id: department.id), + method: :delete + } + end + + def title + I18n.t("ldap_departments.synchronized_departments.destroy.title", name:) + end + + def heading + I18n.t("ldap_departments.synchronized_departments.destroy.heading", name:) + end + + def confirmation_text + I18n.t("ldap_departments.synchronized_departments.destroy.confirmation", name:) + end + + def info_text + I18n.t("ldap_departments.synchronized_departments.destroy.info") + end + end + end +end diff --git a/modules/ldap_departments/app/components/ldap_departments/synchronized_departments/row_component.rb b/modules/ldap_departments/app/components/ldap_departments/synchronized_departments/row_component.rb new file mode 100644 index 000000000000..020e1fda6f93 --- /dev/null +++ b/modules/ldap_departments/app/components/ldap_departments/synchronized_departments/row_component.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module LdapDepartments + module SynchronizedDepartments + class RowComponent < OpPrimer::BorderBoxRowComponent + def group + return if model.group.nil? + + render(Primer::Beta::Link.new(href: admin_department_path(model.group), font_weight: :bold)) do + table.path_for(model.group) + end + end + + delegate :dn, to: :model + + def users + model.users_count + end + + def button_links + [actions_menu] + end + + private + + def actions_menu + render(Primer::Alpha::ActionMenu.new) do |menu| + menu.with_show_button(icon: "kebab-horizontal", scheme: :invisible, "aria-label": I18n.t(:label_actions)) + + menu.with_item( + label: I18n.t(:button_delete), + scheme: :danger, + tag: :a, + href: deletion_dialog_ldap_departments_synchronized_department_path(department_id: model.id), + content_arguments: { data: { controller: "async-dialog" } } + ) { it.with_leading_visual_icon(icon: :trash) } + end + end + end + end +end diff --git a/modules/ldap_departments/app/components/ldap_departments/synchronized_departments/table_component.rb b/modules/ldap_departments/app/components/ldap_departments/synchronized_departments/table_component.rb new file mode 100644 index 000000000000..e1ac21b0de96 --- /dev/null +++ b/modules/ldap_departments/app/components/ldap_departments/synchronized_departments/table_component.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module LdapDepartments + module SynchronizedDepartments + class TableComponent < OpPrimer::BorderBoxTableComponent + columns :group, :dn, :users + main_column :group + mobile_columns :group + mobile_labels :dn, :users + + def mobile_title + I18n.t("ldap_departments.synchronized_departments.plural") + end + + def row_class + RowComponent + end + + def has_actions? + true + end + + def headers + [ + [:group, { caption: SynchronizedDepartment.human_attribute_name(:group) }], + [:dn, { caption: SynchronizedDepartment.human_attribute_name(:dn) }], + [:users, { caption: SynchronizedDepartment.human_attribute_name(:users_count) }] + ] + end + + def blank_title + I18n.t("ldap_departments.synchronized_departments.blankslate.heading") + end + + def blank_description + I18n.t("ldap_departments.synchronized_departments.blankslate.description") + end + + def blank_icon + :organization + end + + # The full department path (e.g. "Human Resources / Support"), built in memory from the + # tree's departments to avoid a hierarchy query per row. + def path_for(group) + names = [] + current = group + while current + names.unshift(current.name) + current = groups_by_id[current.parent_id] + end + names.join(" / ") + end + + private + + def groups_by_id + @groups_by_id ||= rows.filter_map(&:group).index_by(&:id) + end + end + end +end diff --git a/modules/ldap_departments/app/components/ldap_departments/synchronized_trees/delete_dialog_component.html.erb b/modules/ldap_departments/app/components/ldap_departments/synchronized_trees/delete_dialog_component.html.erb new file mode 100644 index 000000000000..35d5339a1014 --- /dev/null +++ b/modules/ldap_departments/app/components/ldap_departments/synchronized_trees/delete_dialog_component.html.erb @@ -0,0 +1,10 @@ +<%= + render(Primer::OpenProject::DangerDialog.new(title:, form_arguments:)) do |dialog| + dialog.with_confirmation_message do |message| + message.with_heading(tag: :h2) { heading } + message.with_description do + safe_join([content_tag(:p, confirmation_text), content_tag(:p, info_text)]) + end + end + end +%> diff --git a/modules/ldap_departments/app/components/ldap_departments/synchronized_trees/delete_dialog_component.rb b/modules/ldap_departments/app/components/ldap_departments/synchronized_trees/delete_dialog_component.rb new file mode 100644 index 000000000000..eb4d88d6fa6f --- /dev/null +++ b/modules/ldap_departments/app/components/ldap_departments/synchronized_trees/delete_dialog_component.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module LdapDepartments + module SynchronizedTrees + class DeleteDialogComponent < ApplicationComponent + include OpTurbo::Streamable + + def initialize(tree:) + super() + @tree = tree + end + + private + + attr_reader :tree + + def form_arguments + { + action: ldap_departments_synchronized_tree_path(tree_id: tree.id), + method: :delete + } + end + + def title + I18n.t("ldap_departments.synchronized_trees.destroy.title", name: tree.name) + end + + def heading + I18n.t("ldap_departments.synchronized_trees.destroy.heading", name: tree.name) + end + + def confirmation_text + I18n.t("ldap_departments.synchronized_trees.destroy.confirmation", + name: tree.name, + departments_count: tree.synchronized_departments.size) + end + + def info_text + I18n.t("ldap_departments.synchronized_trees.destroy.info") + end + end + end +end diff --git a/modules/ldap_departments/app/components/ldap_departments/synchronized_trees/form_component.html.erb b/modules/ldap_departments/app/components/ldap_departments/synchronized_trees/form_component.html.erb new file mode 100644 index 000000000000..80f26f58ba14 --- /dev/null +++ b/modules/ldap_departments/app/components/ldap_departments/synchronized_trees/form_component.html.erb @@ -0,0 +1,5 @@ +<%= + settings_primer_form_with(**form_options) do |f| + render(LdapDepartments::SynchronizedTrees::Form.new(f)) + end +%> diff --git a/modules/ldap_departments/app/components/ldap_departments/synchronized_trees/form_component.rb b/modules/ldap_departments/app/components/ldap_departments/synchronized_trees/form_component.rb new file mode 100644 index 000000000000..ad661f0e820a --- /dev/null +++ b/modules/ldap_departments/app/components/ldap_departments/synchronized_trees/form_component.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module LdapDepartments + module SynchronizedTrees + class FormComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + private + + def form_options + form_target.merge(model:, scope: :synchronized_tree) + end + + def form_target + if model.new_record? + { method: :post, url: ldap_departments_synchronized_trees_path } + else + { method: :patch, url: ldap_departments_synchronized_tree_path(tree_id: model.id) } + end + end + end + end +end diff --git a/modules/ldap_departments/app/components/ldap_departments/synchronized_trees/row_component.rb b/modules/ldap_departments/app/components/ldap_departments/synchronized_trees/row_component.rb new file mode 100644 index 000000000000..8c9d82cd3654 --- /dev/null +++ b/modules/ldap_departments/app/components/ldap_departments/synchronized_trees/row_component.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module LdapDepartments + module SynchronizedTrees + class RowComponent < OpPrimer::BorderBoxRowComponent + def name + render(Primer::Beta::Link.new( + href: ldap_departments_synchronized_tree_path(tree_id: model.id), + font_weight: :bold + )) { model.name } + end + + def ldap_auth_source + model.ldap_auth_source&.name + end + + delegate :base_dn, to: :model + + def departments + model.synchronized_departments.size + end + + def button_links + [actions_menu] + end + + private + + def actions_menu + render(Primer::Alpha::ActionMenu.new) do |menu| + menu.with_show_button(icon: "kebab-horizontal", scheme: :invisible, "aria-label": I18n.t(:label_actions)) + add_edit_item(menu) + add_delete_item(menu) + end + end + + def add_edit_item(menu) + menu.with_item( + label: I18n.t(:button_edit), + tag: :a, + href: edit_ldap_departments_synchronized_tree_path(tree_id: model.id) + ) { it.with_leading_visual_icon(icon: :pencil) } + end + + def add_delete_item(menu) + menu.with_item( + label: I18n.t(:button_delete), + scheme: :danger, + tag: :a, + href: deletion_dialog_ldap_departments_synchronized_tree_path(tree_id: model.id), + content_arguments: { data: { controller: "async-dialog" } } + ) { it.with_leading_visual_icon(icon: :trash) } + end + end + end +end diff --git a/modules/ldap_departments/app/components/ldap_departments/synchronized_trees/side_panel_component.html.erb b/modules/ldap_departments/app/components/ldap_departments/synchronized_trees/side_panel_component.html.erb new file mode 100644 index 000000000000..dded3d64bd3d --- /dev/null +++ b/modules/ldap_departments/app/components/ldap_departments/synchronized_trees/side_panel_component.html.erb @@ -0,0 +1,14 @@ +<%= + render(Primer::OpenProject::SidePanel.new(spacious: true)) do |panel| + panel.with_section do |section| + section.with_title { t("ldap_departments.synchronized_trees.singular") } + + flex_layout do |details| + attributes.each_with_index do |(label, value), index| + details.with_row(font_weight: :bold, mt: index.zero? ? 0 : 2) { label } + details.with_row { value } + end + end + end + end +%> diff --git a/modules/ldap_departments/app/components/ldap_departments/synchronized_trees/side_panel_component.rb b/modules/ldap_departments/app/components/ldap_departments/synchronized_trees/side_panel_component.rb new file mode 100644 index 000000000000..3a0a70ee1885 --- /dev/null +++ b/modules/ldap_departments/app/components/ldap_departments/synchronized_trees/side_panel_component.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module LdapDepartments + module SynchronizedTrees + class SidePanelComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + def initialize(tree:) + super() + @tree = tree + end + + private + + attr_reader :tree + + # [label, value] pairs shown in the side panel; optional attributes are only listed when set. + def attributes + shown_keys.map { |key| [SynchronizedTree.human_attribute_name(key), value_for(key)] } + end + + def shown_keys + keys = %i[ldap_auth_source base_dn structure_filter_string ou_name_attribute] + keys << :guid_attribute if tree.guid_attribute.present? + keys << :user_filter_string if tree.user_filter_string.present? + keys << :sync_users + keys + end + + def value_for(key) + case key + when :ldap_auth_source then tree.ldap_auth_source&.name + when :sync_users then checkmark_text(tree.sync_users) + else tree.public_send(key) + end + end + + def checkmark_text(value) + value ? I18n.t(:general_text_Yes) : I18n.t(:general_text_No) + end + end + end +end diff --git a/modules/ldap_departments/app/components/ldap_departments/synchronized_trees/table_component.rb b/modules/ldap_departments/app/components/ldap_departments/synchronized_trees/table_component.rb new file mode 100644 index 000000000000..fc1fd60474c5 --- /dev/null +++ b/modules/ldap_departments/app/components/ldap_departments/synchronized_trees/table_component.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module LdapDepartments + module SynchronizedTrees + class TableComponent < OpPrimer::BorderBoxTableComponent + columns :name, :ldap_auth_source, :base_dn, :departments + main_column :name + mobile_columns :name + mobile_labels :ldap_auth_source, :base_dn, :departments + + def mobile_title + I18n.t("ldap_departments.synchronized_trees.plural") + end + + def row_class + RowComponent + end + + def has_actions? + true + end + + def headers + [ + [:name, { caption: SynchronizedTree.human_attribute_name(:name) }], + [:ldap_auth_source, { caption: SynchronizedTree.human_attribute_name(:ldap_auth_source) }], + [:base_dn, { caption: SynchronizedTree.human_attribute_name(:base_dn) }], + [:departments, { caption: I18n.t("ldap_departments.synchronized_departments.plural") }] + ] + end + + def blank_title + I18n.t("ldap_departments.synchronized_trees.blankslate.heading") + end + + def blank_description + I18n.t("ldap_departments.synchronized_trees.blankslate.description") + end + + def blank_icon + :organization + end + end + end +end diff --git a/modules/ldap_departments/app/controllers/ldap_departments/synchronized_departments_controller.rb b/modules/ldap_departments/app/controllers/ldap_departments/synchronized_departments_controller.rb new file mode 100644 index 000000000000..2747b2e2f991 --- /dev/null +++ b/modules/ldap_departments/app/controllers/ldap_departments/synchronized_departments_controller.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module LdapDepartments + class SynchronizedDepartmentsController < ::ApplicationController + include OpTurbo::ComponentStream + + before_action :require_admin + + guard_enterprise_feature(:ldap_groups, except: %i[deletion_dialog destroy]) do + redirect_to ldap_departments_synchronized_trees_path, status: :see_other + end + + before_action :find_department, only: %i[deletion_dialog destroy] + + layout "admin" + menu_item :plugin_ldap_departments + + def deletion_dialog + respond_with_dialog SynchronizedDepartments::DeleteDialogComponent.new(department: @department) + end + + # Removing the mapping unmanages the department (it and externally-added members are kept). + def destroy + tree_id = @department.synchronized_tree_id + + if @department.destroy + flash[:notice] = I18n.t(:notice_successful_delete) + else + flash[:error] = I18n.t(:error_can_not_delete_entry) + end + + redirect_to ldap_departments_synchronized_tree_path(tree_id:), status: :see_other + end + + private + + def find_department + @department = SynchronizedDepartment.find(params.expect(:department_id)) + end + end +end diff --git a/modules/ldap_departments/app/controllers/ldap_departments/synchronized_trees_controller.rb b/modules/ldap_departments/app/controllers/ldap_departments/synchronized_trees_controller.rb new file mode 100644 index 000000000000..6d4fce09410a --- /dev/null +++ b/modules/ldap_departments/app/controllers/ldap_departments/synchronized_trees_controller.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +module LdapDepartments + class SynchronizedTreesController < ::ApplicationController + include OpTurbo::ComponentStream + + before_action :require_admin + + guard_enterprise_feature(:ldap_groups, except: %i[index show deletion_dialog destroy]) do + redirect_to action: :index, status: :see_other + end + + before_action :find_tree, only: %i[show edit update destroy deletion_dialog synchronize] + + layout "admin" + menu_item :plugin_ldap_departments + + def index + @trees = SynchronizedTree.includes(:ldap_auth_source, :synchronized_departments) + end + + def show + @departments = @tree.synchronized_departments.includes(group: :group_detail) + end + + def new + @tree = SynchronizedTree.new + end + + def edit; end + + def create + @tree = SynchronizedTree.new(permitted_params) + + if @tree.save + SynchronizeTreeJob.perform_later(@tree) + flash[:notice] = I18n.t("ldap_departments.synchronized_trees.synchronization_started") + redirect_to action: :show, tree_id: @tree.id + else + render action: :new, status: :unprocessable_entity + end + rescue ActionController::ParameterMissing + render_400 + end + + def update + if @tree.update(permitted_params) + flash[:notice] = I18n.t(:notice_successful_update) + redirect_to action: :show + else + render action: :edit, status: :unprocessable_entity + end + rescue ActionController::ParameterMissing + render_400 + end + + def deletion_dialog + respond_with_dialog SynchronizedTrees::DeleteDialogComponent.new(tree: @tree) + end + + def destroy + if @tree.destroy + flash[:notice] = I18n.t(:notice_successful_delete) + else + flash[:error] = I18n.t(:error_can_not_delete_entry) + end + + redirect_to action: :index, status: :see_other + end + + def synchronize + SynchronizeTreeJob.perform_later(@tree) + flash[:notice] = I18n.t("ldap_departments.synchronized_trees.synchronization_started") + redirect_to action: :show + end + + private + + def find_tree + @tree = SynchronizedTree.find(params.expect(:tree_id)) + end + + def permitted_params + params.expect(synchronized_tree: %i[name + base_dn + ldap_auth_source_id + structure_filter_string + ou_name_attribute + guid_attribute + user_filter_string + sync_users]) + end + end +end diff --git a/modules/ldap_departments/app/forms/ldap_departments/synchronized_trees/form.rb b/modules/ldap_departments/app/forms/ldap_departments/synchronized_trees/form.rb new file mode 100644 index 000000000000..48117b86f875 --- /dev/null +++ b/modules/ldap_departments/app/forms/ldap_departments/synchronized_trees/form.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +module LdapDepartments + module SynchronizedTrees + class Form < ApplicationForm + form do |tree_form| + tree_form.text_field( + name: :name, + label: SynchronizedTree.human_attribute_name(:name), + required: true, + input_width: :large + ) + + tree_form.fieldset_group(title: I18n.t("ldap_departments.synchronized_trees.form.sections.connection"), mt: 1) do |group| + group.select_list( + name: :ldap_auth_source_id, + label: SynchronizedTree.human_attribute_name(:ldap_auth_source), + caption: I18n.t("ldap_departments.synchronized_trees.form.auth_source_text"), + required: true, + include_blank: true, + input_width: :large + ) do |list| + LdapAuthSource.order(:name).pluck(:name, :id).each do |name, id| + list.option(label: name, value: id) + end + end + + group.text_field( + name: :base_dn, + label: SynchronizedTree.human_attribute_name(:base_dn), + required: true, + caption: I18n.t("ldap_departments.synchronized_trees.form.base_dn_text"), + input_width: :large + ) + end + + tree_form.fieldset_group(title: I18n.t("ldap_departments.synchronized_trees.form.sections.structure"), mt: 1) do |group| + group.text_field( + name: :structure_filter_string, + label: SynchronizedTree.human_attribute_name(:structure_filter_string), + required: true, + caption: I18n.t("ldap_departments.synchronized_trees.form.structure_filter_string_text"), + input_width: :large + ) + + group.text_field( + name: :ou_name_attribute, + label: SynchronizedTree.human_attribute_name(:ou_name_attribute), + required: true, + caption: I18n.t("ldap_departments.synchronized_trees.form.ou_name_attribute_text"), + input_width: :medium + ) + + group.text_field( + name: :guid_attribute, + label: SynchronizedTree.human_attribute_name(:guid_attribute), + caption: I18n.t("ldap_departments.synchronized_trees.form.guid_attribute_text"), + input_width: :medium + ) + end + + tree_form.fieldset_group(title: I18n.t("ldap_departments.synchronized_trees.form.sections.users"), mt: 1) do |group| + group.text_field( + name: :user_filter_string, + label: SynchronizedTree.human_attribute_name(:user_filter_string), + caption: I18n.t("ldap_departments.synchronized_trees.form.user_filter_string_text"), + input_width: :large + ) + + group.check_box( + name: :sync_users, + label: SynchronizedTree.human_attribute_name(:sync_users), + caption: I18n.t("ldap_departments.synchronized_trees.form.sync_users_text") + ) + end + + tree_form.group(layout: :horizontal, mt: 3) do |buttons| + buttons.button( + name: :cancel, + tag: :a, + label: I18n.t(:button_cancel), + scheme: :default, + href: url_helpers.ldap_departments_synchronized_trees_path + ) + buttons.submit( + name: :submit, + label: model.persisted? ? I18n.t(:button_save) : I18n.t(:button_create), + scheme: :primary + ) + end + end + end + end +end diff --git a/modules/ldap_departments/app/models/ldap_departments.rb b/modules/ldap_departments/app/models/ldap_departments.rb new file mode 100644 index 000000000000..4a1e48757f1d --- /dev/null +++ b/modules/ldap_departments/app/models/ldap_departments.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module LdapDepartments + def self.table_name_prefix + "ldap_departments_" + end +end diff --git a/modules/ldap_departments/app/models/ldap_departments/membership.rb b/modules/ldap_departments/app/models/ldap_departments/membership.rb new file mode 100644 index 000000000000..2ac2280ba1a9 --- /dev/null +++ b/modules/ldap_departments/app/models/ldap_departments/membership.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module LdapDepartments + class Membership < ApplicationRecord + belongs_to :user + belongs_to :synchronized_department, + class_name: "::LdapDepartments::SynchronizedDepartment", + counter_cache: :users_count + + validates :user_id, uniqueness: { scope: :synchronized_department_id } + end +end diff --git a/modules/ldap_departments/app/models/ldap_departments/synchronized_department.rb b/modules/ldap_departments/app/models/ldap_departments/synchronized_department.rb new file mode 100644 index 000000000000..440cec26a7cd --- /dev/null +++ b/modules/ldap_departments/app/models/ldap_departments/synchronized_department.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require "net/ldap" +require "net/ldap/dn" + +module LdapDepartments + # Maps a single LDAP organizational unit (by DN, optionally keyed by a stable GUID) onto an + # OpenProject department (a Group with organizational_unit: true). + class SynchronizedDepartment < ApplicationRecord + belongs_to :synchronized_tree, + class_name: "::LdapDepartments::SynchronizedTree" + + belongs_to :ldap_auth_source + + belongs_to :group + + # Dropping the mapping only removes the tracking records, not the actual group memberships. + has_many :users, + class_name: "::LdapDepartments::Membership", + inverse_of: :synchronized_department, + dependent: :delete_all + + validates :dn, presence: true + validates :group, presence: true + validates :ldap_auth_source, presence: true + + ## + # Add a set of users to this department, recording the sync membership and moving them into the + # underlying group. Callers must ensure the users are not members of another department. + # + # @param new_users [Array | Array] + def add_members!(new_users) + return if new_users.empty? + + self.class.transaction do + memberships = new_users.to_a.map { |user| { synchronized_department_id: id, user_id: user_id(user) } } + ::LdapDepartments::Membership.insert_all memberships, unique_by: %i[user_id synchronized_department_id] + + add_members_to_group(new_users) + end + end + + ## + # Remove a set of users from this department and drop their sync membership. + # + # @param users_to_remove [Array | Array] + def remove_members!(users_to_remove) + return if users_to_remove.empty? + + user_ids = users_to_remove.map { |user| user_id(user) } + + self.class.transaction do + users.delete users.where(user_id: user_ids).select(:id) + remove_members_from_group(user_ids) + end + end + + private + + def user_id(user) + case user + when Integer + user + when User + user.id + else + raise ArgumentError, "Expected User or User ID (Integer) but got #{user}" + end + end + + # rubocop:disable Metrics/AbcSize + def add_members_to_group(new_users) + user_ids = new_users.map { |user| user_id(user) } + + call = Groups::UpdateService + .new(user: User.system, model: group, contract_class: Groups::SyncUpdateContract) + .call(add_user_ids: user_ids) + + call.on_success do + Rails.logger.debug { "[LDAP departments] Added users #{user_ids} to #{group.name}" } + end + + call.on_failure do + Rails.logger.error "[LDAP departments] Failed to add users #{user_ids} to #{group.name}: #{call.message}" + raise ActiveRecord::Rollback + end + end + + def remove_members_from_group(user_ids) + call = Groups::UpdateService + .new(user: User.system, model: group, contract_class: Groups::SyncUpdateContract) + .call(remove_user_ids: user_ids) + + call.on_success do + Rails.logger.debug { "[LDAP departments] Removed users #{user_ids} from #{group.name}" } + end + + call.on_failure do + Rails.logger.error "[LDAP departments] Failed to remove users #{user_ids} from #{group.name}: #{call.message}" + raise ActiveRecord::Rollback + end + end + # rubocop:enable Metrics/AbcSize + end +end diff --git a/modules/ldap_departments/app/models/ldap_departments/synchronized_tree.rb b/modules/ldap_departments/app/models/ldap_departments/synchronized_tree.rb new file mode 100644 index 000000000000..00e7e2821e40 --- /dev/null +++ b/modules/ldap_departments/app/models/ldap_departments/synchronized_tree.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require "net/ldap" +require "net/ldap/dn" + +module LdapDepartments + # Configuration of an LDAP subtree whose organizational units are mirrored into the OpenProject + # department hierarchy. The base DN is the anchor: its direct child OUs become top-level + # departments, deeper OUs are nested accordingly. + class SynchronizedTree < ApplicationRecord + belongs_to :ldap_auth_source + + has_many :synchronized_departments, + class_name: "::LdapDepartments::SynchronizedDepartment", + inverse_of: :synchronized_tree, + dependent: :destroy + + validates :name, presence: true + validates :base_dn, presence: true + validates :ou_name_attribute, presence: true + validates :ldap_auth_source, presence: true + validate :validate_structure_filter_syntax + validate :validate_user_filter_syntax + validate :validate_base_dn + validate :validate_no_overlap + + def parsed_structure_filter + Net::LDAP::Filter.from_rfc2254 structure_filter_string + end + + # Filter identifying user (person) entries below the base DN. Falls back to the auth source + # filter and finally to a generic person filter when nothing is configured. + def parsed_user_filter + if user_filter_string.present? + Net::LDAP::Filter.from_rfc2254 user_filter_string + elsif ldap_auth_source&.filter_string.present? + ldap_auth_source.parsed_filter_string + else + Net::LDAP::Filter.eq("objectClass", "person") + end + end + + def guid_lookup? + guid_attribute.present? + end + + private + + def validate_structure_filter_syntax + parsed_structure_filter + rescue Net::LDAP::FilterSyntaxInvalidError + errors.add :structure_filter_string, :invalid + end + + def validate_user_filter_syntax + return if user_filter_string.blank? + + Net::LDAP::Filter.from_rfc2254 user_filter_string + rescue Net::LDAP::FilterSyntaxInvalidError + errors.add :user_filter_string, :invalid + end + + def validate_base_dn + return if base_dn.blank? || ldap_auth_source.blank? + + base = Dn.normalize(base_dn) + source_base = Dn.normalize(ldap_auth_source.base_dn) + + unless base == source_base || base.end_with?(",#{source_base}") + errors.add :base_dn, :must_contain_base_dn + end + end + + # Two trees on the same auth source may not overlap: neither base DN may be an ancestor of + # (or identical to) the other, otherwise the same OU would be claimed by both. + def validate_no_overlap + return if base_dn.blank? || ldap_auth_source.blank? + + errors.add :base_dn, :overlaps_other_tree if overlapping_sibling? + end + + def overlapping_sibling? + mine = Dn.normalize(base_dn) + + SynchronizedTree + .where(ldap_auth_source_id:) + .where.not(id: id || 0) + .any? { |other| dn_overlaps?(mine, Dn.normalize(other.base_dn)) } + end + + def dn_overlaps?(one, other) + one == other || one.end_with?(",#{other}") || other.end_with?(",#{one}") + end + end +end diff --git a/modules/ldap_departments/app/services/ldap_departments/dn.rb b/modules/ldap_departments/app/services/ldap_departments/dn.rb new file mode 100644 index 000000000000..98e864435f8a --- /dev/null +++ b/modules/ldap_departments/app/services/ldap_departments/dn.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module LdapDepartments + # Small helpers for working with LDAP distinguished names. Splitting is escape-aware so that + # escaped commas (e.g. `cn=Doe\, John,ou=...`) do not break RDN boundaries. + module Dn + module_function + + def split_rdns(value) + rdns = [] + current = +"" + escaped = false + + value.to_s.each_char do |char| + if escaped + current << char + escaped = false + elsif char == "\\" + current << char + escaped = true + elsif char == "," + rdns << current + current = +"" + else + current << char + end + end + + rdns << current + rdns + end + + # The parent container DN (everything past the first RDN), or nil for a single-component DN. + def parent(value) + rdns = split_rdns(value) + return nil if rdns.size <= 1 + + rdns.drop(1).join(",").strip + end + + # Canonical form for case- and spacing-insensitive comparison. + def normalize(value) + split_rdns(value).map { |rdn| rdn.strip.downcase }.join(",") + end + end +end diff --git a/modules/ldap_departments/app/services/ldap_departments/synchronization_service.rb b/modules/ldap_departments/app/services/ldap_departments/synchronization_service.rb new file mode 100644 index 000000000000..e3adc9ef1cca --- /dev/null +++ b/modules/ldap_departments/app/services/ldap_departments/synchronization_service.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module LdapDepartments + # Entry point for the department synchronization. For every configured tree it first mirrors the + # organizational unit structure into departments and then assigns the users found below the base. + class SynchronizationService + def self.synchronize! + User.system.run_given do + new.call + end + end + + # Synchronize a single tree (used by the per-tree background job). + def self.synchronize_tree!(tree) + User.system.run_given do + new.synchronize_tree(tree) + end + end + + def call + SynchronizedTree.includes(:ldap_auth_source).find_each do |tree| + synchronize_tree(tree) + end + end + + def synchronize_tree(tree) + Rails.logger.info { "[LDAP departments] Synchronizing structure for tree '#{tree.name}'" } + SynchronizeTreeService.new(tree).call + + Rails.logger.info { "[LDAP departments] Synchronizing members for tree '#{tree.name}'" } + SynchronizeMembersService.new(tree).call + rescue StandardError => e + Rails.logger.error "[LDAP departments] Failed to synchronize tree '#{tree.name}': #{e.class}: #{e.message}" + end + end +end diff --git a/modules/ldap_departments/app/services/ldap_departments/synchronize_members_service.rb b/modules/ldap_departments/app/services/ldap_departments/synchronize_members_service.rb new file mode 100644 index 000000000000..58ad68dbb450 --- /dev/null +++ b/modules/ldap_departments/app/services/ldap_departments/synchronize_members_service.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +require "net/ldap" + +module LdapDepartments + # Assigns the users found below a tree's base DN to the department of their immediate parent OU. + # LDAP is authoritative: a user is moved out of any other department first, and memberships the + # sync no longer sees are removed. + class SynchronizeMembersService + attr_reader :tree, :ldap + + def initialize(tree) + @tree = tree + @ldap = tree.ldap_auth_source + end + + def call + synchronize! + ServiceResult.success + rescue StandardError => e + message = "[LDAP departments] Failed to synchronize members of tree '#{tree.name}': #{e.class}: #{e.message}" + Rails.logger.error(message) + ServiceResult.failure(message:) + end + + def synchronize! + department_by_dn = build_department_index + return if department_by_dn.empty? + + desired = collect_desired_memberships(department_by_dn) + apply(department_by_dn.values.uniq, desired) + end + + private + + def build_department_index + SynchronizedDepartment + .where(synchronized_tree_id: tree.id) + .index_by { |sync| Dn.normalize(sync.dn) } + end + + # Returns a hash of synchronized_department_id => Set(user_id) reflecting the LDAP state. + def collect_desired_memberships(department_by_dn) + login_data = {} + login_department = {} + + search_users do |entry| + department = department_for(entry, department_by_dn) + next unless department + + data = ldap.get_user_attributes_from_ldap_entry(entry) + login = data[:login] + next if login.blank? + + login_data[login] = data.except(:dn) + login_department[login] = department + end + + create_missing!(login_data) if tree.sync_users + build_desired(login_data, login_department) + end + + def department_for(entry, department_by_dn) + parent = Dn.parent(entry.dn) + return nil if parent.nil? + + department_by_dn[Dn.normalize(parent)] + end + + def build_desired(login_data, login_department) + ids_by_login = user_ids_by_login(login_data.keys) + + desired = Hash.new { |hash, key| hash[key] = Set.new } + login_department.each do |login, department| + user_id = ids_by_login[login.downcase] + desired[department.id] << user_id if user_id + end + desired + end + + def user_ids_by_login(logins) + User + .where("LOWER(login) IN (?)", logins.map(&:downcase)) + .pluck(:login, :id) + .to_h { |login, id| [login.downcase, id] } + end + + def search_users(&) + ldap.with_connection do |connection| + connection.search(base: tree.base_dn, + filter: tree.parsed_user_filter, + attributes: ldap.search_attributes, + &) + end + end + + # Two passes: remove everything no longer desired first, so a user moving between departments of + # the same tree is freed before being added to the new one. + def apply(departments, desired) + departments.each do |department| + remove_outdated(department, desired[department.id]) + add_new(department, desired[department.id]) + SynchronizedDepartment.reset_counters(department.id, :users, touch: true) + end + end + + def remove_outdated(department, desired_ids) + current = current_member_ids(department) + to_remove = current - desired_ids + department.remove_members!(to_remove.to_a) if to_remove.any? + end + + def add_new(department, desired_ids) + to_add = desired_ids - current_member_ids(department) + return if to_add.empty? + + relocate_from_other_departments(to_add.to_a, department.group_id) + department.add_members!(to_add.to_a) + end + + def current_member_ids(department) + Membership.where(synchronized_department_id: department.id).pluck(:user_id).to_set + end + + # LDAP places each user in exactly one OU, so remove them from any other department first. + def relocate_from_other_departments(user_ids, keep_group_id) + GroupUser + .joins(:group) + .merge(Group.organizational_units) + .where(user_id: user_ids) + .where.not(group_id: keep_group_id) + .pluck(:group_id, :user_id) + .group_by(&:first) + .each { |group_id, rows| remove_from_group(group_id, rows.map(&:last)) } + end + + def remove_from_group(group_id, user_ids) + group = Group.find_by(id: group_id) + return unless group + + call = Groups::UpdateService + .new(user: User.system, model: group, contract_class: Groups::SyncUpdateContract) + .call(remove_user_ids: user_ids) + Rails.logger.error("[LDAP departments] Failed to relocate users from #{group.name}: #{call.message}") unless call.success? + + drop_memberships_for_group(group_id, user_ids) + end + + def drop_memberships_for_group(group_id, user_ids) + SynchronizedDepartment.where(group_id:).find_each do |sync| + sync.users.where(user_id: user_ids).delete_all + SynchronizedDepartment.reset_counters(sync.id, :users, touch: true) + end + end + + def create_missing!(login_data) + existing = User.where(login: login_data.keys).pluck(:login).to_set(&:downcase) + + login_data.each do |login, data| + next if existing.include?(login.downcase) + + if OpenProject::Enterprise.user_limit_reached? + Rails.logger.error("[LDAP departments] User '#{login}' could not be created as user limit exceeded.") + break + end + + try_to_create(data) + end + end + + def try_to_create(attrs) + call = Users::CreateService.new(user: User.system).call(attrs) + if call.success? + Rails.logger.info("[LDAP departments] User '#{call.result.login}' created") + else + Rails.logger.error("[LDAP departments] User '#{call.result&.login}' could not be created: #{call.message}") + end + end + end +end diff --git a/modules/ldap_departments/app/services/ldap_departments/synchronize_tree_service.rb b/modules/ldap_departments/app/services/ldap_departments/synchronize_tree_service.rb new file mode 100644 index 000000000000..85fb58e370c0 --- /dev/null +++ b/modules/ldap_departments/app/services/ldap_departments/synchronize_tree_service.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +require "net/ldap" + +module LdapDepartments + # Mirrors the organizational unit subtree below a SynchronizedTree's base DN into the OpenProject + # department hierarchy. Creates/updates one department (Group with organizational_unit: true) per + # OU and prunes mappings for OUs that disappeared (keeping the department itself). + class SynchronizeTreeService + attr_reader :tree, :ldap + + def initialize(tree) + @tree = tree + @ldap = tree.ldap_auth_source + @by_dn = {} + end + + def call + count = synchronize! + ServiceResult.success(result: count) + rescue StandardError => e + message = "[LDAP departments] Failed to synchronize tree '#{tree.name}': #{e.class}: #{e.message}" + Rails.logger.error(message) + ServiceResult.failure(message:) + end + + def synchronize! + entries = fetch_ou_entries + # Process shallow OUs first so a child can always resolve its already-persisted parent. + entries.sort_by! { |entry| Dn.split_rdns(entry[:dn]).size } + + seen = [] + entries.each do |entry| + next if entry[:name].blank? + + sync = upsert_department(entry) + normalized = Dn.normalize(sync.dn) + @by_dn[normalized] = sync + seen << normalized + end + + prune_removed(seen) + seen.size + end + + private + + def fetch_ou_entries + base = Dn.normalize(tree.base_dn) + + entries = [] + ldap.with_connection do |connection| + connection.search(base: tree.base_dn, filter: tree.parsed_structure_filter, attributes: ou_search_attributes) do |entry| + # The base DN itself is the anchor, not a department. + next if Dn.normalize(entry.dn) == base + + entries << build_ou_entry(entry) + end + end + + entries + end + + def ou_search_attributes + ["dn", tree.ou_name_attribute, tree.guid_attribute].compact + end + + def build_ou_entry(entry) + { + dn: entry.dn, + name: LdapAuthSource.get_attr(entry, tree.ou_name_attribute), + uuid: guid_value(entry) + } + end + + def guid_value(entry) + return nil unless tree.guid_lookup? + + raw = Array(entry[tree.guid_attribute]).first + return nil if raw.nil? + + if raw.encoding == Encoding::BINARY || !raw.valid_encoding? + raw.unpack1("H*") + else + raw + end + end + + def upsert_department(entry) + sync = find_existing(entry) || tree.synchronized_departments.build + assign_department_group(sync, entry) + sync.save! + sync + end + + def assign_department_group(sync, entry) + sync.synchronized_tree = tree + sync.ldap_auth_source = ldap + sync.dn = entry[:dn] + sync.ldap_entry_uuid = entry[:uuid] if entry[:uuid].present? + apply_group(sync, entry) + end + + def apply_group(sync, entry) + parent_group_id = resolve_parent_group_id(entry[:dn]) + + if sync.group_id + update_department(sync.group, entry[:name], parent_group_id) + else + sync.group = create_department(entry[:name], parent_group_id) + end + end + + def find_existing(entry) + if entry[:uuid].present? + tree.synchronized_departments.find_by(ldap_entry_uuid: entry[:uuid]) || + find_by_dn(entry[:dn]) + else + find_by_dn(entry[:dn]) + end + end + + def find_by_dn(value) + tree.synchronized_departments.find_by(dn: value) + end + + # The parent OU's department, or nil when the parent is the base DN (→ top-level department). + def resolve_parent_group_id(child_dn) + parent = Dn.parent(child_dn) + return nil if parent.nil? + + normalized_parent = Dn.normalize(parent) + return nil if normalized_parent == Dn.normalize(tree.base_dn) + + parent_sync = @by_dn[normalized_parent] + unless parent_sync + Rails.logger.warn { "[LDAP departments] No synced parent for #{child_dn}, treating as top-level" } + end + + parent_sync&.group_id + end + + def create_department(name, parent_group_id) + call = Groups::CreateService + .new(user: User.system, contract_class: Groups::SyncCreateContract) + .call(name:, organizational_unit: true, parent_id: parent_group_id) + require_success!(call, "create department '#{name}'") + call.result + end + + def update_department(group, name, parent_group_id) + return if group.name == name && group.parent_id == parent_group_id + + call = Groups::UpdateService + .new(user: User.system, model: group, contract_class: Groups::SyncUpdateContract) + .call(name:, parent_id: parent_group_id) + require_success!(call, "update department '#{name}'") + end + + def require_success!(call, action) + raise "Failed to #{action}: #{call.message}" unless call.success? + end + + def prune_removed(seen) + SynchronizedDepartment + .where(synchronized_tree_id: tree.id) + .reject { |sync| seen.include?(Dn.normalize(sync.dn)) } + .each do |sync| + Rails.logger.info { "[LDAP departments] OU #{sync.dn} no longer present, unmanaging department" } + sync.destroy! + end + end + end +end diff --git a/modules/ldap_departments/app/views/ldap_departments/synchronized_trees/edit.html.erb b/modules/ldap_departments/app/views/ldap_departments/synchronized_trees/edit.html.erb new file mode 100644 index 000000000000..235782668be3 --- /dev/null +++ b/modules/ldap_departments/app/views/ldap_departments/synchronized_trees/edit.html.erb @@ -0,0 +1,18 @@ +<% html_title(t(:label_administration), t(:label_edit_x, x: @tree.name)) -%> + +<%= + render Primer::OpenProject::PageHeader.new do |header| + header.with_title { @tree.name } + header.with_breadcrumbs( + [ + { href: admin_index_path, text: t(:label_administration) }, + { href: admin_settings_authentication_path, text: t(:label_authentication) }, + { href: ldap_departments_synchronized_trees_path, text: I18n.t("ldap_departments.label_menu_item") }, + @tree.name + ], + selected_item_font_weight: :normal + ) + end +%> + +<%= render(LdapDepartments::SynchronizedTrees::FormComponent.new(@tree)) %> diff --git a/modules/ldap_departments/app/views/ldap_departments/synchronized_trees/index.html.erb b/modules/ldap_departments/app/views/ldap_departments/synchronized_trees/index.html.erb new file mode 100644 index 000000000000..367ebc065596 --- /dev/null +++ b/modules/ldap_departments/app/views/ldap_departments/synchronized_trees/index.html.erb @@ -0,0 +1,38 @@ +<% html_title(t(:label_administration), t("ldap_departments.synchronized_trees.plural")) -%> + +<%= + render Primer::OpenProject::PageHeader.new do |header| + header.with_title { I18n.t("ldap_departments.synchronized_trees.plural") } + header.with_breadcrumbs( + [{ href: admin_index_path, text: t(:label_administration) }, + { href: admin_settings_authentication_path, text: t(:label_authentication) }, + I18n.t("ldap_departments.synchronized_trees.plural")] + ) + end +%> + +<%= render(Primer::OpenProject::SubHeader.new) do |subheader| + subheader.with_action_button( + tag: :a, + href: new_ldap_departments_synchronized_tree_path, + scheme: :primary, + leading_icon: :plus, + disabled: !EnterpriseToken.allows_to?(:ldap_groups), + label: I18n.t("ldap_departments.synchronized_trees.add_new") + ) do + I18n.t("ldap_departments.synchronized_trees.add_new") + end + end %> + +<%= render EnterpriseEdition::BannerComponent.new(:ldap_groups, variant: :inline, i18n_scope: "ee.upsell.ldap_departments") %> + +
+
+ <%= t("note") %> + <%= t("ldap_departments.synchronized_trees.help_text") %> +
+
+ +<%= render(Primer::Box.new(mt: 3)) do %> + <%= render(LdapDepartments::SynchronizedTrees::TableComponent.new(rows: @trees)) %> +<% end %> diff --git a/modules/ldap_departments/app/views/ldap_departments/synchronized_trees/new.html.erb b/modules/ldap_departments/app/views/ldap_departments/synchronized_trees/new.html.erb new file mode 100644 index 000000000000..8880f081432e --- /dev/null +++ b/modules/ldap_departments/app/views/ldap_departments/synchronized_trees/new.html.erb @@ -0,0 +1,15 @@ +<% html_title(t(:label_administration), t("ldap_departments.synchronized_trees.add_new")) -%> + +<%= + render Primer::OpenProject::PageHeader.new do |header| + header.with_title { t("ldap_departments.synchronized_trees.add_new") } + header.with_breadcrumbs( + [{ href: admin_index_path, text: t(:label_administration) }, + { href: admin_settings_authentication_path, text: t(:label_authentication) }, + { href: ldap_departments_synchronized_trees_path, text: I18n.t("ldap_departments.label_menu_item") }, + t("ldap_departments.synchronized_trees.add_new")] + ) + end +%> + +<%= render(LdapDepartments::SynchronizedTrees::FormComponent.new(@tree)) %> diff --git a/modules/ldap_departments/app/views/ldap_departments/synchronized_trees/show.html.erb b/modules/ldap_departments/app/views/ldap_departments/synchronized_trees/show.html.erb new file mode 100644 index 000000000000..e7b2676dc636 --- /dev/null +++ b/modules/ldap_departments/app/views/ldap_departments/synchronized_trees/show.html.erb @@ -0,0 +1,54 @@ +<% html_title(t(:label_administration), @tree.name) -%> + +<%= + render Primer::OpenProject::PageHeader.new do |header| + header.with_title { @tree.name } + header.with_breadcrumbs( + [{ href: admin_index_path, text: t(:label_administration) }, + { href: admin_settings_authentication_path, text: t(:label_authentication) }, + { href: ldap_departments_synchronized_trees_path, text: I18n.t("ldap_departments.label_menu_item") }, + @tree.name] + ) + end +%> + +<%= render(Primer::OpenProject::SubHeader.new) do |subheader| + subheader.with_action_button( + tag: :a, + href: synchronize_ldap_departments_synchronized_tree_path(tree_id: @tree.id), + scheme: :primary, + leading_icon: :sync, + data: { turbo_method: :post }, + disabled: !EnterpriseToken.allows_to?(:ldap_groups), + label: t("ldap_departments.label_synchronize") + ) do + t("ldap_departments.label_synchronize") + end + subheader.with_action_button( + tag: :a, + href: edit_ldap_departments_synchronized_tree_path(tree_id: @tree.id), + scheme: :secondary, + leading_icon: :pencil, + label: t(:button_edit) + ) do + t(:button_edit) + end + end %> + +<%= render EnterpriseEdition::BannerComponent.new(:ldap_groups, variant: :inline, i18n_scope: "ee.upsell.ldap_departments") %> + +<%= render(Primer::Alpha::Layout.new(stacking_breakpoint: :md)) do |layout| %> + <% layout.with_main do %> +
+ <%= render(Primer::Beta::Heading.new(tag: :h3, pb: 3)) do + t("ldap_departments.synchronized_departments.plural") + end %> + + <%= render(LdapDepartments::SynchronizedDepartments::TableComponent.new(rows: @departments)) %> +
+ <% end %> + + <% layout.with_sidebar(row_placement: :start, col_placement: :end) do %> + <%= render(LdapDepartments::SynchronizedTrees::SidePanelComponent.new(tree: @tree)) %> + <% end %> +<% end %> diff --git a/modules/ldap_departments/app/workers/ldap_departments/synchronization_job.rb b/modules/ldap_departments/app/workers/ldap_departments/synchronization_job.rb new file mode 100644 index 000000000000..f4a01a6c753e --- /dev/null +++ b/modules/ldap_departments/app/workers/ldap_departments/synchronization_job.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module LdapDepartments + class SynchronizationJob < ApplicationJob + def perform + return unless EnterpriseToken.allows_to?(:ldap_groups) + return if skipped? + + ::LdapDepartments::SynchronizationService.synchronize! + end + + def skipped? + OpenProject::Configuration.ldap_departments_disable_sync_job? + end + end +end diff --git a/modules/ldap_departments/app/workers/ldap_departments/synchronize_tree_job.rb b/modules/ldap_departments/app/workers/ldap_departments/synchronize_tree_job.rb new file mode 100644 index 000000000000..bc00609f0ead --- /dev/null +++ b/modules/ldap_departments/app/workers/ldap_departments/synchronize_tree_job.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module LdapDepartments + # Synchronizes a single tree in the background. Triggered when a tree is created or when an admin + # requests a manual synchronization, since syncing a large organizational unit tree can be slow. + class SynchronizeTreeJob < ApplicationJob + def perform(tree) + return unless EnterpriseToken.allows_to?(:ldap_groups) + return if tree.nil? + + ::LdapDepartments::SynchronizationService.synchronize_tree!(tree) + end + end +end diff --git a/modules/ldap_departments/config/locales/en.yml b/modules/ldap_departments/config/locales/en.yml new file mode 100644 index 000000000000..d75a18045056 --- /dev/null +++ b/modules/ldap_departments/config/locales/en.yml @@ -0,0 +1,89 @@ +--- +en: + activerecord: + attributes: + ldap_departments/synchronized_department: + dn: "DN" + group: "Department" + ldap_auth_source: "LDAP connection" + users_count: "Members" + ldap_departments/synchronized_tree: + base_dn: "Base DN" + guid_attribute: "Unique identifier attribute" + ldap_auth_source: "LDAP connection" + name: "Name" + ou_name_attribute: "OU name attribute" + structure_filter_string: "Organizational unit filter" + sync_users: "Sync users" + user_filter_string: "User filter" + errors: + models: + ldap_departments/synchronized_tree: + must_contain_base_dn: "The base DN must be contained within the LDAP connection's base DN." + overlaps_other_tree: "Overlaps the base DN of another synchronization on the same LDAP connection." + models: + ldap_departments/synchronized_department: "Synchronized department" + ldap_departments/synchronized_tree: "LDAP department synchronization" + ee: + upsell: + ldap_departments: + title: "LDAP department synchronization" + description: "Mirror your LDAP organizational unit structure into the OpenProject department hierarchy and keep department members in sync automatically." + ldap_departments: + label_menu_item: "LDAP department synchronization" + label_synchronize: "Synchronize organizational units" + synchronized_departments: + blankslate: + description: "Run the synchronization to import the organizational units of this base DN as departments." + heading: "No departments synchronized yet" + destroy: + confirmation: "If you continue, %{name} is no longer managed by LDAP synchronization." + heading: "Stop managing %{name}?" + info: "The department and its members are kept. It becomes a regular department that you can edit or delete manually." + title: "Stop managing %{name}" + managed_notice: "Managed by LDAP synchronization" + no_results: "No departments have been synchronized yet." + plural: "Synchronized departments" + singular: "Synchronized department" + synchronized_trees: + add_new: "Add LDAP department synchronization" + synchronization_started: "Synchronization started in the background. Departments will appear once it completes." + blankslate: + description: "Set up a synchronization to mirror your LDAP organizational unit structure into the OpenProject department hierarchy." + heading: "No LDAP department synchronizations" + destroy: + confirmation: "If you continue, the synchronization %{name} is removed and its %{departments_count} departments are no longer managed by LDAP." + heading: "Remove synchronization %{name}?" + info: "The departments and their members are kept and become regular departments. You can edit or delete them manually afterwards under Administration → Users and permissions → Organization." + title: "Remove synchronization %{name}" + form: + auth_source_text: "Select which LDAP connection should be used." + base_dn_text: > + Enter the base DN whose organizational units should be synchronized. Its direct child OUs + become top-level departments. It must be below the base DN of the selected LDAP connection. + guid_attribute_text: > + Optional. The LDAP attribute holding a stable unique identifier (e.g. "objectGUID" or + "entryUUID"). When set, departments survive OU renames and moves. Leave empty to match by DN. + ou_name_attribute_text: 'The LDAP attribute used to name the department (e.g. "ou").' + sections: + connection: "LDAP connection" + structure: "Organizational units" + users: "Users" + structure_filter_string_text: "RFC4515 LDAP filter identifying organizational unit entries." + sync_users_text: > + If enabled, users found below the base DN are also created in OpenProject. Without it, only + existing accounts are assigned to departments. + user_filter_string_text: > + Optional RFC4515 LDAP filter identifying user entries. Leave empty to use the connection + filter, or a generic person filter when none is set. + help_text: > + This module mirrors the organizational unit (OU) structure of your LDAP directory into the + OpenProject department hierarchy. Each OU below the configured base DN becomes a department, + and users are assigned to the department of the OU they reside in. Departments are + synchronized regularly through a cron job. + no_results: "No LDAP department synchronizations configured yet." + plural: "LDAP department synchronizations" + singular: "LDAP department synchronization" + plugin_openproject_ldap_departments: + description: "Synchronization of LDAP organizational units into OpenProject departments." + name: "OpenProject LDAP departments" diff --git a/modules/ldap_departments/config/routes.rb b/modules/ldap_departments/config/routes.rb new file mode 100644 index 000000000000..4c9bb0654667 --- /dev/null +++ b/modules/ldap_departments/config/routes.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +Rails.application.routes.draw do + namespace "ldap_departments" do + resources :synchronized_trees, + param: :tree_id do + member do + # Synchronize the organizational unit structure and members of a single tree + post "synchronize" + + # Danger confirmation dialog shown before deletion + get "deletion_dialog" + end + end + + resources :synchronized_departments, + param: :department_id, + only: %i(destroy) do + member do + # Danger confirmation dialog shown before unlinking a department + get "deletion_dialog" + end + end + end +end diff --git a/modules/ldap_departments/db/migrate/20260622150055_create_ldap_departments_tables.rb b/modules/ldap_departments/db/migrate/20260622150055_create_ldap_departments_tables.rb new file mode 100644 index 000000000000..821b6e284ba7 --- /dev/null +++ b/modules/ldap_departments/db/migrate/20260622150055_create_ldap_departments_tables.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +class CreateLdapDepartmentsTables < ActiveRecord::Migration[8.1] + def change + create_synchronized_trees + create_synchronized_departments + create_memberships + end + + private + + def create_synchronized_trees + create_table :ldap_departments_synchronized_trees do |t| + t.string :name + t.references :ldap_auth_source + t.text :base_dn + t.string :structure_filter_string, null: false, default: "(objectClass=organizationalUnit)" + t.string :ou_name_attribute, null: false, default: "ou" + t.string :guid_attribute, null: true + t.text :user_filter_string, null: true + t.boolean :sync_users, null: false, default: false + + t.timestamps + end + end + + def create_synchronized_departments + create_table :ldap_departments_synchronized_departments do |t| + t.belongs_to :synchronized_tree, + foreign_key: { to_table: :ldap_departments_synchronized_trees } + t.references :ldap_auth_source + t.references :group + t.text :dn + t.string :ldap_entry_uuid, null: true + t.integer :users_count, null: false, default: 0 + + t.timestamps + + t.index :dn, unique: true + t.index :ldap_entry_uuid + end + end + + def create_memberships + create_table :ldap_departments_memberships do |t| + t.references :user + t.belongs_to :synchronized_department, + foreign_key: { to_table: :ldap_departments_synchronized_departments } + + t.timestamps + + t.index %i[user_id synchronized_department_id], unique: true + end + end +end diff --git a/modules/ldap_departments/lib/open_project/ldap_departments.rb b/modules/ldap_departments/lib/open_project/ldap_departments.rb new file mode 100644 index 000000000000..29c6872a312b --- /dev/null +++ b/modules/ldap_departments/lib/open_project/ldap_departments.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module OpenProject + module LdapDepartments + require "open_project/ldap_departments/engine" + end +end diff --git a/modules/ldap_departments/lib/open_project/ldap_departments/engine.rb b/modules/ldap_departments/lib/open_project/ldap_departments/engine.rb new file mode 100644 index 000000000000..0c51e5cfb554 --- /dev/null +++ b/modules/ldap_departments/lib/open_project/ldap_departments/engine.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module OpenProject::LdapDepartments + class Engine < ::Rails::Engine + engine_name :openproject_ldap_departments + + include OpenProject::Plugins::ActsAsOpEngine + + register "openproject-ldap_departments", + author_url: "https://github.com/opf/openproject", + bundled: true, + settings: { + default: {} + } do + menu :admin_menu, + :plugin_ldap_departments, + { controller: "/ldap_departments/synchronized_trees", action: :index }, + parent: :authentication, + after: :plugin_ldap_groups, + caption: ->(*) { I18n.t("ldap_departments.label_menu_item") }, + enterprise_feature: "ldap_groups", + if: ->(*) { OpenProject::FeatureDecisions.departments_active? } + end + + add_cron_jobs do + { + "LdapDepartments::SynchronizationJob": { + cron: "*/30 * * * *", # Run every 30 minutes + class: LdapDepartments::SynchronizationJob.name + } + } + end + + patches %i[LdapAuthSource Group User] + end +end diff --git a/modules/ldap_departments/lib/open_project/ldap_departments/patches/group_patch.rb b/modules/ldap_departments/lib/open_project/ldap_departments/patches/group_patch.rb new file mode 100644 index 000000000000..9d48087a55a1 --- /dev/null +++ b/modules/ldap_departments/lib/open_project/ldap_departments/patches/group_patch.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module OpenProject::LdapDepartments + module Patches + module GroupPatch + def self.included(base) # :nodoc: + base.class_eval do + has_many :ldap_departments_synchronized_departments, + class_name: "::LdapDepartments::SynchronizedDepartment", + foreign_key: :group_id, + dependent: :destroy + + # A department is managed when an LDAP organizational unit is mapped onto it. Only + # organizational units can ever be managed, so skip the lookup for regular groups. + register_ldap_managed_check do |group| + next false if group.new_record? + next false unless group.organizational_unit? + + group.ldap_departments_synchronized_departments.exists? + end + end + end + end + end +end diff --git a/modules/ldap_departments/lib/open_project/ldap_departments/patches/ldap_auth_source_patch.rb b/modules/ldap_departments/lib/open_project/ldap_departments/patches/ldap_auth_source_patch.rb new file mode 100644 index 000000000000..e897c120dc44 --- /dev/null +++ b/modules/ldap_departments/lib/open_project/ldap_departments/patches/ldap_auth_source_patch.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module OpenProject::LdapDepartments + module Patches + module LdapAuthSourcePatch + def self.included(base) # :nodoc: + base.class_eval do + has_many :ldap_departments_synchronized_trees, + class_name: "::LdapDepartments::SynchronizedTree", + dependent: :destroy + + has_many :ldap_departments_synchronized_departments, + class_name: "::LdapDepartments::SynchronizedDepartment", + dependent: :destroy + end + end + end + end +end diff --git a/modules/ldap_departments/lib/open_project/ldap_departments/patches/user_patch.rb b/modules/ldap_departments/lib/open_project/ldap_departments/patches/user_patch.rb new file mode 100644 index 000000000000..fa361dfb8a7d --- /dev/null +++ b/modules/ldap_departments/lib/open_project/ldap_departments/patches/user_patch.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module OpenProject::LdapDepartments + module Patches + module UserPatch + def self.included(base) # :nodoc: + base.class_eval do + has_many :ldap_departments_memberships, + class_name: "::LdapDepartments::Membership", + dependent: :destroy + end + end + end + end +end diff --git a/modules/ldap_departments/lib/openproject-ldap_departments.rb b/modules/ldap_departments/lib/openproject-ldap_departments.rb new file mode 100644 index 000000000000..d08d4e138907 --- /dev/null +++ b/modules/ldap_departments/lib/openproject-ldap_departments.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require "open_project/ldap_departments" diff --git a/modules/ldap_departments/lib/tasks/ldap_departments.rake b/modules/ldap_departments/lib/tasks/ldap_departments.rake new file mode 100644 index 000000000000..4622456d398a --- /dev/null +++ b/modules/ldap_departments/lib/tasks/ldap_departments.rake @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +namespace :ldap_departments do + desc "Synchronize departments and their members from the configured LDAP organizational units." + task synchronize: :environment do + LdapDepartments::SynchronizationService.synchronize! + end + + namespace :development do + desc "Create a development LDAP server with a nested OU tree synced into departments" + task ldap_server: :environment do + require "ladle" + ldif = ENV.fetch("LDIF_FILE") { Rails.root.join("spec/fixtures/ldap/users.ldif") } + ldap_server = Ladle::Server.new(quiet: false, port: "12389", domain: "dc=example,dc=com", ldif:).start + + source = LdapAuthSource.find_or_initialize_by(name: "ladle departments") + source.attributes = { + host: "localhost", + port: "12389", + tls_mode: "plain_ldap", + account: "uid=admin,ou=system", + account_password: "secret", + base_dn: "dc=example,dc=com", + onthefly_register: true, + attr_login: "uid", + attr_firstname: "givenName", + attr_lastname: "sn", + attr_mail: "mail", + attr_admin: "isAdmin" + } + source.save! + + tree = LdapDepartments::SynchronizedTree.find_or_initialize_by(ldap_auth_source: source, name: "Organization") + tree.base_dn = "ou=org,dc=example,dc=com" + tree.structure_filter_string = "(objectClass=organizationalUnit)" + tree.ou_name_attribute = "ou" + tree.sync_users = true + tree.save! + + # Run the service directly rather than the job so the dev server syncs even without an + # Enterprise token present. + LdapDepartments::SynchronizationService.synchronize! + + puts <<~INFO + LDAP server ready at localhost:12389 + + Synchronized the organizational unit tree below ou=org,dc=example,dc=com into departments: + + IT + Development + Frontend (member: jdoe) + Backend (member: bsmith) + Support + Human Resources + Recruiting (member: hwest) + Support + + Manage the synchronization under Administration > Authentication > LDAP department synchronization. + Departments appear under Administration > Departments. + + -------------------------------------------------------- + + System account + + Account: uid=admin,ou=system + Password: secret + INFO + + puts "Send CTRL+D to stop the server" + require "irb" + binding.irb # rubocop:disable Lint/Debugger + + ldap_server.stop + end + end +end diff --git a/modules/ldap_departments/openproject-ldap_departments.gemspec b/modules/ldap_departments/openproject-ldap_departments.gemspec new file mode 100644 index 000000000000..a81116e021d5 --- /dev/null +++ b/modules/ldap_departments/openproject-ldap_departments.gemspec @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +Gem::Specification.new do |s| + s.name = "openproject-ldap_departments" + s.version = "1.0.0" + s.authors = "OpenProject GmbH" + s.email = "info@openproject.com" + s.homepage = "https://github.com/opf/openproject" + s.summary = "OpenProject LDAP departments" + s.description = "Synchronization of LDAP organizational units into OpenProject departments" + s.license = "GPL-3" + + s.files = Dir["{app,config,db,lib}/**/*"] + %w(README.md) + s.metadata["rubygems_mfa_required"] = "true" +end diff --git a/modules/ldap_departments/spec/components/admin/departments/change_parent_dialog_component_spec.rb b/modules/ldap_departments/spec/components/admin/departments/change_parent_dialog_component_spec.rb new file mode 100644 index 000000000000..237d48a988ac --- /dev/null +++ b/modules/ldap_departments/spec/components/admin/departments/change_parent_dialog_component_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require_relative "../../../spec_helper" + +RSpec.describe Admin::Departments::ChangeParentDialogComponent, type: :component do + let(:moved) { create(:department, lastname: "Moved") } + let(:managed) { create(:department, lastname: "Managed") } + let(:manual) { create(:department, lastname: "Manual") } + + before do + moved + manual + create(:ldap_synchronized_department, group: managed) + end + + it "disables LDAP-managed departments as parent candidates" do + departments = Group.organizational_units.with_detail.in_tree_order + + render_inline(described_class.new(department: moved, departments:)) + + expect(page).to have_css("[data-value='#{managed.id}'][aria-disabled='true']") + expect(page).to have_css("[data-value='#{manual.id}']") + expect(page).to have_no_css("[data-value='#{manual.id}'][aria-disabled='true']") + end +end diff --git a/modules/ldap_departments/spec/components/admin/departments/department_row_component_spec.rb b/modules/ldap_departments/spec/components/admin/departments/department_row_component_spec.rb new file mode 100644 index 000000000000..0bfd584879aa --- /dev/null +++ b/modules/ldap_departments/spec/components/admin/departments/department_row_component_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require_relative "../../../spec_helper" + +RSpec.describe Admin::Departments::DepartmentRowComponent, type: :component do + let(:department) { create(:department, lastname: "IT") } + + context "when the department is not managed by LDAP" do + it "renders the action menu" do + render_inline(described_class.new(department:)) + + expect(page).to have_css("action-menu") + expect(page).to have_no_text(I18n.t(:label_managed_by_ldap)) + end + end + + context "when the department is managed by LDAP" do + before { create(:ldap_synchronized_department, group: department) } + + it "renders a managed label instead of the action menu" do + render_inline(described_class.new(department: department.reload)) + + expect(page).to have_text(I18n.t(:label_managed_by_ldap)) + expect(page).to have_no_css("action-menu") + end + end +end diff --git a/modules/ldap_departments/spec/components/admin/departments/detail_blankslate_component_spec.rb b/modules/ldap_departments/spec/components/admin/departments/detail_blankslate_component_spec.rb new file mode 100644 index 000000000000..be81d0ab7c2e --- /dev/null +++ b/modules/ldap_departments/spec/components/admin/departments/detail_blankslate_component_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require_relative "../../../spec_helper" + +RSpec.describe Admin::Departments::DetailBlankslateComponent, type: :component do + let(:department) { create(:department, lastname: "IT") } + + context "when the department is not managed by LDAP" do + it "invites adding departments or users" do + render_inline(described_class.new(group: department)) + + expect(page).to have_text(I18n.t("departments.detail_blankslate.heading")) + expect(page).to have_no_text(I18n.t("departments.detail_blankslate.managed_heading")) + end + end + + context "when the department is managed by LDAP" do + before { create(:ldap_synchronized_department, group: department) } + + it "explains that it is managed and cannot be edited manually" do + render_inline(described_class.new(group: department.reload)) + + expect(page).to have_text(I18n.t("departments.detail_blankslate.managed_heading")) + expect(page).to have_text(I18n.t("departments.detail_blankslate.managed_description")) + end + end +end diff --git a/modules/ldap_departments/spec/components/admin/departments/hierarchy_layout_component_spec.rb b/modules/ldap_departments/spec/components/admin/departments/hierarchy_layout_component_spec.rb new file mode 100644 index 000000000000..0513aebd7447 --- /dev/null +++ b/modules/ldap_departments/spec/components/admin/departments/hierarchy_layout_component_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require_relative "../../../spec_helper" + +RSpec.describe Admin::Departments::HierarchyLayoutComponent, type: :component do + let(:department) { create(:department, lastname: "IT") } + + context "when the active department is not managed by LDAP" do + it "offers the add menu" do + render_inline(described_class.new(groups: [department], active_group: department)) + + expect(page).to have_css("action-menu") + expect(page).to have_button(I18n.t(:button_add)) + end + end + + context "when the active department is managed by LDAP" do + before { create(:ldap_synchronized_department, group: department) } + + it "hides the add menu" do + render_inline(described_class.new(groups: [department], active_group: department.reload)) + + expect(page).to have_no_css("action-menu") + expect(page).to have_no_button(I18n.t(:button_add)) + end + end +end diff --git a/modules/ldap_departments/spec/components/admin/departments/move_user_dialog_component_spec.rb b/modules/ldap_departments/spec/components/admin/departments/move_user_dialog_component_spec.rb new file mode 100644 index 000000000000..75e2097e6219 --- /dev/null +++ b/modules/ldap_departments/spec/components/admin/departments/move_user_dialog_component_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require_relative "../../../spec_helper" + +RSpec.describe Admin::Departments::MoveUserDialogComponent, type: :component do + let(:user) { create(:user) } + let(:managed) { create(:department, lastname: "Managed") } + let(:manual) { create(:department, lastname: "Manual") } + + context "when the source department is managed by LDAP" do + before { create(:ldap_synchronized_department, group: managed) } + + it "shows an info message and offers no move action" do + render_inline(described_class.new(user:, from_department: managed.reload, to_department: manual)) + + expect(page).to have_text(I18n.t("departments.move_user_dialog.managed_heading")) + expect(page).to have_no_text(I18n.t("departments.move_user_dialog.confirm")) + end + end + + context "when the source department is not managed by LDAP" do + it "offers to move the user" do + render_inline(described_class.new(user:, from_department: manual, to_department: managed)) + + expect(page).to have_text(I18n.t("departments.move_user_dialog.heading")) + expect(page).to have_no_text(I18n.t("departments.move_user_dialog.managed_heading")) + end + end +end diff --git a/modules/ldap_departments/spec/components/ldap_departments/synchronized_departments/table_component_spec.rb b/modules/ldap_departments/spec/components/ldap_departments/synchronized_departments/table_component_spec.rb new file mode 100644 index 000000000000..79bad772cc6b --- /dev/null +++ b/modules/ldap_departments/spec/components/ldap_departments/synchronized_departments/table_component_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require_relative "../../../spec_helper" + +RSpec.describe LdapDepartments::SynchronizedDepartments::TableComponent, type: :component do + let(:tree) { create(:ldap_synchronized_tree) } + let(:hr) { create(:department, lastname: "Human Resources") } + let(:support) { create(:department, lastname: "Support", parent: hr) } + let!(:hr_sync) { create(:ldap_synchronized_department, synchronized_tree: tree, group: hr) } + let!(:support_sync) { create(:ldap_synchronized_department, synchronized_tree: tree, group: support) } + + it "renders the full department path instead of only the leaf name" do + render_inline(described_class.new(rows: tree.synchronized_departments.includes(group: :group_detail))) + + expect(page).to have_text("Human Resources / Support") + end +end diff --git a/modules/ldap_departments/spec/factories/synchronized_department_factory.rb b/modules/ldap_departments/spec/factories/synchronized_department_factory.rb new file mode 100644 index 000000000000..3cb5dab31a0e --- /dev/null +++ b/modules/ldap_departments/spec/factories/synchronized_department_factory.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :ldap_synchronized_department, class: "::LdapDepartments::SynchronizedDepartment" do + synchronized_tree factory: :ldap_synchronized_tree + group factory: :department + ldap_auth_source { synchronized_tree.ldap_auth_source } + sequence(:dn) { |n| "ou=Department #{n},dc=example,dc=com" } + end +end diff --git a/modules/ldap_departments/spec/factories/synchronized_tree_factory.rb b/modules/ldap_departments/spec/factories/synchronized_tree_factory.rb new file mode 100644 index 000000000000..73e4d15faa1f --- /dev/null +++ b/modules/ldap_departments/spec/factories/synchronized_tree_factory.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :ldap_synchronized_tree, class: "::LdapDepartments::SynchronizedTree" do + sequence(:name) { |n| "Tree #{n}" } + base_dn { "dc=example,dc=com" } + ldap_auth_source + end +end diff --git a/modules/ldap_departments/spec/integration/department_synchronization_spec.rb b/modules/ldap_departments/spec/integration/department_synchronization_spec.rb new file mode 100644 index 000000000000..1c87f7e6cfb8 --- /dev/null +++ b/modules/ldap_departments/spec/integration/department_synchronization_spec.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" +require "ladle" + +# Full end-to-end synchronization against a real (Ladle/ApacheDS) LDAP server using the nested +# organizational unit tree below ou=org in spec/fixtures/ldap/users.ldif: +# +# ou=org +# ou=IT +# ou=Development +# ou=Frontend (uid=jdoe) +# ou=Backend (uid=bsmith) +# ou=Support +# ou=Human Resources +# ou=Recruiting (uid=hwest) +# ou=Support (duplicate name on a different branch) +RSpec.describe "LDAP department synchronization (integration)", :aggregate_failures, # rubocop:disable RSpec/DescribeClass + with_ee: %i[ldap_groups] do + before(:all) do # rubocop:disable RSpec/BeforeAfterAll + ldif = Rails.root.join("spec/fixtures/ldap/users.ldif") + @ldap_server = Ladle::Server.new(quiet: true, + port: ParallelHelper.port_for_ldap.to_s, + domain: "dc=example,dc=com", + ldif:).start + end + + after(:all) do # rubocop:disable RSpec/BeforeAfterAll + @ldap_server&.stop # rubocop:disable RSpec/InstanceVariable + end + + let(:ldap_auth_source) do + create(:ldap_auth_source, + port: ParallelHelper.port_for_ldap.to_s, + account: "uid=admin,ou=system", + account_password: "secret", + base_dn: "dc=example,dc=com", + attr_login: "uid", + attr_firstname: "givenName", + attr_lastname: "sn", + attr_mail: "mail", + attr_admin: "isAdmin") + end + + let(:base_dn) { "ou=org,dc=example,dc=com" } + let(:sync_users) { true } + let(:tree) { create(:ldap_synchronized_tree, ldap_auth_source:, base_dn:, sync_users:) } + + def department(name) + Group.organizational_units.find_by(lastname: name) + end + + def run_sync + LdapDepartments::SynchronizeTreeService.new(tree).call + LdapDepartments::SynchronizeMembersService.new(tree).call + end + + describe "the organizational unit structure" do + before { run_sync } + + it "mirrors the nested hierarchy below the base DN" do + expect(department("IT").parent_id).to be_nil + expect(department("Human Resources").parent_id).to be_nil + expect(department("Development").parent_id).to eq(department("IT").id) + expect(department("Recruiting").parent_id).to eq(department("Human Resources").id) + expect(department("Frontend").parent_id).to eq(department("Development").id) + expect(department("Backend").parent_id).to eq(department("Development").id) + end + + it "keeps the base DN out of the department tree" do + expect(Group.organizational_units.where(lastname: "org")).to be_empty + end + + it "creates both departments that share the name Support on different branches" do + supports = Group.organizational_units.where(lastname: "Support") + + expect(supports.count).to eq(2) + expect(supports.map(&:parent_id)) + .to contain_exactly(department("IT").id, department("Human Resources").id) + end + end + + describe "user assignment" do + before { run_sync } + + it "creates users and assigns them to their containing OU's department" do + jdoe = User.find_by(login: "jdoe") + bsmith = User.find_by(login: "bsmith") + hwest = User.find_by(login: "hwest") + + expect(jdoe).to be_present + expect(department("Frontend").users).to contain_exactly(jdoe) + expect(department("Backend").users).to contain_exactly(bsmith) + expect(department("Recruiting").users).to contain_exactly(hwest) + expect(department("Development").users).to be_empty + end + end + + describe "running the synchronization twice" do + it "is idempotent" do + run_sync + expect { run_sync }.not_to(change { Group.organizational_units.count }) + expect(LdapDepartments::Membership.count).to eq(3) + end + end + + context "with a deeper base DN" do + let(:base_dn) { "ou=IT,ou=org,dc=example,dc=com" } + + before { run_sync } + + it "only synchronizes OUs below that base and ignores other branches" do + expect(department("Development").parent_id).to be_nil + expect(department("Frontend").parent_id).to eq(department("Development").id) + expect(department("Human Resources")).to be_nil + expect(department("Recruiting")).to be_nil + end + end + + context "with sync_users disabled" do + let(:sync_users) { false } + + before { run_sync } + + it "builds the structure but creates no users" do + expect(department("Frontend")).to be_present + expect(User.where(login: %w[jdoe bsmith hwest])).to be_empty + expect(LdapDepartments::Membership.count).to eq(0) + end + end +end diff --git a/modules/ldap_departments/spec/managed_department_spec.rb b/modules/ldap_departments/spec/managed_department_spec.rb new file mode 100644 index 000000000000..17f4997373d4 --- /dev/null +++ b/modules/ldap_departments/spec/managed_department_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require_relative "spec_helper" + +RSpec.describe "LDAP-managed department locking", :aggregate_failures do # rubocop:disable RSpec/DescribeClass + let(:admin) { create(:admin) } + let(:department) { create(:department, lastname: "Engineering") } + + describe "Group#ldap_managed?" do + it "is false for an organizational unit without a mapping" do + expect(department.ldap_managed?).to be(false) + end + + it "is true for an organizational unit with a mapping" do + create(:ldap_synchronized_department, group: department) + + expect(department.reload.ldap_managed?).to be(true) + end + + it "is false for a regular group without querying the mapping" do + group = create(:group) + allow(group).to receive(:ldap_departments_synchronized_departments).and_call_original + + expect(group.ldap_managed?).to be(false) + expect(group).not_to have_received(:ldap_departments_synchronized_departments) + end + + it "is false for an unsaved group" do + expect(Group.new.ldap_managed?).to be(false) + end + end + + context "when the department is mapped from LDAP" do + before { create(:ldap_synchronized_department, group: department) } + + it "reports the department as managed" do + expect(department.reload.ldap_managed?).to be(true) + end + + it "rejects renaming by an admin" do + call = Groups::UpdateService.new(user: admin, model: department).call(name: "Renamed") + + expect(call).to be_failure + expect(department.reload.name).to eq("Engineering") + end + + it "rejects deletion by an admin" do + call = Groups::DeleteService.new(user: admin, model: department).call + + expect(call).to be_failure + expect(Group.exists?(department.id)).to be(true) + end + + it "allows the synchronization itself to change it" do + call = Groups::UpdateService + .new(user: User.system, model: department, contract_class: Groups::SyncUpdateContract) + .call(name: "Renamed") + + expect(call).to be_success + expect(department.reload.name).to eq("Renamed") + end + end + + context "when the department is not managed" do + it "allows an admin to rename it" do + call = Groups::UpdateService.new(user: admin, model: department).call(name: "Renamed") + + expect(call).to be_success + end + end + + describe "adding a child department under a managed parent" do + before { create(:ldap_synchronized_department, group: department) } + + it "is rejected for an admin" do + call = Groups::CreateService + .new(user: admin) + .call(name: "Child", organizational_unit: true, parent_id: department.id) + + expect(call).to be_failure + expect(call.errors.symbols_for(:parent_id)).to include(:parent_ldap_managed) + end + + it "is allowed for the synchronization" do + call = Groups::CreateService + .new(user: User.system, contract_class: Groups::SyncCreateContract) + .call(name: "Child", organizational_unit: true, parent_id: department.id) + + expect(call).to be_success + expect(call.result.parent_id).to eq(department.id) + end + end + + describe "moving a user out of a managed department" do + let(:managed) { create(:department, lastname: "Managed dept") } + let(:manual) { create(:department, lastname: "Manual dept") } + let(:user) { create(:user) } + let(:synced) { create(:ldap_synchronized_department, group: managed) } + + before { synced.add_members!([user]) } + + it "is rejected with a dedicated error and leaves the user in the managed department" do + call = Departments::AddUserService + .new(manual, user: admin) + .call(user_id: user.id, remove_from_previous_department: true) + + expect(call).to be_failure + expect(call.errors.symbols_for(:base)).to include(:user_in_ldap_managed_department) + expect(managed.reload.users).to include(user) + expect(manual.reload.users).not_to include(user) + end + end + + describe "sibling-scoped name uniqueness" do + let(:it_dep) { create(:department, lastname: "IT") } + let(:hr_dep) { create(:department, lastname: "HR") } + + it "allows the same name under different parents" do + first = build(:department, lastname: "Support", parent: it_dep) + second = build(:department, lastname: "Support", parent: hr_dep) + + expect(first).to be_valid + expect(second.tap(&:valid?)).to be_valid + expect { first.save! && second.save! }.not_to raise_error + end + + it "rejects the same name among siblings" do + create(:department, lastname: "Support", parent: it_dep) + duplicate = build(:department, lastname: "Support", parent: it_dep) + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:name]).to be_present + end + end +end diff --git a/modules/ldap_departments/spec/models/ldap_departments/synchronized_department_spec.rb b/modules/ldap_departments/spec/models/ldap_departments/synchronized_department_spec.rb new file mode 100644 index 000000000000..4345c597b2ad --- /dev/null +++ b/modules/ldap_departments/spec/models/ldap_departments/synchronized_department_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require_relative "../../spec_helper" + +RSpec.describe LdapDepartments::SynchronizedDepartment do + let(:user) { create(:user) } + let(:department) { create(:department, lastname: "Frontend") } + let(:tree) { create(:ldap_synchronized_tree) } + let!(:synchronized_department) do + create(:ldap_synchronized_department, synchronized_tree: tree, group: department) + end + + before { synchronized_department.add_members!([user]) } + + describe "destroying the mapping" do + it "keeps the department and its members, dropping only the tracking record" do + expect { synchronized_department.destroy } + .to change(LdapDepartments::Membership, :count).by(-1) + + expect(Group.exists?(department.id)).to be(true) + expect(department.reload.users).to include(user) + end + end + + describe "destroying the parent tree" do + it "unlinks its departments while keeping them and their members" do + expect { tree.destroy } + .to change(described_class, :count).by(-1) + .and change(LdapDepartments::Membership, :count).by(-1) + + expect(Group.exists?(department.id)).to be(true) + expect(department.reload.users).to include(user) + end + end +end diff --git a/modules/ldap_departments/spec/models/ldap_departments/synchronized_tree_spec.rb b/modules/ldap_departments/spec/models/ldap_departments/synchronized_tree_spec.rb new file mode 100644 index 000000000000..0c0be151f6ca --- /dev/null +++ b/modules/ldap_departments/spec/models/ldap_departments/synchronized_tree_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require_relative "../../spec_helper" + +RSpec.describe LdapDepartments::SynchronizedTree do + let(:ldap_auth_source) { create(:ldap_auth_source, base_dn: "dc=example,dc=com") } + + subject { build(:ldap_synchronized_tree, ldap_auth_source:, base_dn:) } + + context "with a base DN inside the auth source base" do + let(:base_dn) { "ou=IT,dc=example,dc=com" } + + it { is_expected.to be_valid } + end + + context "with a base DN equal to the auth source base" do + let(:base_dn) { "dc=example,dc=com" } + + it { is_expected.to be_valid } + end + + context "with a base DN outside the auth source base" do + let(:base_dn) { "ou=IT,dc=other,dc=com" } + + it "is invalid" do + expect(subject).not_to be_valid + expect(subject.errors[:base_dn]).to be_present + end + end + + context "with an invalid structure filter" do + let(:base_dn) { "dc=example,dc=com" } + + it "is invalid" do + subject.structure_filter_string = "(objectClass=" + expect(subject).not_to be_valid + expect(subject.errors[:structure_filter_string]).to be_present + end + end + + describe "overlap with sibling trees" do + let(:base_dn) { "ou=IT,dc=example,dc=com" } + + before { create(:ldap_synchronized_tree, ldap_auth_source:, base_dn: "ou=IT,dc=example,dc=com") } + + it "rejects an identical base" do + expect(subject).not_to be_valid + expect(subject.errors[:base_dn]).to be_present + end + + it "rejects a descendant base" do + subject.base_dn = "ou=Development,ou=IT,dc=example,dc=com" + expect(subject).not_to be_valid + end + + it "allows a disjoint base" do + subject.base_dn = "ou=HR,dc=example,dc=com" + expect(subject).to be_valid + end + end +end diff --git a/modules/ldap_departments/spec/requests/synchronized_departments_spec.rb b/modules/ldap_departments/spec/requests/synchronized_departments_spec.rb new file mode 100644 index 000000000000..9e6ae4927cb7 --- /dev/null +++ b/modules/ldap_departments/spec/requests/synchronized_departments_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +RSpec.describe "LDAP synchronized departments", :aggregate_failures, :skip_csrf, + type: :rails_request, with_ee: %i[ldap_groups] do + shared_let(:admin) { create(:admin) } + shared_let(:ldap_auth_source) { create(:ldap_auth_source, base_dn: "dc=example,dc=com") } + + let(:tree) { create(:ldap_synchronized_tree, ldap_auth_source:) } + let(:department) { create(:department, lastname: "Frontend") } + let!(:synced) { create(:ldap_synchronized_department, synchronized_tree: tree, group: department) } + + before { login_as(admin) } + + describe "GET /ldap_departments/synchronized_departments/:id/deletion_dialog" do + it "renders the danger dialog explaining the department is kept" do + get deletion_dialog_ldap_departments_synchronized_department_path(department_id: synced.id), as: :turbo_stream + + expect(response).to have_http_status(:ok) + expect(response.body).to include(I18n.t("ldap_departments.synchronized_departments.destroy.info")) + end + end + + describe "DELETE /ldap_departments/synchronized_departments/:id" do + it "unlinks the department but keeps it" do + expect { delete ldap_departments_synchronized_department_path(department_id: synced.id) } + .to change(LdapDepartments::SynchronizedDepartment, :count).by(-1) + + expect(Group.exists?(department.id)).to be(true) + end + end +end diff --git a/modules/ldap_departments/spec/requests/synchronized_trees_spec.rb b/modules/ldap_departments/spec/requests/synchronized_trees_spec.rb new file mode 100644 index 000000000000..f1311009ffaa --- /dev/null +++ b/modules/ldap_departments/spec/requests/synchronized_trees_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +RSpec.describe "LDAP department synchronized trees", :aggregate_failures, :skip_csrf, + type: :rails_request, with_ee: %i[ldap_groups] do + shared_let(:admin) { create(:admin) } + shared_let(:ldap_auth_source) { create(:ldap_auth_source, base_dn: "dc=example,dc=com") } + + before { login_as(admin) } + + describe "GET /ldap_departments/synchronized_trees" do + it "renders the empty index" do + get ldap_departments_synchronized_trees_path + + expect(response).to have_http_status(:ok) + end + + it "renders the index with existing trees" do + create(:ldap_synchronized_tree, ldap_auth_source:) + + get ldap_departments_synchronized_trees_path + + expect(response).to have_http_status(:ok) + end + end + + describe "GET /ldap_departments/synchronized_trees/:id" do + let(:tree) { create(:ldap_synchronized_tree, ldap_auth_source:) } + + it "renders the tree with its synchronized departments" do + create(:ldap_synchronized_department, synchronized_tree: tree) + + get ldap_departments_synchronized_tree_path(tree_id: tree.id) + + expect(response).to have_http_status(:ok) + end + end + + describe "POST /ldap_departments/synchronized_trees" do + let(:params) do + { + synchronized_tree: { + name: "IT directory", + ldap_auth_source_id: ldap_auth_source.id, + base_dn: "ou=IT,dc=example,dc=com", + structure_filter_string: "(objectClass=organizationalUnit)", + ou_name_attribute: "ou", + sync_users: "1" + } + } + end + + it "creates a synchronized tree and starts the background synchronization" do + allow(LdapDepartments::SynchronizeTreeJob).to receive(:perform_later) + + expect { post ldap_departments_synchronized_trees_path, params: } + .to change(LdapDepartments::SynchronizedTree, :count).by(1) + + tree = LdapDepartments::SynchronizedTree.last + expect(response).to redirect_to(ldap_departments_synchronized_tree_path(tree_id: tree.id)) + expect(tree.base_dn).to eq("ou=IT,dc=example,dc=com") + expect(LdapDepartments::SynchronizeTreeJob).to have_received(:perform_later).with(tree) + end + + it "rejects an out-of-base DN" do + params[:synchronized_tree][:base_dn] = "ou=IT,dc=other,dc=com" + + expect { post ldap_departments_synchronized_trees_path, params: } + .not_to change(LdapDepartments::SynchronizedTree, :count) + expect(response).to have_http_status(:unprocessable_entity) + end + end + + describe "GET /ldap_departments/synchronized_trees/:id/deletion_dialog" do + let!(:tree) { create(:ldap_synchronized_tree, ldap_auth_source:, name: "IT directory") } + + it "renders the danger confirmation dialog explaining departments are kept" do + get deletion_dialog_ldap_departments_synchronized_tree_path(tree_id: tree.id), as: :turbo_stream + + expect(response).to have_http_status(:ok) + expect(response.body).to include(I18n.t("ldap_departments.synchronized_trees.destroy.info")) + end + end + + describe "DELETE /ldap_departments/synchronized_trees/:id" do + let!(:tree) { create(:ldap_synchronized_tree, ldap_auth_source:) } + + it "removes the tree" do + expect { delete ldap_departments_synchronized_tree_path(tree_id: tree.id) } + .to change(LdapDepartments::SynchronizedTree, :count).by(-1) + end + end + + describe "POST /ldap_departments/synchronized_trees/:id/synchronize" do + let!(:tree) { create(:ldap_synchronized_tree, ldap_auth_source:) } + + it "enqueues a background synchronization and redirects" do + allow(LdapDepartments::SynchronizeTreeJob).to receive(:perform_later) + + post synchronize_ldap_departments_synchronized_tree_path(tree_id: tree.id) + + expect(LdapDepartments::SynchronizeTreeJob).to have_received(:perform_later).with(tree) + expect(response).to redirect_to(ldap_departments_synchronized_tree_path(tree_id: tree.id)) + end + end + + context "without the enterprise feature" do + before do + allow(EnterpriseToken).to receive(:allows_to?).and_call_original + allow(EnterpriseToken).to receive(:allows_to?).with(:ldap_groups).and_return(false) + end + + it "redirects away from the new form" do + get new_ldap_departments_synchronized_tree_path + + expect(response).to have_http_status(:see_other) + end + + # An instance that lost its enterprise token must still be able to clean up trees it set up. + it "still allows deleting a tree" do + tree = create(:ldap_synchronized_tree, ldap_auth_source:) + + expect { delete ldap_departments_synchronized_tree_path(tree_id: tree.id) } + .to change(LdapDepartments::SynchronizedTree, :count).by(-1) + end + + it "still renders the deletion dialog" do + tree = create(:ldap_synchronized_tree, ldap_auth_source:) + + get deletion_dialog_ldap_departments_synchronized_tree_path(tree_id: tree.id), as: :turbo_stream + + expect(response).to have_http_status(:ok) + end + end +end diff --git a/modules/ldap_departments/spec/services/ldap_departments/synchronize_members_service_spec.rb b/modules/ldap_departments/spec/services/ldap_departments/synchronize_members_service_spec.rb new file mode 100644 index 000000000000..e24f701561ae --- /dev/null +++ b/modules/ldap_departments/spec/services/ldap_departments/synchronize_members_service_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require_relative "../../spec_helper" + +RSpec.describe LdapDepartments::SynchronizeMembersService do + subject(:service_call) { described_class.new(tree).call } + + let(:ldap_auth_source) do + create(:ldap_auth_source, + base_dn: "dc=example,dc=com", + attr_login: "uid", + attr_firstname: "givenName", + attr_lastname: "sn", + attr_mail: "mail") + end + let(:sync_users) { false } + let(:tree) { create(:ldap_synchronized_tree, ldap_auth_source:, base_dn: "dc=example,dc=com", sync_users:) } + + let(:frontend) { create(:department, lastname: "Frontend") } + let(:backend) { create(:department, lastname: "Backend") } + let!(:frontend_sync) do + create(:ldap_synchronized_department, + synchronized_tree: tree, group: frontend, + dn: "ou=Frontend,ou=IT,dc=example,dc=com") + end + let!(:backend_sync) do + create(:ldap_synchronized_department, + synchronized_tree: tree, group: backend, + dn: "ou=Backend,ou=IT,dc=example,dc=com") + end + + let(:user_entries) { [] } + + def user_entry(dn_value, uid) + entry = Net::LDAP::Entry.new(dn_value) + entry[:objectClass] = ["person"] + entry[:uid] = [uid] + entry[:givenName] = ["Given"] + entry[:sn] = ["Surname"] + entry[:mail] = ["#{uid}@example.com"] + entry + end + + before do + connection = instance_double(Net::LDAP) + allow(ldap_auth_source).to receive(:with_connection).and_yield(connection) + allow(connection).to receive(:search) { |**_opts, &block| user_entries.each(&block) } + allow(tree).to receive(:ldap_auth_source).and_return(ldap_auth_source) + end + + context "with an existing user inside an OU" do + let!(:user) { create(:user, login: "jdoe", ldap_auth_source:) } + + before { user_entries << user_entry("cn=John,ou=Frontend,ou=IT,dc=example,dc=com", "jdoe") } + + it "assigns the user to the matching department only" do + expect(service_call).to be_success + + expect(frontend.reload.users).to include(user) + expect(backend.reload.users).not_to include(user) + expect(LdapDepartments::Membership.where(synchronized_department: frontend_sync, user:)).to exist + end + end + + context "when the user moves to another OU" do + let!(:user) { create(:user, login: "jdoe", ldap_auth_source:) } + + before do + user_entries << user_entry("cn=John,ou=Backend,ou=IT,dc=example,dc=com", "jdoe") + described_class.new(tree).call + user_entries.replace([user_entry("cn=John,ou=Frontend,ou=IT,dc=example,dc=com", "jdoe")]) + end + + it "moves the user out of the previous department" do + expect(service_call).to be_success + + expect(frontend.reload.users).to include(user) + expect(backend.reload.users).not_to include(user) + expect(LdapDepartments::Membership.where(user:).count).to eq(1) + end + end + + context "when a user leaves the directory" do + let!(:user) { create(:user, login: "jdoe", ldap_auth_source:) } + + before do + user_entries << user_entry("cn=John,ou=Frontend,ou=IT,dc=example,dc=com", "jdoe") + described_class.new(tree).call + user_entries.clear + end + + it "removes the synchronized membership" do + expect(service_call).to be_success + + expect(frontend.reload.users).not_to include(user) + expect(LdapDepartments::Membership.where(user:)).not_to exist + end + end + + context "with sync_users enabled and a missing account" do + let(:sync_users) { true } + + before { user_entries << user_entry("cn=New,ou=Frontend,ou=IT,dc=example,dc=com", "newbie") } + + it "creates the user and assigns them" do + expect { service_call }.to change { User.where(login: "newbie").count }.from(0).to(1) + + expect(frontend.reload.user_ids).to include(User.find_by(login: "newbie").id) + end + end + + context "with a user directly under the base DN" do + let(:sync_users) { false } + let!(:user) { create(:user, login: "jdoe", ldap_auth_source:) } + + before { user_entries << user_entry("cn=John,dc=example,dc=com", "jdoe") } + + it "leaves the user unassigned" do + service_call + + expect(LdapDepartments::Membership.where(user:)).not_to exist + end + end +end diff --git a/modules/ldap_departments/spec/services/ldap_departments/synchronize_tree_service_spec.rb b/modules/ldap_departments/spec/services/ldap_departments/synchronize_tree_service_spec.rb new file mode 100644 index 000000000000..66c5d7888cf5 --- /dev/null +++ b/modules/ldap_departments/spec/services/ldap_departments/synchronize_tree_service_spec.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require_relative "../../spec_helper" + +RSpec.describe LdapDepartments::SynchronizeTreeService do + subject(:service_call) { described_class.new(tree).call } + + let(:ldap_auth_source) { create(:ldap_auth_source, base_dn: "dc=example,dc=com", attr_login: "uid") } + let(:base_dn) { "dc=example,dc=com" } + let(:guid_attribute) { nil } + let(:tree) { create(:ldap_synchronized_tree, ldap_auth_source:, base_dn:, guid_attribute:) } + + # OUs returned by the directory. The base DN entry is intentionally included to verify it is skipped. + let(:ou_entries) do + [ + ou_entry("dc=example,dc=com", nil), + ou_entry("ou=IT,dc=example,dc=com", "IT"), + ou_entry("ou=Development,ou=IT,dc=example,dc=com", "Development"), + ou_entry("ou=Frontend,ou=Development,ou=IT,dc=example,dc=com", "Frontend"), + ou_entry("ou=Human Resources,dc=example,dc=com", "Human Resources"), + ou_entry("ou=Support,ou=IT,dc=example,dc=com", "Support"), + ou_entry("ou=Support,ou=Human Resources,dc=example,dc=com", "Support") + ] + end + + def ou_entry(dn_value, ou_value, guid: nil) + entry = Net::LDAP::Entry.new(dn_value) + entry[:objectClass] = ["organizationalUnit"] + entry[:ou] = [ou_value] if ou_value + entry[:objectGUID] = [guid] if guid + entry + end + + before do + connection = instance_double(Net::LDAP) + allow(ldap_auth_source).to receive(:with_connection).and_yield(connection) + allow(connection).to receive(:search) do |**opts, &block| + base = LdapDepartments::Dn.normalize(opts[:base]) + ou_entries + .select { |entry| LdapDepartments::Dn.normalize(entry.dn).end_with?(base) } + .each(&block) + end + allow(tree).to receive(:ldap_auth_source).and_return(ldap_auth_source) + end + + def department(name) + Group.organizational_units.find_by(lastname: name) + end + + it "mirrors the full OU hierarchy into departments" do + expect(service_call).to be_success + + it_department = department("IT") + development = department("Development") + frontend = department("Frontend") + + expect(it_department).to be_present + expect(it_department.parent_id).to be_nil + expect(development.parent_id).to eq(it_department.id) + expect(frontend.parent_id).to eq(development.id) + expect(department("Human Resources").parent_id).to be_nil + end + + it "allows the same OU name on different branches" do + service_call + + supports = Group.organizational_units.where(lastname: "Support") + expect(supports.count).to eq(2) + expect(supports.map(&:parent_id)).to contain_exactly(department("IT").id, department("Human Resources").id) + end + + it "does not create a department for the base DN itself" do + service_call + + expect(Group.organizational_units.where(lastname: "example")).to be_empty + expect(LdapDepartments::SynchronizedDepartment.where(dn: "dc=example,dc=com")).to be_empty + end + + context "with a base DN deeper in the tree" do + let(:base_dn) { "ou=IT,dc=example,dc=com" } + + it "only synchronizes OUs below that base" do + service_call + + expect(department("Development").parent_id).to be_nil + expect(department("Frontend").parent_id).to eq(department("Development").id) + expect(department("Human Resources")).to be_nil + end + end + + describe "removal of an OU" do + let!(:existing) do + service_call + department("Frontend") + end + + it "keeps the department but drops the mapping when the OU disappears" do + ou_entries.reject! { |entry| entry.dn.start_with?("ou=Frontend") } + + described_class.new(tree).call + + expect(department("Frontend")).to be_present + expect(LdapDepartments::SynchronizedDepartment.where(dn: "ou=Frontend,ou=Development,ou=IT,dc=example,dc=com")) + .to be_empty + end + end + + describe "stable identity via GUID" do + let(:guid_attribute) { "objectGUID" } + let(:ou_entries) do + [ou_entry("ou=IT,dc=example,dc=com", "IT", guid: "guid-it")] + end + + it "updates the same department when the OU is renamed" do + service_call + original = department("IT") + + ou_entries.replace([ou_entry("ou=Information,dc=example,dc=com", "Information", guid: "guid-it")]) + described_class.new(tree).call + + expect(department("Information")&.id).to eq(original.id) + expect(LdapDepartments::SynchronizedDepartment.find_by(ldap_entry_uuid: "guid-it").dn) + .to eq("ou=Information,dc=example,dc=com") + end + end +end diff --git a/modules/ldap_departments/spec/services/principals/delete_job_integration_spec.rb b/modules/ldap_departments/spec/services/principals/delete_job_integration_spec.rb new file mode 100644 index 000000000000..0f518de4e13f --- /dev/null +++ b/modules/ldap_departments/spec/services/principals/delete_job_integration_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require_relative "../../spec_helper" + +RSpec.describe Principals::DeleteJob, "LDAP departments", type: :model do + subject(:job) { described_class.perform_now(user) } + + shared_let(:deleted_user) { create(:deleted_user) } + + let(:department) { create(:department, lastname: "Frontend") } + let(:synchronized_department) { create(:ldap_synchronized_department, group: department) } + let(:user) { create(:user) } + + before do + synchronized_department.add_members!([user]) + end + + it "can delete a user that is a synchronized department member" do + expect(LdapDepartments::Membership.where(user:)).to exist + + expect { job }.to change(LdapDepartments::Membership, :count).by(-1) + + expect(User.exists?(user.id)).to be(false) + expect(LdapDepartments::Membership.where(user_id: user.id)).not_to exist + end + + it "keeps the synchronized department itself" do + job + + expect(Group.exists?(department.id)).to be(true) + expect(LdapDepartments::SynchronizedDepartment.exists?(synchronized_department.id)).to be(true) + end +end diff --git a/modules/ldap_departments/spec/spec_helper.rb b/modules/ldap_departments/spec/spec_helper.rb new file mode 100644 index 000000000000..6ef4f13b31bd --- /dev/null +++ b/modules/ldap_departments/spec/spec_helper.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require "spec_helper" diff --git a/modules/ldap_departments/spec/workers/ldap_departments/synchronize_tree_job_spec.rb b/modules/ldap_departments/spec/workers/ldap_departments/synchronize_tree_job_spec.rb new file mode 100644 index 000000000000..e4d636142f21 --- /dev/null +++ b/modules/ldap_departments/spec/workers/ldap_departments/synchronize_tree_job_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require_relative "../../spec_helper" + +RSpec.describe LdapDepartments::SynchronizeTreeJob, type: :job do + let(:tree) { create(:ldap_synchronized_tree) } + + before { allow(LdapDepartments::SynchronizationService).to receive(:synchronize_tree!) } + + context "with the enterprise feature", with_ee: %i[ldap_groups] do + it "synchronizes the given tree" do + described_class.perform_now(tree) + + expect(LdapDepartments::SynchronizationService).to have_received(:synchronize_tree!).with(tree) + end + end + + context "without the enterprise feature" do + it "does nothing" do + described_class.perform_now(tree) + + expect(LdapDepartments::SynchronizationService).not_to have_received(:synchronize_tree!) + end + end +end diff --git a/modules/ldap_groups/lib/tasks/ldap_groups.rake b/modules/ldap_groups/lib/tasks/ldap_groups.rake index 172177a895d4..ce3c3130d4ae 100644 --- a/modules/ldap_groups/lib/tasks/ldap_groups.rake +++ b/modules/ldap_groups/lib/tasks/ldap_groups.rake @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -86,11 +88,32 @@ namespace :ldap_groups do filter.save! + puts "Set up group synchronization filter 'All groups' and synchronizing LDAP groups..." LdapGroups::SynchronizationJob.perform_now + puts " → Synchronized #{LdapGroups::SynchronizedGroup.count} group(s)." + + # The fixture also ships a nested organizational unit tree below ou=org, which is mirrored + # into departments so the development server demonstrates department synchronization too. + tree = LdapDepartments::SynchronizedTree.find_or_initialize_by(ldap_auth_source: source, name: "Organization") + tree.base_dn = "ou=org,dc=example,dc=com" + tree.structure_filter_string = "(objectClass=organizationalUnit)" + tree.ou_name_attribute = "ou" + tree.sync_users = true + tree.save! + + puts "Set up department synchronization 'Organization' and synchronizing organizational units..." + LdapDepartments::SynchronizationService.synchronize! + puts " → Synchronized #{LdapDepartments::SynchronizedDepartment.count} department(s)." puts <<~INFO LDAP server ready at localhost:12389 + Synchronizations configured: + - Group synchronization via filter "All groups" (base ou=groups) + - Department synchronization via tree "Organization" (base ou=org) + + -------------------------------------------------------- + Connection details Host: localhost @@ -130,6 +153,12 @@ namespace :ldap_groups do uid=cc414,ou=people,dc=example,dc=com (Password: retneprac) uid=bölle,ou=people,dc=example,dc=com (Password: bólle) + Department members (below ou=org): + + uid=jdoe,ou=Frontend,ou=Development,ou=IT,ou=org,dc=example,dc=com (Password: john) + uid=bsmith,ou=Backend,ou=Development,ou=IT,ou=org,dc=example,dc=com (Password: bob) + uid=hwest,ou=Recruiting,ou=Human Resources,ou=org,dc=example,dc=com (Password: helen) + -------------------------------------------------------- Groups @@ -137,6 +166,22 @@ namespace :ldap_groups do cn=foo,ou=groups,dc=example,dc=com (Members: aa729) cn=bar,ou=groups,dc=example,dc=com (Members: aa729, bb459, cc414) + -------------------------------------------------------- + + Organizational units / Departments (synced from ou=org,dc=example,dc=com) + + IT + Development + Frontend (member: jdoe) + Backend (member: bsmith) + Support + Human Resources + Recruiting (member: hwest) + Support + + Manage under Administration > Authentication > LDAP department synchronization. + Departments appear under Administration > Departments. + INFO puts "Send CTRL+D to stop the server" diff --git a/spec/fixtures/ldap/users.ldif b/spec/fixtures/ldap/users.ldif index 1daaeed36865..551181eb8f7e 100644 --- a/spec/fixtures/ldap/users.ldif +++ b/spec/fixtures/ldap/users.ldif @@ -196,3 +196,100 @@ uid: bölle samAccountName: bölle # Password is "bólle" userpassword:: e1NIQX1rNDBGWHRYQ3RFL3l2cENhblRpQmZ2cE1ON1k9Cg== + +######################################################### +# Organizational unit tree for department synchronization +# ou=org +# ou=IT +# ou=Development +# ou=Frontend (uid=jdoe) +# ou=Backend (uid=bsmith) +# ou=Support +# ou=Human Resources +# ou=Recruiting (uid=hwest) +# ou=Support (duplicate name on a different branch) +######################################################### + +dn: ou=org,dc=example,dc=com +objectClass: organizationalUnit +objectClass: top +ou: org + +dn: ou=IT,ou=org,dc=example,dc=com +objectClass: organizationalUnit +objectClass: top +ou: IT + +dn: ou=Development,ou=IT,ou=org,dc=example,dc=com +objectClass: organizationalUnit +objectClass: top +ou: Development + +dn: ou=Frontend,ou=Development,ou=IT,ou=org,dc=example,dc=com +objectClass: organizationalUnit +objectClass: top +ou: Frontend + +dn: uid=jdoe,ou=Frontend,ou=Development,ou=IT,ou=org,dc=example,dc=com +objectClass: inetOrgPerson +objectClass: organizationalPerson +objectClass: person +objectClass: top +cn: John Doe +sn: Doe +givenName: John +mail: john.doe@example.org +uid: jdoe +# Password is "john" +userpassword:: e1NIQX1wUjNhZkgvMUMySHE2Z1JFTng5S2FwTUI1UUU9 + +dn: ou=Backend,ou=Development,ou=IT,ou=org,dc=example,dc=com +objectClass: organizationalUnit +objectClass: top +ou: Backend + +dn: uid=bsmith,ou=Backend,ou=Development,ou=IT,ou=org,dc=example,dc=com +objectClass: inetOrgPerson +objectClass: organizationalPerson +objectClass: person +objectClass: top +cn: Bob Smith +sn: Smith +givenName: Bob +mail: bob.smith@example.org +uid: bsmith +# Password is "bob" +userpassword:: e1NIQX1TQmdhelNLejdhNjhpa1I0YUtmZmZPWXBrZ289 + +dn: ou=Support,ou=IT,ou=org,dc=example,dc=com +objectClass: organizationalUnit +objectClass: top +ou: Support + +dn: ou=Human Resources,ou=org,dc=example,dc=com +objectClass: organizationalUnit +objectClass: top +ou: Human Resources + +dn: ou=Recruiting,ou=Human Resources,ou=org,dc=example,dc=com +objectClass: organizationalUnit +objectClass: top +ou: Recruiting + +dn: uid=hwest,ou=Recruiting,ou=Human Resources,ou=org,dc=example,dc=com +objectClass: inetOrgPerson +objectClass: organizationalPerson +objectClass: person +objectClass: top +cn: Helen West +sn: West +givenName: Helen +mail: helen.west@example.org +uid: hwest +# Password is "helen" +userpassword:: e1NIQX1aR25LNHZWVDB3UzUvZnhjQ1A1b2o0T2c3WGs9 + +dn: ou=Support,ou=Human Resources,ou=org,dc=example,dc=com +objectClass: organizationalUnit +objectClass: top +ou: Support diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 1233f13fc562..5a0003e0f11d 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -212,6 +212,15 @@ let!(:grandchild) { create(:group, parent_id: child.id) } let!(:unrelated) { create(:group) } + describe "#destroy" do + it "nullifies the parent of its children instead of failing on the foreign key" do + expect { parent_group.destroy }.not_to raise_error + + expect(described_class.exists?(parent_group.id)).to be(false) + expect(child.reload.parent_id).to be_nil + end + end + describe "#children" do it "returns direct children only" do expect(grandparent.children).to contain_exactly(parent_group)