From 76386c79edb32a1ce921ffa172e3a9e737924b54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 2 Oct 2023 20:17:48 +0200 Subject: [PATCH 01/80] SAML Ui --- .../saml/providers/row_component.rb | 40 +++++ .../saml/providers/table_component.rb | 25 +++ .../controllers/saml/providers_controller.rb | 85 +++++++++++ modules/auth_saml/app/models/saml/provider.rb | 144 ++++++++++++++++++ .../app/views/saml/providers/_form.html.erb | 110 +++++++++++++ .../app/views/saml/providers/edit.html.erb | 19 +++ .../app/views/saml/providers/index.html.erb | 18 +++ .../app/views/saml/providers/new.html.erb | 15 ++ .../app/views/saml/providers/upsale.html.erb | 9 ++ modules/auth_saml/config/locales/en.yml | 33 ++++ modules/auth_saml/config/routes.rb | 7 + .../lib/open_project/auth_saml/engine.rb | 25 ++- 12 files changed, 524 insertions(+), 6 deletions(-) create mode 100644 modules/auth_saml/app/components/saml/providers/row_component.rb create mode 100644 modules/auth_saml/app/components/saml/providers/table_component.rb create mode 100644 modules/auth_saml/app/controllers/saml/providers_controller.rb create mode 100644 modules/auth_saml/app/models/saml/provider.rb create mode 100644 modules/auth_saml/app/views/saml/providers/_form.html.erb create mode 100644 modules/auth_saml/app/views/saml/providers/edit.html.erb create mode 100644 modules/auth_saml/app/views/saml/providers/index.html.erb create mode 100644 modules/auth_saml/app/views/saml/providers/new.html.erb create mode 100644 modules/auth_saml/app/views/saml/providers/upsale.html.erb create mode 100644 modules/auth_saml/config/locales/en.yml create mode 100644 modules/auth_saml/config/routes.rb diff --git a/modules/auth_saml/app/components/saml/providers/row_component.rb b/modules/auth_saml/app/components/saml/providers/row_component.rb new file mode 100644 index 000000000000..5fc382a8e1b1 --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/row_component.rb @@ -0,0 +1,40 @@ +module Saml + module Providers + class RowComponent < ::RowComponent + def provider + model + end + + def name + link_to( + provider.display_name || provider.name, + url_for(action: :edit, id: provider.id) + ) + end + + def button_links + [edit_link, delete_link].compact + end + + def edit_link + link_to( + helpers.op_icon('icon icon-edit button--link'), + url_for(action: :edit, id: provider.id), + title: t(:button_edit) + ) + end + + def delete_link + return if provider.readonly + + link_to( + helpers.op_icon('icon icon-delete button--link'), + url_for(action: :destroy, id: provider.id), + method: :delete, + data: { confirm: I18n.t(:text_are_you_sure) }, + title: t(:button_delete) + ) + end + end + end +end diff --git a/modules/auth_saml/app/components/saml/providers/table_component.rb b/modules/auth_saml/app/components/saml/providers/table_component.rb new file mode 100644 index 000000000000..9cb15f87c876 --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/table_component.rb @@ -0,0 +1,25 @@ +module Saml + module Providers + class TableComponent < ::TableComponent + columns :name + + def initial_sort + %i[id asc] + end + + def sortable? + false + end + + def empty_row_message + I18n.t 'saml.providers.no_results_table' + end + + def headers + [ + ['name', { caption: I18n.t('attributes.name') }] + ] + end + end + end +end diff --git a/modules/auth_saml/app/controllers/saml/providers_controller.rb b/modules/auth_saml/app/controllers/saml/providers_controller.rb new file mode 100644 index 000000000000..9dd7ed85118d --- /dev/null +++ b/modules/auth_saml/app/controllers/saml/providers_controller.rb @@ -0,0 +1,85 @@ +module Saml + class ProvidersController < ::ApplicationController + layout 'admin' + menu_item :plugin_saml + + before_action :require_admin + before_action :check_ee + before_action :find_provider, only: %i[edit update destroy] + + def index; end + + def new + @provider = ::Saml::Provider.new(defaults) + end + + def create + @provider = ::Saml::Provider.new(create_params) + + if @provider.save + flash[:notice] = I18n.t(:notice_successful_create) + redirect_to action: :index + else + render action: :new + end + end + + def edit; end + + def update + @provider = ::OpenIDConnect::Provider.initialize_with( + update_params.merge("name" => params[:id]) + ) + if @provider.save + flash[:notice] = I18n.t(:notice_successful_update) + redirect_to action: :index + else + render action: :edit + end + end + + def destroy + if @provider.destroy + flash[:notice] = I18n.t(:notice_successful_delete) + else + flash[:error] = I18n.t(:error_failed_to_delete_entry) + end + + redirect_to action: :index + end + + private + + def defaults + { + } + end + + def check_ee + unless EnterpriseToken.allows_to?(:openid_providers) + render template: '/saml/providers/upsale' + false + end + end + + def create_params + params.require(:openid_connect_provider).permit(:name, :display_name, :identifier, :secret, :limit_self_registration) + end + + def update_params + params.require(:openid_connect_provider).permit(:display_name, :identifier, :secret, :limit_self_registration) + end + + def find_provider + @provider = providers.find { |provider| provider.id.to_s == params[:id].to_s } + if @provider.nil? + render_404 + end + end + + def providers + @providers ||= OpenProject::AuthSaml.providers + end + helper_method :providers + end +end diff --git a/modules/auth_saml/app/models/saml/provider.rb b/modules/auth_saml/app/models/saml/provider.rb new file mode 100644 index 000000000000..e7e75bf7f7d4 --- /dev/null +++ b/modules/auth_saml/app/models/saml/provider.rb @@ -0,0 +1,144 @@ +module Saml + class Provider < OpenStruct + DEFAULT_NAME_IDENTIFIER_FORMAT = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'.freeze + + include ActiveModel::Validations + include ActiveModel::Conversion + + attr_accessor :name, :readonly + validates_presence_of :name, :display_name + + def initialize(name, attributes = {}, readonly: false) + self.name = name + self.readonly = readonly + super(attributes) + + set_default_attributes(attributes) + end + + def id + name + end + + def sp_entity_id + super.presence || issuer.presence || url_helpers.root_url + end + + def attribute_statements + super.presence || {} + end + + %w[login email first_name last_name].each do |accessor| + define_method("#{accessor}_mapping") do + value = attribute_statements[accessor] + warn value + if value.is_a?(Array) + value.join(', ') + else + value + end + end + + define_method("#{accessor}_mapping=") do |newval| + parsed = newval.split(/\s*,\s*/) + attribute_statements[accessor] = parsed + end + end + + def new_record? + !persisted? + end + + def persisted? + uuid.present? + end + + def limit_self_registration? + limit_self_registration + end + + def save + return false unless valid? + + Setting.plugin_openproject_auth_saml = setting_with_provider + + true + end + + def destroy + Setting.plugin_openproject_auth_saml = setting_without_provider + + true + end + + def setting_with_provider + setting.deep_merge "providers" => { name => to_h.stringify_keys } + end + + def to_h + super + .reverse_merge(uuid: SecureRandom.uuid) + .merge(derived_attributes) + end + + def setting_without_provider + setting.tap do |s| + s["providers"].delete_if { |_, config| config['id'] == id } + end + end + + def setting + Hash(Setting.plugin_openproject_auth_saml).tap do |h| + h["providers"] ||= Hash.new + end + end + + private + + def set_default_attributes(attributes) + self.limit_self_registration = attributes.fetch(:limit_self_registration, false) + self.name_identifier_format = attributes.fetch(:name_identifier_format, DEFAULT_NAME_IDENTIFIER_FORMAT) + self.issuer = attributes.fetch(:issuer, url_helpers.root_url) + self.request_attributes = attributes.fetch(:request_attributes, default_request_attributes) + end + + def default_request_attributes + [ + { + "name" => "mail", + "name_format" => "urn:oasis:names:tc:SAML:2.0:attrname-format:basic", + "friendly_name" => "Email address", + "is_required" => true + }, + { + "name" => "givenName", + "name_format" => "urn:oasis:names:tc:SAML:2.0:attrname-format:basic", + "friendly_name" => "Given name", + "is_required" => true + }, + { + "name" => "sn", + "name_format" => "urn:oasis:names:tc:SAML:2.0:attrname-format:basic", + "friendly_name" => "Family name", + "is_required" => true + }, + { + "name" => "uid", + "name_format" => "urn:oasis:names:tc:SAML:2.0:attrname-format:basic", + "friendly_name" => "Stable unique ID / login of the user", + "is_required" => true + } + ] + end + + def derived_attributes + { + assertion_consumer_service_url: url_helpers.root_url.join("/auth/#{name}/callback") + } + end + + def url_helpers + @url_helpers ||= OpenProject::StaticRouting::StaticUrlHelpers.new + end + end +end diff --git a/modules/auth_saml/app/views/saml/providers/_form.html.erb b/modules/auth_saml/app/views/saml/providers/_form.html.erb new file mode 100644 index 000000000000..296d0d79bb20 --- /dev/null +++ b/modules/auth_saml/app/views/saml/providers/_form.html.erb @@ -0,0 +1,110 @@ +
+
+ <%= f.text_field :name, required: true, disable: f.object.persisted?, container_class: '-middle' %> +
+ +
+ <%= f.text_field :display_name, required: true, container_class: '-middle' %> +
+ +
+ <%= f.text_field :sp_entity_id, required: false, container_class: '-middle' %> +
+ +
+ <%= f.text_field :idp_sso_target_url, required: true, container_class: '-xwide' %> +
+ +
+ <%= f.text_field :idp_slo_target_url, required: false, container_class: '-xwide' %> +
+ +
+ <%= f.text_field :idp_cert_fingerprint, + required: false, + container_class: '-xwide' %> +
+ +
+ <%= f.text_area :idp_cert, required: false, rows: 10, columns: 20, container_class: '-xwide' %> +
+ +
+ <%= f.check_box :limit_self_registration, required: false, container_class: '-middle' %> +
+ <%= I18n.t('openid_connect.setting_instructions.limit_self_registration') %> +
+
+ +
+ <%= f.text_field :name_identifier_format, required: false, container_class: '-xwide' %> +
+
+ +
+ <%= t('saml.providers.attribute_mapping') %> +

<%= t 'saml.providers.attribute_mapping_text' %>

+ +
+ <%= f.text_field 'login_mapping', + required: true, + size: 20, + container_class: '-middle' %> + + <%= t('ldap_auth_sources.attribute_texts.login_map') %> + +
+
+ <%= f.text_field 'first_name_mapping', + size: 20, + container_class: '-middle' %> + + <%= t('ldap_auth_sources.attribute_texts.generic_map', attribute: ApplicationRecord.human_attribute_name(:firstname)) %> + +
+
+ <%= f.text_field 'last_name_mapping', + size: 20, + container_class: '-middle' %> + + <%= t('ldap_auth_sources.attribute_texts.generic_map', attribute: ApplicationRecord.human_attribute_name(:lastname)) %> + +
+
+ <%= f.text_field 'email_mapping', + size: 20, + placeholder: 'mail', + container_class: '-middle' %> + + <%= t('ldap_auth_sources.attribute_texts.generic_map', attribute: ApplicationRecord.human_attribute_name(:mail)) %> + +
+
+ +
+ <%= t('saml.providers.request_attributes.title') %> +

<%= t 'saml.providers.request_attributes.legend' %>

+ + <% @provider.request_attributes.each do |request| %> + <% base_key = "saml_request_#{request['name']}" %> +
+ <%= styled_label_tag "#{base_key}_name", t('saml.providers.requested_attributes.name') %> +
+ <%= text_field_tag 'saml_provider[request_attributes][][name]', + request['name'], + id: "#{base_key}_name" + %> +
+
+
+ <%= styled_label_tag "#{base_key}_format", t('saml.providers.requested_attributes.format') %> +
+ <%= text_field_tag 'saml_provider[request_attributes][][name_format]', + request['name_format'], + id: "#{base_key}_format" + %> +
+
+
+ <% end %> +
diff --git a/modules/auth_saml/app/views/saml/providers/edit.html.erb b/modules/auth_saml/app/views/saml/providers/edit.html.erb new file mode 100644 index 000000000000..25d092111a53 --- /dev/null +++ b/modules/auth_saml/app/views/saml/providers/edit.html.erb @@ -0,0 +1,19 @@ +<% page_title = t('saml.providers.label_edit', name: @provider.name) %> +<% local_assigns[:additional_breadcrumb] = @provider.name %> + +<% html_title(t(:label_administration), page_title) -%> + +<%= toolbar title: page_title %> + +<%= error_messages_for @provider %> + +<%= @provider.assertion_consumer_service_url %> + +<%= labelled_tabular_form_for @provider, + html: { class: 'form', autocomplete: 'off' } do |f| %> + <%= render partial: "form", locals: { f: f } %> +

+ <%= styled_button_tag t(:button_save), class: '-highlight -with-icon icon-checkmark' %> + <%= link_to t(:button_cancel), { action: :index }, class: 'button -with-icon icon-cancel' %> +

+<% end %> diff --git a/modules/auth_saml/app/views/saml/providers/index.html.erb b/modules/auth_saml/app/views/saml/providers/index.html.erb new file mode 100644 index 000000000000..c25b47bf5040 --- /dev/null +++ b/modules/auth_saml/app/views/saml/providers/index.html.erb @@ -0,0 +1,18 @@ +<% html_title t(:label_administration), t('saml.providers.plural') %> + +<%= render(Primer::OpenProject::PageHeader.new) do |header| + header.with_title { t('saml.providers.plural') } + header.with_breadcrumbs([{ href: admin_index_path, text: t("label_administration") }, + t('saml.providers.plural')]) + header.with_action_button(scheme: :primary, + aria: { label: t('saml.providers.label_add_new') }, + mobile_icon: :plus, + mobile_label: t('saml.providers.label_add_new'), + tag: :a, + href: new_saml_provider_path) do |button| + button.with_leading_visual_icon(icon: :plus) + t('saml.providers.singular') + end +end %> + +<%= render ::Saml::Providers::TableComponent.new(rows: providers) %> diff --git a/modules/auth_saml/app/views/saml/providers/new.html.erb b/modules/auth_saml/app/views/saml/providers/new.html.erb new file mode 100644 index 000000000000..50ae9f727d35 --- /dev/null +++ b/modules/auth_saml/app/views/saml/providers/new.html.erb @@ -0,0 +1,15 @@ +<% html_title(t(:label_administration), t('saml.providers.label_add_new')) -%> +<% local_assigns[:additional_breadcrumb] = t('saml.providers.label_add_new') %> + +<%= toolbar title: t('saml.providers.label_add_new') %> + +<%= error_messages_for @provider %> + +<%= labelled_tabular_form_for @provider, + html: { class: 'form', autocomplete: 'off' } do |f| %> + <%= render partial: "form", locals: { f: f } %> +

+ <%= styled_button_tag t(:button_create), class: '-highlight -with-icon icon-checkmark' %> + <%= link_to t(:button_cancel), { action: :index }, class: 'button -with-icon icon-cancel' %> +

+<% end %> diff --git a/modules/auth_saml/app/views/saml/providers/upsale.html.erb b/modules/auth_saml/app/views/saml/providers/upsale.html.erb new file mode 100644 index 000000000000..5a3b6742c1d2 --- /dev/null +++ b/modules/auth_saml/app/views/saml/providers/upsale.html.erb @@ -0,0 +1,9 @@ +<% html_title(t(:label_administration), t('saml.providers.plural')) -%> + +<%= render template: 'common/upsale', + locals: { + feature_title: t('saml.providers.plural'), + feature_description: t('saml.providers.upsale.description'), + feature_reference: 'enterprise-openid-connect', + feature_image: 'enterprise/open-id-providers.jpg' # TODO + } %> diff --git a/modules/auth_saml/config/locales/en.yml b/modules/auth_saml/config/locales/en.yml new file mode 100644 index 000000000000..5121b7a28ca6 --- /dev/null +++ b/modules/auth_saml/config/locales/en.yml @@ -0,0 +1,33 @@ +en: + activemodel: + attributes: + saml/provider: + name: Name + display_name: Display name + identifier: Identifier + secret: Secret + scope: Scope + limit_self_registration: Limit self registration + sp_entity_id: Service entity ID + saml: + menu_title: SAML providers + providers: + label_add_new: Add a new SAML provider + label_edit: Edit SAML provider %{name} + no_results_table: No providers have been defined yet. + plural: SAML providers + singular: SAML provider + attribute_mapping: Attribute mapping + attribute_mapping_text: > + The following fields control which attributes provided by the SAML identity provider + are used to provide user attributes in OpenProject + upsale: + description: Connect OpenProject to a SAML identity provider + request_attributes: + title: 'Requested attributes' + legend: 'These attributes are added to the SAML XML metadata to signify to the identify provider which attributes OpenProject requires.' + name: 'Requested attribute key' + format: 'Attribute format' + setting_instructions: + limit_self_registration: > + If enabled users can only register using this provider if the self registration setting allows for it. diff --git a/modules/auth_saml/config/routes.rb b/modules/auth_saml/config/routes.rb new file mode 100644 index 000000000000..5e26b8ceaee4 --- /dev/null +++ b/modules/auth_saml/config/routes.rb @@ -0,0 +1,7 @@ +Rails.application.routes.draw do + scope :admin do + namespace :saml do + resources :providers, only: %i[index new create edit update destroy] + end + end +end diff --git a/modules/auth_saml/lib/open_project/auth_saml/engine.rb b/modules/auth_saml/lib/open_project/auth_saml/engine.rb index 09f680d1cc32..202b64e48876 100644 --- a/modules/auth_saml/lib/open_project/auth_saml/engine.rb +++ b/modules/auth_saml/lib/open_project/auth_saml/engine.rb @@ -3,20 +3,19 @@ module OpenProject module AuthSaml def self.configuration RequestStore.fetch(:openproject_omniauth_saml_provider) do - @saml_settings ||= load_global_settings! - @saml_settings.deep_merge(settings_from_db) + global_configuration.deep_merge(settings_from_db) end end def self.reload_configuration! - @saml_settings = nil + @global_configuration = nil RequestStore.delete :openproject_omniauth_saml_provider end ## # Loads the settings once to avoid accessing the file in each request - def self.load_global_settings! - Hash(settings_from_config || settings_from_yaml).with_indifferent_access + def self.global_configuration + @global_configuration ||= Hash(settings_from_config || settings_from_yaml).with_indifferent_access end def self.settings_from_db @@ -25,6 +24,13 @@ def self.settings_from_db value.is_a?(Hash) ? value : {} end + def self.providers + configuration.map do |name, config| + readonly = global_configuration.keys.include?(name) + ::Saml::Provider.new(name, config, readonly:) + end + end + def self.settings_from_config if OpenProject::Configuration["saml"].present? Rails.logger.info("[auth_saml] Registering saml integration from configuration.yml") @@ -50,7 +56,14 @@ class Engine < ::Rails::Engine register "openproject-auth_saml", author_url: "https://github.com/finnlabs/openproject-auth_saml", bundled: true, - settings: { default: { "providers" => nil } } + settings: { default: { "providers" => nil } } do + menu :admin_menu, + :plugin_saml, + :saml_providers_path, + parent: :authentication, + caption: ->(*) { I18n.t('saml.menu_title') }, + enterprise_feature: 'openid_providers' + end assets %w( auth_saml/** From 16532f70fccb2dc3124cdd20780fded78c2cdf05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 24 Jul 2024 13:12:07 +0200 Subject: [PATCH 02/80] Info component --- .../general_info_form_component.html.erb | 24 ++++++++++ .../forms/general_info_form_component.rb | 39 ++++++++++++++++ .../saml/providers/info_component.html.erb | 3 ++ .../saml/providers/info_component.rb | 7 +++ .../providers/provider_name_input_form.rb | 44 +++++++++++++++++++ modules/auth_saml/config/locales/en.yml | 4 ++ 6 files changed, 121 insertions(+) create mode 100644 modules/auth_saml/app/components/saml/providers/forms/general_info_form_component.html.erb create mode 100644 modules/auth_saml/app/components/saml/providers/forms/general_info_form_component.rb create mode 100644 modules/auth_saml/app/components/saml/providers/info_component.html.erb create mode 100644 modules/auth_saml/app/components/saml/providers/info_component.rb create mode 100644 modules/auth_saml/app/forms/saml/providers/provider_name_input_form.rb diff --git a/modules/auth_saml/app/components/saml/providers/forms/general_info_form_component.html.erb b/modules/auth_saml/app/components/saml/providers/forms/general_info_form_component.html.erb new file mode 100644 index 000000000000..a3454d52b3c2 --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/forms/general_info_form_component.html.erb @@ -0,0 +1,24 @@ +<%= + primer_form_with( + model: provider, + url: saml_providers_path, + method: :post, + ) do |form| + flex_layout do |general_info_row| + general_info_row.with_row(mb: 3) do + render(Saml::Providers::Infocomponent.new(provider)) + end + + general_info_row.with_row do + render( + Storages::Admin::SubmitOrCancelForm.new( + form, + storage:, + submit_button_options:, + cancel_button_options: + ) + ) + end + end + end +%> diff --git a/modules/auth_saml/app/components/saml/providers/forms/general_info_form_component.rb b/modules/auth_saml/app/components/saml/providers/forms/general_info_form_component.rb new file mode 100644 index 000000000000..9d8216c43108 --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/forms/general_info_form_component.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml::Providers::Forms + class GeneralInfoFormComponent < ApplicationComponent + attr_reader :provider + + def initialize(provider:) + @provider = provider + end + end +end diff --git a/modules/auth_saml/app/components/saml/providers/info_component.html.erb b/modules/auth_saml/app/components/saml/providers/info_component.html.erb new file mode 100644 index 000000000000..8c1957332f56 --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/info_component.html.erb @@ -0,0 +1,3 @@ +<%= render(Primer::Beta::Heading.new(tag: :h4)) { I18n.t('saml.info.title') } %> + +<%= render(Primer::Beta::Text.new) { I18n.t('saml.info.description') }%> diff --git a/modules/auth_saml/app/components/saml/providers/info_component.rb b/modules/auth_saml/app/components/saml/providers/info_component.rb new file mode 100644 index 000000000000..b1d79afe940f --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/info_component.rb @@ -0,0 +1,7 @@ +module Saml + module Providers + class InfoComponent < ApplicationComponent + alias_method :provider, :model + end + end +end diff --git a/modules/auth_saml/app/forms/saml/providers/provider_name_input_form.rb b/modules/auth_saml/app/forms/saml/providers/provider_name_input_form.rb new file mode 100644 index 000000000000..e8ed28a62d14 --- /dev/null +++ b/modules/auth_saml/app/forms/saml/providers/provider_name_input_form.rb @@ -0,0 +1,44 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml + module Providers + class ProviderNameInputForm < ApplicationForm + form do |storage_form| + storage_form.text_field( + name: :name, + label: I18n.t("activerecord.attributes.storages/storage.name"), + required: true, + caption: I18n.t("storages.instructions.name"), + placeholder: I18n.t("storages.label_file_storage"), + input_width: :large + ) + end + end + end +end diff --git a/modules/auth_saml/config/locales/en.yml b/modules/auth_saml/config/locales/en.yml index 5121b7a28ca6..886292f0a108 100644 --- a/modules/auth_saml/config/locales/en.yml +++ b/modules/auth_saml/config/locales/en.yml @@ -11,6 +11,10 @@ en: sp_entity_id: Service entity ID saml: menu_title: SAML providers + info: + title: "SAML Protocol Configuration Parameters" + description: > + Use these parameters to configure your identity provider connection to OpenProject. providers: label_add_new: Add a new SAML provider label_edit: Edit SAML provider %{name} From c5753084cb096f443f4c7e148be9dd723142e11a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 24 Jul 2024 20:27:39 +0200 Subject: [PATCH 03/80] metadata --- .../controllers/saml/providers_controller.rb | 42 +++++++++--- .../forms/saml/providers/metadata_url_form.rb | 43 ++++++++++++ ..._name_input_form.rb => name_input_form.rb} | 13 ++-- .../saml/providers/submit_or_cancel_form.rb | 67 +++++++++++++++++++ modules/auth_saml/app/models/saml/provider.rb | 5 +- .../services/saml/metadata_parser_service.rb | 61 +++++++++++++++++ .../app/views/saml/providers/_form.html.erb | 4 +- .../app/views/saml/providers/index.html.erb | 32 +++++---- .../app/views/saml/providers/new.html.erb | 32 +++++++-- modules/auth_saml/config/locales/en.yml | 24 +++++-- modules/auth_saml/config/routes.rb | 6 +- .../lib/open_project/auth_saml/engine.rb | 3 +- 12 files changed, 284 insertions(+), 48 deletions(-) create mode 100644 modules/auth_saml/app/forms/saml/providers/metadata_url_form.rb rename modules/auth_saml/app/forms/saml/providers/{provider_name_input_form.rb => name_input_form.rb} (79%) create mode 100644 modules/auth_saml/app/forms/saml/providers/submit_or_cancel_form.rb create mode 100644 modules/auth_saml/app/services/saml/metadata_parser_service.rb diff --git a/modules/auth_saml/app/controllers/saml/providers_controller.rb b/modules/auth_saml/app/controllers/saml/providers_controller.rb index 9dd7ed85118d..54d6a1521fe3 100644 --- a/modules/auth_saml/app/controllers/saml/providers_controller.rb +++ b/modules/auth_saml/app/controllers/saml/providers_controller.rb @@ -1,6 +1,6 @@ module Saml class ProvidersController < ::ApplicationController - layout 'admin' + layout "admin" menu_item :plugin_saml before_action :require_admin @@ -10,11 +10,21 @@ class ProvidersController < ::ApplicationController def index; end def new - @provider = ::Saml::Provider.new(defaults) + @provider = ::Saml::Provider.new(name: "saml") + end + + def import + @provider = ::Saml::Provider.new(name: import_params[:name]) + + if import_params[:metadata_url].present? + import_metadata + end + + render action: :edit end def create - @provider = ::Saml::Provider.new(create_params) + @provider = ::Saml::Provider.new(**create_params) if @provider.save flash[:notice] = I18n.t(:notice_successful_create) @@ -51,23 +61,39 @@ def destroy private def defaults - { - } + {} end def check_ee unless EnterpriseToken.allows_to?(:openid_providers) - render template: '/saml/providers/upsale' + render template: "/saml/providers/upsale" false end end + def import_params + params.require(:saml_provider).permit(:name, :metadata_url) + end + + def import_metadata + call = Saml::MetadataParserService + .new(user: User.current) + .parse_url(import_params[:metadata_url]) + + if call.success? + flash[:notice] = I18n.t("saml.metadata_parser.success") + @provider = ::Saml::Provider.new(**call.result.merge(name: import_params[:name])) + else + flash[:error] = call.message + end + end + def create_params - params.require(:openid_connect_provider).permit(:name, :display_name, :identifier, :secret, :limit_self_registration) + params.require(:saml_provider).permit(:name, :display_name, :identifier, :secret, :limit_self_registration) end def update_params - params.require(:openid_connect_provider).permit(:display_name, :identifier, :secret, :limit_self_registration) + params.require(:saml_provider).permit(:display_name, :identifier, :secret, :limit_self_registration) end def find_provider diff --git a/modules/auth_saml/app/forms/saml/providers/metadata_url_form.rb b/modules/auth_saml/app/forms/saml/providers/metadata_url_form.rb new file mode 100644 index 000000000000..ad700d621ef2 --- /dev/null +++ b/modules/auth_saml/app/forms/saml/providers/metadata_url_form.rb @@ -0,0 +1,43 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml + module Providers + class MetadataUrlForm < ApplicationForm + form do |f| + f.text_field( + name: :metadata_url, + label: I18n.t("saml.settings.metadata_url"), + required: false, + caption: I18n.t("saml.setting_instructions.metadata_url"), + input_width: :medium + ) + end + end + end +end diff --git a/modules/auth_saml/app/forms/saml/providers/provider_name_input_form.rb b/modules/auth_saml/app/forms/saml/providers/name_input_form.rb similarity index 79% rename from modules/auth_saml/app/forms/saml/providers/provider_name_input_form.rb rename to modules/auth_saml/app/forms/saml/providers/name_input_form.rb index e8ed28a62d14..0782f1c53dac 100644 --- a/modules/auth_saml/app/forms/saml/providers/provider_name_input_form.rb +++ b/modules/auth_saml/app/forms/saml/providers/name_input_form.rb @@ -28,15 +28,14 @@ module Saml module Providers - class ProviderNameInputForm < ApplicationForm - form do |storage_form| - storage_form.text_field( + class NameInputForm < ApplicationForm + form do |f| + f.text_field( name: :name, - label: I18n.t("activerecord.attributes.storages/storage.name"), + label: I18n.t("activemodel.attributes.saml/provider.name"), required: true, - caption: I18n.t("storages.instructions.name"), - placeholder: I18n.t("storages.label_file_storage"), - input_width: :large + caption: I18n.t("saml.setting_instructions.name"), + input_width: :medium ) end end diff --git a/modules/auth_saml/app/forms/saml/providers/submit_or_cancel_form.rb b/modules/auth_saml/app/forms/saml/providers/submit_or_cancel_form.rb new file mode 100644 index 000000000000..ad53b6c74bda --- /dev/null +++ b/modules/auth_saml/app/forms/saml/providers/submit_or_cancel_form.rb @@ -0,0 +1,67 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml + module Providers + class SubmitOrCancelForm < ApplicationForm + form do |buttons| + buttons.group(layout: :horizontal) do |button_group| + button_group.submit(**@submit_button_options) + button_group.button(**@cancel_button_options) + end + end + + def initialize(submit_button_options: {}, cancel_button_options: {}) + super() + @submit_button_options = default_submit_button_options.merge(submit_button_options) + @cancel_button_options = default_cancel_button_options.merge(cancel_button_options) + end + + private + + def default_submit_button_options + { + name: :submit, + scheme: :primary, + label: I18n.t(:button_continue), + disabled: false + } + end + + def default_cancel_button_options + { + name: :cancel, + scheme: :default, + tag: :a, + href: OpenProject::StaticRouting::StaticRouter.new.url_helpers.saml_providers_path, + label: I18n.t("button_cancel") + } + end + end + end +end diff --git a/modules/auth_saml/app/models/saml/provider.rb b/modules/auth_saml/app/models/saml/provider.rb index e7e75bf7f7d4..9a7592247e96 100644 --- a/modules/auth_saml/app/models/saml/provider.rb +++ b/modules/auth_saml/app/models/saml/provider.rb @@ -5,11 +5,10 @@ class Provider < OpenStruct include ActiveModel::Validations include ActiveModel::Conversion - attr_accessor :name, :readonly + attr_accessor :readonly validates_presence_of :name, :display_name - def initialize(name, attributes = {}, readonly: false) - self.name = name + def initialize(readonly: false, **attributes) self.readonly = readonly super(attributes) diff --git a/modules/auth_saml/app/services/saml/metadata_parser_service.rb b/modules/auth_saml/app/services/saml/metadata_parser_service.rb new file mode 100644 index 000000000000..1186204679a8 --- /dev/null +++ b/modules/auth_saml/app/services/saml/metadata_parser_service.rb @@ -0,0 +1,61 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml + class MetadataParserService + attr_reader :user + + def initialize(user:) + @user = user + end + + def parse_url(url) + validate_url!(url) + parse_remote_metadata(url) + rescue URI::InvalidURIError + ServiceResult.failure(message: I18n.t('saml.metadata_parser.invalid_url')) + rescue OneLogin::RubySaml::HttpError => e + ServiceResult.failure(message: I18n.t('saml.metadata_parser.error', error: e.message)) + rescue StandardError => e + OpenProject.logger.error(e) + ServiceResult.failure(message: I18n.t('saml.metadata_parser.error', error: e.class.name)) + end + + def parse_remote_metadata(metadata_url) + idp_metadata_parser = OneLogin::RubySaml::IdpMetadataParser.new + result = idp_metadata_parser.parse_remote_to_hash(metadata_url) + + ServiceResult.success(result:) + end + + def validate_url!(url) + uri = URI.parse(url) + raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) + end + end +end diff --git a/modules/auth_saml/app/views/saml/providers/_form.html.erb b/modules/auth_saml/app/views/saml/providers/_form.html.erb index 296d0d79bb20..2573404127b6 100644 --- a/modules/auth_saml/app/views/saml/providers/_form.html.erb +++ b/modules/auth_saml/app/views/saml/providers/_form.html.erb @@ -12,11 +12,11 @@
- <%= f.text_field :idp_sso_target_url, required: true, container_class: '-xwide' %> + <%= f.text_field :idp_sso_service_url, required: true, container_class: '-xwide' %>
- <%= f.text_field :idp_slo_target_url, required: false, container_class: '-xwide' %> + <%= f.text_field :idp_slo_service_url, required: false, container_class: '-xwide' %>
diff --git a/modules/auth_saml/app/views/saml/providers/index.html.erb b/modules/auth_saml/app/views/saml/providers/index.html.erb index c25b47bf5040..79ec7749387b 100644 --- a/modules/auth_saml/app/views/saml/providers/index.html.erb +++ b/modules/auth_saml/app/views/saml/providers/index.html.erb @@ -1,18 +1,24 @@ <% html_title t(:label_administration), t('saml.providers.plural') %> -<%= render(Primer::OpenProject::PageHeader.new) do |header| - header.with_title { t('saml.providers.plural') } - header.with_breadcrumbs([{ href: admin_index_path, text: t("label_administration") }, - t('saml.providers.plural')]) - header.with_action_button(scheme: :primary, - aria: { label: t('saml.providers.label_add_new') }, - mobile_icon: :plus, - mobile_label: t('saml.providers.label_add_new'), - tag: :a, - href: new_saml_provider_path) do |button| - button.with_leading_visual_icon(icon: :plus) - t('saml.providers.singular') +<%= + content_for :content_header do + render(Primer::OpenProject::PageHeader.new) do |header| + header.with_title { t('saml.providers.plural') } + header.with_breadcrumbs([{ href: admin_index_path, text: t("label_administration") }, + t('saml.providers.plural')]) + + header.with_action_button(scheme: :primary, + aria: { label: t('saml.providers.label_add_new') }, + mobile_icon: :plus, + mobile_label: t('saml.providers.label_add_new'), + tag: :a, + href: new_saml_provider_path) do |button| + button.with_leading_visual_icon(icon: :plus) + t('saml.providers.singular') + end + end end -end %> +%> + <%= render ::Saml::Providers::TableComponent.new(rows: providers) %> diff --git a/modules/auth_saml/app/views/saml/providers/new.html.erb b/modules/auth_saml/app/views/saml/providers/new.html.erb index 50ae9f727d35..2a7e3bcf7b62 100644 --- a/modules/auth_saml/app/views/saml/providers/new.html.erb +++ b/modules/auth_saml/app/views/saml/providers/new.html.erb @@ -5,11 +5,29 @@ <%= error_messages_for @provider %> -<%= labelled_tabular_form_for @provider, - html: { class: 'form', autocomplete: 'off' } do |f| %> - <%= render partial: "form", locals: { f: f } %> -

- <%= styled_button_tag t(:button_create), class: '-highlight -with-icon icon-checkmark' %> - <%= link_to t(:button_cancel), { action: :index }, class: 'button -with-icon icon-cancel' %> -

+ +<%= + primer_form_with( + model: @provider, + url: { action: :import }, + method: :post + ) do |form| %> + + <%= + render(Primer::OpenProject::FlexLayout.new) do |flex| + flex.with_row(mb: 3) do + render(Saml::Providers::NameInputForm.new(form)) + end + + flex.with_row(mb: 3) do + render(Saml::Providers::MetadataUrlForm.new(form)) + end + + flex.with_row do + render( + Saml::Providers::SubmitOrCancelForm.new(form) + ) + end + end + %> <% end %> diff --git a/modules/auth_saml/config/locales/en.yml b/modules/auth_saml/config/locales/en.yml index 886292f0a108..78112af520d1 100644 --- a/modules/auth_saml/config/locales/en.yml +++ b/modules/auth_saml/config/locales/en.yml @@ -2,25 +2,30 @@ en: activemodel: attributes: saml/provider: - name: Name + name: Identifier display_name: Display name identifier: Identifier secret: Secret scope: Scope limit_self_registration: Limit self registration sp_entity_id: Service entity ID + metadata_url: Identity provider metadata URL saml: menu_title: SAML providers info: title: "SAML Protocol Configuration Parameters" description: > Use these parameters to configure your identity provider connection to OpenProject. + metadata_parser: + success: "Successfully retrieved the identity provider metadata. Please review and save the configuration." + invalid_url: "Provided metadata URL is invalid. Provide a HTTP(s) URL." + error: "Failed to retrieve the identity provider metadata: %{error}" providers: - label_add_new: Add a new SAML provider - label_edit: Edit SAML provider %{name} - no_results_table: No providers have been defined yet. - plural: SAML providers - singular: SAML provider + label_add_new: New SAML identity provider + label_edit: Edit SAML identity provider %{name} + no_results_table: No SAML identity providers have been defined yet. + plural: SAML identity providers + singular: SAML identity provider attribute_mapping: Attribute mapping attribute_mapping_text: > The following fields control which attributes provided by the SAML identity provider @@ -32,6 +37,13 @@ en: legend: 'These attributes are added to the SAML XML metadata to signify to the identify provider which attributes OpenProject requires.' name: 'Requested attribute key' format: 'Attribute format' + settings: + metadata_url: Identity provider metadata URL (Optional) setting_instructions: + name: > + The identifier of the SAML provider. This needs to be alphanumeric, unique, and is used in the URL fragment to identify the provider. + metadata_url: > + Enter the URL of the identity provider XML metadata endpoint. + If provided, OpenProject will try to prefill all available configuration options from your identity provider. limit_self_registration: > If enabled users can only register using this provider if the self registration setting allows for it. diff --git a/modules/auth_saml/config/routes.rb b/modules/auth_saml/config/routes.rb index 5e26b8ceaee4..cf0ddb5cf771 100644 --- a/modules/auth_saml/config/routes.rb +++ b/modules/auth_saml/config/routes.rb @@ -1,7 +1,11 @@ Rails.application.routes.draw do scope :admin do namespace :saml do - resources :providers, only: %i[index new create edit update destroy] + resources :providers, only: %i[index new create edit update destroy] do + collection do + post :import + end + end end end end diff --git a/modules/auth_saml/lib/open_project/auth_saml/engine.rb b/modules/auth_saml/lib/open_project/auth_saml/engine.rb index 202b64e48876..34242d2b0322 100644 --- a/modules/auth_saml/lib/open_project/auth_saml/engine.rb +++ b/modules/auth_saml/lib/open_project/auth_saml/engine.rb @@ -26,8 +26,9 @@ def self.settings_from_db def self.providers configuration.map do |name, config| + config['name'] = name readonly = global_configuration.keys.include?(name) - ::Saml::Provider.new(name, config, readonly:) + ::Saml::Provider.new(readonly:, **config) end end From 892208839b771b3de4c38eebd48134988b76906d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 24 Jul 2024 21:39:50 +0200 Subject: [PATCH 04/80] Change impl to border box --- .../saml/providers/row_component.rb | 22 ++++++++++++++----- .../saml/providers/table_component.rb | 7 +++--- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/modules/auth_saml/app/components/saml/providers/row_component.rb b/modules/auth_saml/app/components/saml/providers/row_component.rb index 5fc382a8e1b1..c9a436a579f6 100644 --- a/modules/auth_saml/app/components/saml/providers/row_component.rb +++ b/modules/auth_saml/app/components/saml/providers/row_component.rb @@ -1,15 +1,23 @@ module Saml module Providers - class RowComponent < ::RowComponent + class RowComponent < ::OpPrimer::BorderBoxRowComponent def provider model end def name - link_to( - provider.display_name || provider.name, - url_for(action: :edit, id: provider.id) - ) + concat render(Primer::Beta::Link.new( + scheme: :primary, + href: url_for(action: :edit, id: provider.id) + )) { provider.display_name || provider.name } + + if provider.idp_sso_target_url + concat render(Primer::Beta::Text.new( + tag: :p, + font_size: :small, + color: :subtle + )) { provider.idp_sso_target_url } + end end def button_links @@ -24,6 +32,10 @@ def edit_link ) end + def users + "1234" + end + def delete_link return if provider.readonly diff --git a/modules/auth_saml/app/components/saml/providers/table_component.rb b/modules/auth_saml/app/components/saml/providers/table_component.rb index 9cb15f87c876..af0fb5b91bb6 100644 --- a/modules/auth_saml/app/components/saml/providers/table_component.rb +++ b/modules/auth_saml/app/components/saml/providers/table_component.rb @@ -1,7 +1,7 @@ module Saml module Providers - class TableComponent < ::TableComponent - columns :name + class TableComponent < ::OpPrimer::BorderBoxTableComponent + columns :name, :users def initial_sort %i[id asc] @@ -17,7 +17,8 @@ def empty_row_message def headers [ - ['name', { caption: I18n.t('attributes.name') }] + ['name', { caption: I18n.t('attributes.name') }], + ['users', { caption: I18n.t(:label_user_plural) }] ] end end From cd6959700596cfa9197478de48a56e36d4a13852 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 1 Aug 2024 08:52:11 +0200 Subject: [PATCH 05/80] use SubHeader --- .../app/views/saml/providers/index.html.erb | 33 +++++++++---------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/modules/auth_saml/app/views/saml/providers/index.html.erb b/modules/auth_saml/app/views/saml/providers/index.html.erb index 79ec7749387b..98c082840584 100644 --- a/modules/auth_saml/app/views/saml/providers/index.html.erb +++ b/modules/auth_saml/app/views/saml/providers/index.html.erb @@ -1,24 +1,23 @@ <% html_title t(:label_administration), t('saml.providers.plural') %> -<%= - content_for :content_header do - render(Primer::OpenProject::PageHeader.new) do |header| - header.with_title { t('saml.providers.plural') } - header.with_breadcrumbs([{ href: admin_index_path, text: t("label_administration") }, - t('saml.providers.plural')]) +<%= content_for :content_header do %> + <%= render(Primer::OpenProject::PageHeader.new) do |header| + header.with_title { t('saml.providers.plural') } + header.with_breadcrumbs([{ href: admin_index_path, text: t("label_administration") }, + t('saml.providers.plural')]) + end %> - header.with_action_button(scheme: :primary, - aria: { label: t('saml.providers.label_add_new') }, - mobile_icon: :plus, - mobile_label: t('saml.providers.label_add_new'), - tag: :a, - href: new_saml_provider_path) do |button| - button.with_leading_visual_icon(icon: :plus) - t('saml.providers.singular') - end + <%= render(Primer::OpenProject::SubHeader.new) do |subheader| + subheader.with_action_button(scheme: :primary, + aria: { label: I18n.t('saml.providers.label_add_new') }, + title: I18n.t('saml.providers.label_add_new'), + tag: :a, + href: new_saml_provider_path) do |button| + button.with_leading_visual_icon(icon: :plus) + t('saml.providers.singular') end end -%> - + %> +<% end %> <%= render ::Saml::Providers::TableComponent.new(rows: providers) %> From 4caf835349f7d56e90af432244f10ec6cf802666 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 1 Aug 2024 09:55:33 +0200 Subject: [PATCH 06/80] Implement border_box view component --- config/locales/en.yml | 2 + lib/open_project/static/links.rb | 5 ++ lib_static/redmine/i18n.rb | 6 +- .../general_info_form_component.html.erb | 24 ------ .../saml/providers/row_component.rb | 2 +- .../sections/metadata_component.html.erb | 18 +++++ .../providers/sections/metadata_component.rb | 41 ++++++++++ .../sections/metadata_form_component.html.erb | 25 ++++++ .../sections/metadata_form_component.rb | 41 ++++++++++ .../sections/name_component.html.erb | 15 ++++ .../name_component.rb} | 8 +- .../sections/name_form_component.html.erb | 17 ++++ .../providers/sections/name_form_component.rb | 41 ++++++++++ .../saml/providers/view_component.html.erb | 79 +++++++++++++++++++ .../saml/providers/view_component.rb | 39 +++++++++ .../controllers/saml/providers_controller.rb | 20 +++-- .../forms/saml/providers/metadata_url_form.rb | 2 +- .../forms/saml/providers/name_input_form.rb | 6 +- .../saml/providers/submit_or_cancel_form.rb | 25 +++++- modules/auth_saml/app/models/saml/provider.rb | 9 ++- .../app/views/saml/providers/new.html.erb | 44 ++++------- .../app/views/saml/providers/show.html.erb | 30 +++++++ modules/auth_saml/config/locales/en.yml | 13 ++- modules/auth_saml/config/routes.rb | 2 +- .../admin/storage_view_information.rb | 4 +- modules/storages/config/locales/en.yml | 2 - 26 files changed, 439 insertions(+), 81 deletions(-) delete mode 100644 modules/auth_saml/app/components/saml/providers/forms/general_info_form_component.html.erb create mode 100644 modules/auth_saml/app/components/saml/providers/sections/metadata_component.html.erb create mode 100644 modules/auth_saml/app/components/saml/providers/sections/metadata_component.rb create mode 100644 modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.html.erb create mode 100644 modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.rb create mode 100644 modules/auth_saml/app/components/saml/providers/sections/name_component.html.erb rename modules/auth_saml/app/components/saml/providers/{forms/general_info_form_component.rb => sections/name_component.rb} (89%) create mode 100644 modules/auth_saml/app/components/saml/providers/sections/name_form_component.html.erb create mode 100644 modules/auth_saml/app/components/saml/providers/sections/name_form_component.rb create mode 100644 modules/auth_saml/app/components/saml/providers/view_component.html.erb create mode 100644 modules/auth_saml/app/components/saml/providers/view_component.rb create mode 100644 modules/auth_saml/app/views/saml/providers/show.html.erb diff --git a/config/locales/en.yml b/config/locales/en.yml index 0b0ee449c0ae..557d50419564 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2118,6 +2118,7 @@ en: label_calendars_and_dates: "Calendars and dates" label_calendar_show: "Show Calendar" label_category: "Category" + label_completed: Completed label_consent_settings: "User Consent" label_wiki_menu_item: Wiki menu item label_select_main_menu_item: Select new main menu item @@ -2277,6 +2278,7 @@ en: label_inactive: "Inactive" label_incoming_emails: "Incoming emails" label_includes: "includes" + label_incomplete: Incomplete label_include_sub_projects: Include sub-projects label_index_by_date: "Index by date" label_index_by_title: "Index by title" diff --git a/lib/open_project/static/links.rb b/lib/open_project/static/links.rb index 97bf710de570..738734f4ff0b 100644 --- a/lib/open_project/static/links.rb +++ b/lib/open_project/static/links.rb @@ -270,6 +270,11 @@ def static_links href: "https://www.openproject.org/docs/system-admin-guide/manage-work-packages/work-package-status/#create-a-new-work-package-status" } }, + sysadmin_docs: { + saml: { + href: "https://www.openproject.org/docs/system-admin-guide/authentication/saml/" + } + }, storage_docs: { setup: { href: "https://www.openproject.org/docs/system-admin-guide/integrations/storage/" diff --git a/lib_static/redmine/i18n.rb b/lib_static/redmine/i18n.rb index c54f18d32d91..2284a31fb1a3 100644 --- a/lib_static/redmine/i18n.rb +++ b/lib_static/redmine/i18n.rb @@ -91,13 +91,15 @@ def format_date(date) # # @param i18n_key [String] The I18n key to translate. # @param links [Hash] Link names mapped to URLs. - def link_translate(i18n_key, links: {}, locale: ::I18n.locale) + # @param target [String] optional HTML target attribute for the links. + def link_translate(i18n_key, links: {}, locale: ::I18n.locale, target: nil) translation = ::I18n.t(i18n_key.to_s, locale:) result = translation.scan(link_regex).inject(translation) do |t, matches| link, text, key = matches href = String(links[key.to_sym]) + link_tag = content_tag(:a, text, href: href, target: target) - t.sub(link, "#{text}") + t.sub(link, link_tag) end result.html_safe diff --git a/modules/auth_saml/app/components/saml/providers/forms/general_info_form_component.html.erb b/modules/auth_saml/app/components/saml/providers/forms/general_info_form_component.html.erb deleted file mode 100644 index a3454d52b3c2..000000000000 --- a/modules/auth_saml/app/components/saml/providers/forms/general_info_form_component.html.erb +++ /dev/null @@ -1,24 +0,0 @@ -<%= - primer_form_with( - model: provider, - url: saml_providers_path, - method: :post, - ) do |form| - flex_layout do |general_info_row| - general_info_row.with_row(mb: 3) do - render(Saml::Providers::Infocomponent.new(provider)) - end - - general_info_row.with_row do - render( - Storages::Admin::SubmitOrCancelForm.new( - form, - storage:, - submit_button_options:, - cancel_button_options: - ) - ) - end - end - end -%> diff --git a/modules/auth_saml/app/components/saml/providers/row_component.rb b/modules/auth_saml/app/components/saml/providers/row_component.rb index c9a436a579f6..ef5ef2f5eb4d 100644 --- a/modules/auth_saml/app/components/saml/providers/row_component.rb +++ b/modules/auth_saml/app/components/saml/providers/row_component.rb @@ -8,7 +8,7 @@ def provider def name concat render(Primer::Beta::Link.new( scheme: :primary, - href: url_for(action: :edit, id: provider.id) + href: url_for(action: :show, id: provider.id) )) { provider.display_name || provider.name } if provider.idp_sso_target_url diff --git a/modules/auth_saml/app/components/saml/providers/sections/metadata_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/metadata_component.html.erb new file mode 100644 index 000000000000..8ef31fb231d1 --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/sections/metadata_component.html.erb @@ -0,0 +1,18 @@ +<%= + flex_layout do |flex| + flex.with_column do + render(Primer::Beta::Text.new(font_weight: :semibold)) { t("saml.providers.label_metadata") } + end + + if provider.metadata_url.present? + flex.with_column do + render(Primer::Beta::Label.new(scheme: :success, ml: 1)) { t(:label_completed) } + end + end + end +%> +<%= + render(Primer::Beta::Text.new(tag: :p, font_size: :small, color: :subtle)) do + t("saml.providers.section_texts.metadata") + end +%> diff --git a/modules/auth_saml/app/components/saml/providers/sections/metadata_component.rb b/modules/auth_saml/app/components/saml/providers/sections/metadata_component.rb new file mode 100644 index 000000000000..15e825edfd1b --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/sections/metadata_component.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml::Providers::Sections + class MetadataComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + attr_reader :provider + + def initialize(provider) + @provider = provider + end + end +end diff --git a/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.html.erb new file mode 100644 index 000000000000..1e8be68155e4 --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.html.erb @@ -0,0 +1,25 @@ +<%= + primer_form_with( + model: provider, + url: saml_providers_path, + method: :post, + ) do |form| + flex_layout do |flex| + flex.with_row do + render(Primer::Alpha::SegmentedControl.new("aria-label": "File view", full_width: full_width, hide_labels: hide_labels, size: size)) do |component| + component.with_item(label: "Preview", icon: icon, selected: true) + component.with_item(label: "Raw", icon: icon) + component.with_item(label: "Blame", icon: icon) + end + end + + flex.with_row do + render(Saml::Providers::MetadataUrlForm.new(form)) + end + + flex.with_row(mt: 4) do + render(Saml::Providers::SubmitOrCancelForm.new(form, provider:, state: :metadata)) + end + end + end +%> diff --git a/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.rb b/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.rb new file mode 100644 index 000000000000..a5065554b682 --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml::Providers::Sections + class MetadataFormComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + attr_reader :provider + + def initialize(provider) + @provider = provider + end + end +end diff --git a/modules/auth_saml/app/components/saml/providers/sections/name_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/name_component.html.erb new file mode 100644 index 000000000000..a911047d0bb7 --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/sections/name_component.html.erb @@ -0,0 +1,15 @@ +<%= + flex_layout do |flex| + flex.with_column do + render(Primer::Beta::Text.new(font_weight: :semibold)) { t("saml.providers.singular") } + end + flex.with_column do + render(Primer::Beta::Label.new(scheme: :success, ml: 1)) { t(:label_completed) } + end + end +%> +<%= + render(Primer::Beta::Text.new(tag: :p, font_size: :small, color: :subtle)) do + t("saml.providers.section_texts.display_name") + end +%> diff --git a/modules/auth_saml/app/components/saml/providers/forms/general_info_form_component.rb b/modules/auth_saml/app/components/saml/providers/sections/name_component.rb similarity index 89% rename from modules/auth_saml/app/components/saml/providers/forms/general_info_form_component.rb rename to modules/auth_saml/app/components/saml/providers/sections/name_component.rb index 9d8216c43108..0d33cb2e5dc4 100644 --- a/modules/auth_saml/app/components/saml/providers/forms/general_info_form_component.rb +++ b/modules/auth_saml/app/components/saml/providers/sections/name_component.rb @@ -28,11 +28,13 @@ # See COPYRIGHT and LICENSE files for more details. #++ # -module Saml::Providers::Forms - class GeneralInfoFormComponent < ApplicationComponent +module Saml::Providers::Sections + class NameComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + attr_reader :provider - def initialize(provider:) + def initialize(provider) @provider = provider end end diff --git a/modules/auth_saml/app/components/saml/providers/sections/name_form_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/name_form_component.html.erb new file mode 100644 index 000000000000..b02055fa0945 --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/sections/name_form_component.html.erb @@ -0,0 +1,17 @@ +<%= + primer_form_with( + model: provider, + url: saml_providers_path, + method: :post, + ) do |form| + flex_layout do |flex| + flex.with_row do + render(Saml::Providers::NameInputForm.new(form)) + end + + flex.with_row(mt: 4) do + render(Saml::Providers::SubmitOrCancelForm.new(form, provider:, state: :name)) + end + end + end +%> diff --git a/modules/auth_saml/app/components/saml/providers/sections/name_form_component.rb b/modules/auth_saml/app/components/saml/providers/sections/name_form_component.rb new file mode 100644 index 000000000000..4f0abfb05231 --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/sections/name_form_component.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml::Providers::Sections + class NameFormComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + attr_reader :provider + + def initialize(provider) + @provider = provider + end + end +end diff --git a/modules/auth_saml/app/components/saml/providers/view_component.html.erb b/modules/auth_saml/app/components/saml/providers/view_component.html.erb new file mode 100644 index 000000000000..6231524a5266 --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/view_component.html.erb @@ -0,0 +1,79 @@ +<%= + render(border_box_container) do |component| + component.with_header(color: :muted) do + render(Primer::Beta::Text.new(font_weight: :semibold)) { I18n.t("activemodel.attributes.saml/provider.display_name") } + end + + component.with_row(scheme: :default) do + if edit_state == :name + render(Saml::Providers::Sections::NameFormComponent.new(provider)) + else + render(Saml::Providers::Sections::NameComponent.new(provider)) + end + end + + + component.with_row(scheme: :neutral, color: :muted) do + render(Primer::Beta::Text.new(font_weight: :semibold)) { I18n.t('saml.providers.label_automatic_configuration') } + end + + component.with_row(scheme: :default) do + if edit_state == :metadata + render(Saml::Providers::Sections::MetadataFormComponent.new(provider)) + else + render(Saml::Providers::Sections::MetadataComponent.new(provider)) + end + end + # + # component.with_row(scheme: :default) do + # render(Storages::Admin::OAuthClientInfoComponent.new(oauth_client: storage.oauth_client, storage:)) + # end + # + # component.with_row(scheme: :neutral, color: :muted) do + # grid_layout('op-storage-view--row', tag: :div, align_items: :center) do |grid| + # grid.with_area(:item, tag: :div) do + # render(Primer::Beta::Text.new(font_weight: :semibold, mr: 1)) { I18n.t('storages.file_storage_view.project_folders') } + # end + # end + # end + # + # component.with_row(scheme: :default) do + # if automatically_managed_project_folders_section_closed? + # render(Storages::Admin::AutomaticallyManagedProjectFoldersInfoComponent.new(storage)) + # else + # render(Storages::Admin::Forms::AutomaticallyManagedProjectFoldersFormComponent.new(storage)) + # end + # end + # end + # + # if storage.provider_type_one_drive? + # component.with_row(scheme: :neutral, color: :muted) do + # grid_layout('op-storage-view--row', tag: :div, align_items: :center) do |grid| + # grid.with_area(:item, tag: :div, mr: 3) do + # render(Primer::Beta::Text.new(font_weight: :semibold, mr: 1)) { I18n.t('storages.file_storage_view.access_management.title') } + # end + # end + # end + # + # component.with_row(scheme: :default) do + # render(Storages::Admin::AccessManagementComponent.new(storage)) + # end + # + # component.with_row(scheme: :neutral, color: :muted) do + # grid_layout('op-storage-view--row', tag: :div, align_items: :center) do |grid| + # grid.with_area(:item, tag: :div, mr: 3) do + # render(Primer::Beta::Text.new(font_weight: :semibold, mr: 1)) { I18n.t('storages.file_storage_view.oauth_applications') } + # end + # end + # end + # + # component.with_row(scheme: :default) do + # render(Storages::Admin::OAuthClientInfoComponent.new(oauth_client: storage.oauth_client, storage:)) + # end + # + # component.with_row(scheme: :default) do + # render(Storages::Admin::RedirectUriComponent.new(oauth_client: storage.oauth_client, storage:)) + # end + # end + end +%> diff --git a/modules/auth_saml/app/components/saml/providers/view_component.rb b/modules/auth_saml/app/components/saml/providers/view_component.rb new file mode 100644 index 000000000000..e5106a384d36 --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/view_component.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml::Providers + class ViewComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + options :edit_state + + alias_method :provider, :model + end +end diff --git a/modules/auth_saml/app/controllers/saml/providers_controller.rb b/modules/auth_saml/app/controllers/saml/providers_controller.rb index 54d6a1521fe3..ec8b4b9f1202 100644 --- a/modules/auth_saml/app/controllers/saml/providers_controller.rb +++ b/modules/auth_saml/app/controllers/saml/providers_controller.rb @@ -5,9 +5,11 @@ class ProvidersController < ::ApplicationController before_action :require_admin before_action :check_ee - before_action :find_provider, only: %i[edit update destroy] + before_action :find_provider, only: %i[show edit update destroy] def index; end + def edit; end + def show; end def new @provider = ::Saml::Provider.new(name: "saml") @@ -28,21 +30,19 @@ def create if @provider.save flash[:notice] = I18n.t(:notice_successful_create) - redirect_to action: :index + redirect_to saml_provider_path(@provider) else render action: :new end end - def edit; end - def update - @provider = ::OpenIDConnect::Provider.initialize_with( + @provider = ::Saml::Provider.initialize_with( update_params.merge("name" => params[:id]) ) if @provider.save flash[:notice] = I18n.t(:notice_successful_update) - redirect_to action: :index + success_redirect else render action: :edit end @@ -60,6 +60,14 @@ def destroy private + def success_redirect + if params[:edit_state].present? + redirect_to edit_saml_provider_path(@provider, edit_state: params[:edit_state]) + else + redirect_to saml_provider_path(@provider) + end + end + def defaults {} end diff --git a/modules/auth_saml/app/forms/saml/providers/metadata_url_form.rb b/modules/auth_saml/app/forms/saml/providers/metadata_url_form.rb index ad700d621ef2..649c0cb0a066 100644 --- a/modules/auth_saml/app/forms/saml/providers/metadata_url_form.rb +++ b/modules/auth_saml/app/forms/saml/providers/metadata_url_form.rb @@ -34,7 +34,7 @@ class MetadataUrlForm < ApplicationForm name: :metadata_url, label: I18n.t("saml.settings.metadata_url"), required: false, - caption: I18n.t("saml.setting_instructions.metadata_url"), + caption: I18n.t("saml.instructions.metadata_url"), input_width: :medium ) end diff --git a/modules/auth_saml/app/forms/saml/providers/name_input_form.rb b/modules/auth_saml/app/forms/saml/providers/name_input_form.rb index 0782f1c53dac..e4b8c68e42d3 100644 --- a/modules/auth_saml/app/forms/saml/providers/name_input_form.rb +++ b/modules/auth_saml/app/forms/saml/providers/name_input_form.rb @@ -31,10 +31,10 @@ module Providers class NameInputForm < ApplicationForm form do |f| f.text_field( - name: :name, - label: I18n.t("activemodel.attributes.saml/provider.name"), + name: :display_name, + label: I18n.t("activemodel.attributes.saml/provider.display_name"), required: true, - caption: I18n.t("saml.setting_instructions.name"), + caption: I18n.t("saml.instructions.display_name"), input_width: :medium ) end diff --git a/modules/auth_saml/app/forms/saml/providers/submit_or_cancel_form.rb b/modules/auth_saml/app/forms/saml/providers/submit_or_cancel_form.rb index ad53b6c74bda..519d287eb022 100644 --- a/modules/auth_saml/app/forms/saml/providers/submit_or_cancel_form.rb +++ b/modules/auth_saml/app/forms/saml/providers/submit_or_cancel_form.rb @@ -29,15 +29,24 @@ module Saml module Providers class SubmitOrCancelForm < ApplicationForm - form do |buttons| - buttons.group(layout: :horizontal) do |button_group| + form do |f| + if @state + f.hidden( + name: :edit_state, + value: @state + ) + end + + f.group(layout: :horizontal) do |button_group| button_group.submit(**@submit_button_options) button_group.button(**@cancel_button_options) end end - def initialize(submit_button_options: {}, cancel_button_options: {}) + def initialize(provider:, state: nil, submit_button_options: {}, cancel_button_options: {}) super() + @state = state + @provider = provider @submit_button_options = default_submit_button_options.merge(submit_button_options) @cancel_button_options = default_cancel_button_options.merge(cancel_button_options) end @@ -58,10 +67,18 @@ def default_cancel_button_options name: :cancel, scheme: :default, tag: :a, - href: OpenProject::StaticRouting::StaticRouter.new.url_helpers.saml_providers_path, + href: back_link, label: I18n.t("button_cancel") } end + + def back_link + if @provider.new_record? + OpenProject::StaticRouting::StaticRouter.new.url_helpers.saml_providers_path + else + OpenProject::StaticRouting::StaticRouter.new.url_helpers.saml_provider_path(@provider) + end + end end end end diff --git a/modules/auth_saml/app/models/saml/provider.rb b/modules/auth_saml/app/models/saml/provider.rb index 9a7592247e96..3934e83fbdba 100644 --- a/modules/auth_saml/app/models/saml/provider.rb +++ b/modules/auth_saml/app/models/saml/provider.rb @@ -6,7 +6,7 @@ class Provider < OpenStruct include ActiveModel::Conversion attr_accessor :readonly - validates_presence_of :name, :display_name + validates_presence_of :display_name def initialize(readonly: false, **attributes) self.readonly = readonly @@ -19,6 +19,10 @@ def id name end + def name + super.presence || "saml-#{uuid}" + end + def sp_entity_id super.presence || issuer.presence || url_helpers.root_url end @@ -99,6 +103,7 @@ def set_default_attributes(attributes) self.name_identifier_format = attributes.fetch(:name_identifier_format, DEFAULT_NAME_IDENTIFIER_FORMAT) self.issuer = attributes.fetch(:issuer, url_helpers.root_url) self.request_attributes = attributes.fetch(:request_attributes, default_request_attributes) + self.uuid ||= SecureRandom.hex(4) end def default_request_attributes @@ -132,7 +137,7 @@ def default_request_attributes def derived_attributes { - assertion_consumer_service_url: url_helpers.root_url.join("/auth/#{name}/callback") + assertion_consumer_service_url: URI.join(url_helpers.root_url, "/auth/#{name}/callback").to_s, } end diff --git a/modules/auth_saml/app/views/saml/providers/new.html.erb b/modules/auth_saml/app/views/saml/providers/new.html.erb index 2a7e3bcf7b62..6973dcdd6c84 100644 --- a/modules/auth_saml/app/views/saml/providers/new.html.erb +++ b/modules/auth_saml/app/views/saml/providers/new.html.erb @@ -1,33 +1,23 @@ <% html_title(t(:label_administration), t('saml.providers.label_add_new')) -%> -<% local_assigns[:additional_breadcrumb] = t('saml.providers.label_add_new') %> -<%= toolbar title: t('saml.providers.label_add_new') %> +<%= render(Primer::OpenProject::PageHeader.new) do |header| %> + <% header.with_title { t('saml.providers.label_add_new') } %> -<%= error_messages_for @provider %> - - -<%= - primer_form_with( - model: @provider, - url: { action: :import }, - method: :post - ) do |form| %> + <% header.with_breadcrumbs([{ href: admin_index_path, text: t("label_administration")}, + { href: saml_providers_path, text: t('saml.providers.plural') }, + t('saml.providers.label_add_new')]) %> - <%= - render(Primer::OpenProject::FlexLayout.new) do |flex| - flex.with_row(mb: 3) do - render(Saml::Providers::NameInputForm.new(form)) - end + <% header.with_description do %> + <%= link_translate( + 'saml.instructions.new_description', + links: { + docs_url: ::OpenProject::Static::Links[:sysadmin_docs][:saml][:href], + }, + target: "_blank" + ) %> + <% end %> +<% end %> - flex.with_row(mb: 3) do - render(Saml::Providers::MetadataUrlForm.new(form)) - end +<%= error_messages_for @provider %> - flex.with_row do - render( - Saml::Providers::SubmitOrCancelForm.new(form) - ) - end - end - %> -<% end %> +<%= render(Saml::Providers::ViewComponent.new(@provider, edit_state: :name)) %> diff --git a/modules/auth_saml/app/views/saml/providers/show.html.erb b/modules/auth_saml/app/views/saml/providers/show.html.erb new file mode 100644 index 000000000000..1b37c4b73442 --- /dev/null +++ b/modules/auth_saml/app/views/saml/providers/show.html.erb @@ -0,0 +1,30 @@ +<% html_title(t(:label_administration), t('saml.providers.plural'), @provider.display_name) -%> + +<%= render(Primer::OpenProject::PageHeader.new) do |header| %> + <% header.with_title { @provider.display_name } %> + + <% header.with_breadcrumbs([{ href: admin_index_path, text: t("label_administration")}, + { href: saml_providers_path, text: t('saml.providers.plural') }, + @provider.display_name]) %> + + <% + header.with_action_button(tag: :a, + scheme: :danger, + mobile_icon: :trash, + mobile_label: t(:button_delete), + size: :medium, + href: saml_provider_path(@provider), + aria: { label: I18n.t(:button_delete) }, + data: { + confirm: t(:text_are_you_sure), + method: :delete, + }, + title: I18n.t(:button_delete)) do |button| + button.with_leading_visual_icon(icon: :trash) + t(:button_delete) + end + %> + +<% end %> + +<%= render(Saml::Providers::ViewComponent.new(@provider)) %> diff --git a/modules/auth_saml/config/locales/en.yml b/modules/auth_saml/config/locales/en.yml index 78112af520d1..c0dd0a05ae8d 100644 --- a/modules/auth_saml/config/locales/en.yml +++ b/modules/auth_saml/config/locales/en.yml @@ -21,6 +21,8 @@ en: invalid_url: "Provided metadata URL is invalid. Provide a HTTP(s) URL." error: "Failed to retrieve the identity provider metadata: %{error}" providers: + label_automatic_configuration: Automatic configuration + label_metadata: Metadata label_add_new: New SAML identity provider label_edit: Edit SAML identity provider %{name} no_results_table: No SAML identity providers have been defined yet. @@ -37,11 +39,16 @@ en: legend: 'These attributes are added to the SAML XML metadata to signify to the identify provider which attributes OpenProject requires.' name: 'Requested attribute key' format: 'Attribute format' + section_texts: + display_name: "Configure the display name of the SAML provider." + metadata: "Pre-fill configuration using a metadata URL or by pasting metadata XML" settings: metadata_url: Identity provider metadata URL (Optional) - setting_instructions: - name: > - The identifier of the SAML provider. This needs to be alphanumeric, unique, and is used in the URL fragment to identify the provider. + instructions: + new_description: > + Add a new SAML provider. Please refer to our [documentation on configuring SAML providers](docs_url). + display_name: > + The name of the provider. This will be displayed as the login button and in the list of providers. metadata_url: > Enter the URL of the identity provider XML metadata endpoint. If provided, OpenProject will try to prefill all available configuration options from your identity provider. diff --git a/modules/auth_saml/config/routes.rb b/modules/auth_saml/config/routes.rb index cf0ddb5cf771..e09a10516a07 100644 --- a/modules/auth_saml/config/routes.rb +++ b/modules/auth_saml/config/routes.rb @@ -1,7 +1,7 @@ Rails.application.routes.draw do scope :admin do namespace :saml do - resources :providers, only: %i[index new create edit update destroy] do + resources :providers do collection do post :import end diff --git a/modules/storages/app/components/storages/admin/storage_view_information.rb b/modules/storages/app/components/storages/admin/storage_view_information.rb index d019b95d3835..f12f5a69d3f5 100644 --- a/modules/storages/app/components/storages/admin/storage_view_information.rb +++ b/modules/storages/app/components/storages/admin/storage_view_information.rb @@ -55,9 +55,9 @@ def configuration_check_label_for(*configs) return if storage.configuration_checks.values.none? if storage.configuration_checks.slice(*configs.map(&:to_sym)).values.all? - status_label(I18n.t("storages.label_completed"), scheme: :success, test_selector: "label-#{configs.join('-')}-status") + status_label(I18n.t(:label_completed), scheme: :success, test_selector: "label-#{configs.join('-')}-status") else - status_label(I18n.t("storages.label_incomplete"), scheme: :attention, test_selector: "label-#{configs.join('-')}-status") + status_label(I18n.t(:label_incomplete), scheme: :attention, test_selector: "label-#{configs.join('-')}-status") end end diff --git a/modules/storages/config/locales/en.yml b/modules/storages/config/locales/en.yml index 0471043a59f9..e568aa618f07 100644 --- a/modules/storages/config/locales/en.yml +++ b/modules/storages/config/locales/en.yml @@ -287,7 +287,6 @@ en: label_active: Active label_add_new_storage: Add new storage label_automatic_folder: New folder with automatically managed permissions - label_completed: Completed label_creation_time: Creation time label_creator: Creator label_delete_storage: Delete storage @@ -299,7 +298,6 @@ en: label_file_storage: File storage label_host: Host URL label_inactive: Inactive - label_incomplete: Incomplete label_managed_project_folders: application_password: Application password automatically_managed_folders: Automatically managed folders From cd2aea31e82f5f9666e795552c3a05d0ffcc6af3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 1 Aug 2024 13:35:48 +0200 Subject: [PATCH 07/80] Add blankslate --- .../border_box_table_component.html.erb | 10 +++++++--- .../op_primer/border_box_table_component.rb | 20 +++++++++++++++++++ .../saml/providers/table_component.rb | 12 +++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/app/components/op_primer/border_box_table_component.html.erb b/app/components/op_primer/border_box_table_component.html.erb index 6b28c0c49e19..60a5ca6177b0 100644 --- a/app/components/op_primer/border_box_table_component.html.erb +++ b/app/components/op_primer/border_box_table_component.html.erb @@ -44,9 +44,13 @@ See COPYRIGHT and LICENSE files for more details. end end - rows.each do |row| - component.with_row(scheme: :default) do - render(row_class.new(row:, table: self)) + component.with_row(scheme: :default) do + if rows.empty? + render_blank_slate + else + rows.each do |row| + render(row_class.new(row:, table: self)) + end end end end diff --git a/app/components/op_primer/border_box_table_component.rb b/app/components/op_primer/border_box_table_component.rb index 84f5dee83613..f0a464c8c6f3 100644 --- a/app/components/op_primer/border_box_table_component.rb +++ b/app/components/op_primer/border_box_table_component.rb @@ -54,5 +54,25 @@ def has_actions? def sortable? false end + + def render_blank_slate + render(Primer::Beta::Blankslate.new(border: false)) do |component| + component.with_visual_icon(icon: blank_icon, size: :medium) if blank_icon + component.with_heading(tag: :h2) { blank_title } + component.with_description { blank_description } + end + end + + def blank_title + I18n.t(:label_nothing_display) + end + + def blank_description + I18n.t(:no_results_title_text) + end + + def blank_icon + nil + end end end diff --git a/modules/auth_saml/app/components/saml/providers/table_component.rb b/modules/auth_saml/app/components/saml/providers/table_component.rb index af0fb5b91bb6..fde31d890dbe 100644 --- a/modules/auth_saml/app/components/saml/providers/table_component.rb +++ b/modules/auth_saml/app/components/saml/providers/table_component.rb @@ -21,6 +21,18 @@ def headers ['users', { caption: I18n.t(:label_user_plural) }] ] end + + def blank_title + I18n.t('saml.providers.label_empty_title') + end + + def blank_description + I18n.t('saml.providers.label_empty_description') + end + + def blank_icon + :key + end end end end From d1e76d8d202d64ff6856cf0825b738ffda6f4b9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 1 Aug 2024 13:36:00 +0200 Subject: [PATCH 08/80] Don't store as settings, but as record --- .../controllers/saml/providers_controller.rb | 7 +- modules/auth_saml/app/models/saml/provider.rb | 174 +++++------------- .../app/views/saml/providers/index.html.erb | 2 +- .../app/views/saml/providers/new.html.erb | 2 - modules/auth_saml/config/locales/en.yml | 2 + .../20240801105918_add_saml_provider.rb | 13 ++ 6 files changed, 72 insertions(+), 128 deletions(-) create mode 100644 modules/auth_saml/db/migrate/20240801105918_add_saml_provider.rb diff --git a/modules/auth_saml/app/controllers/saml/providers_controller.rb b/modules/auth_saml/app/controllers/saml/providers_controller.rb index ec8b4b9f1202..2e9e42b006db 100644 --- a/modules/auth_saml/app/controllers/saml/providers_controller.rb +++ b/modules/auth_saml/app/controllers/saml/providers_controller.rb @@ -7,12 +7,15 @@ class ProvidersController < ::ApplicationController before_action :check_ee before_action :find_provider, only: %i[show edit update destroy] - def index; end + def index + @providers = Saml::Provider.all + end + def edit; end def show; end def new - @provider = ::Saml::Provider.new(name: "saml") + @provider = ::Saml::Provider.new end def import diff --git a/modules/auth_saml/app/models/saml/provider.rb b/modules/auth_saml/app/models/saml/provider.rb index 3934e83fbdba..b4166f4b6bf3 100644 --- a/modules/auth_saml/app/models/saml/provider.rb +++ b/modules/auth_saml/app/models/saml/provider.rb @@ -1,148 +1,76 @@ module Saml - class Provider < OpenStruct - DEFAULT_NAME_IDENTIFIER_FORMAT = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'.freeze - - include ActiveModel::Validations - include ActiveModel::Conversion - - attr_accessor :readonly - validates_presence_of :display_name + class Provider < ApplicationRecord + self.table_name = "saml_providers" - def initialize(readonly: false, **attributes) - self.readonly = readonly - super(attributes) + DEFAULT_NAME_IDENTIFIER_FORMAT = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'.freeze + DEFAULT_MAIL_MAPPING = %w[mail email Email emailAddress emailaddress + urn:oid:0.9.2342.19200300.100.1.3 http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress].freeze - set_default_attributes(attributes) - end + DEFAULT_FIRSTNAME_MAPPING = %w[givenName givenname given_name given_name + urn:oid:2.5.4.42 http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname].freeze - def id - name - end + DEFAULT_LASTNAME_MAPPING = %w[surname sur_name sn + urn:oid:2.5.4.4 http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname].freeze - def name - super.presence || "saml-#{uuid}" - end + DEFAULT_REQUESTED_ATTRIBUTES = [ + { + "name" => "mail", + "name_format" => "urn:oasis:names:tc:SAML:2.0:attrname-format:basic", + "friendly_name" => "Email address", + "is_required" => true + }, + { + "name" => "givenName", + "name_format" => "urn:oasis:names:tc:SAML:2.0:attrname-format:basic", + "friendly_name" => "Given name", + "is_required" => true + }, + { + "name" => "sn", + "name_format" => "urn:oasis:names:tc:SAML:2.0:attrname-format:basic", + "friendly_name" => "Family name", + "is_required" => true + } + ].freeze - def sp_entity_id - super.presence || issuer.presence || url_helpers.root_url - end + store_attribute :options, :sp_entity_id, :string, default: -> { OpenProject::StaticRouting::StaticUrlHelpers.new.root_url } + store_attribute :options, :name_identifier_format, :string, default: -> { DEFAULT_NAME_IDENTIFIER_FORMAT } + store_attribute :options, :metadata_url, :string + store_attribute :options, :metadata_xml, :string - def attribute_statements - super.presence || {} - end + store_attribute :options, :mapping_login, :json, default: -> { DEFAULT_MAIL_MAPPING } + store_attribute :options, :mapping_mail, :json, default: -> { DEFAULT_MAIL_MAPPING } + store_attribute :options, :mapping_firstname, :json, default: -> { DEFAULT_FIRSTNAME_MAPPING } + store_attribute :options, :mapping_lastname, :json, default: -> { DEFAULT_LASTNAME_MAPPING } + store_attribute :options, :mapping_uid, :json - %w[login email first_name last_name].each do |accessor| - define_method("#{accessor}_mapping") do - value = attribute_statements[accessor] - warn value - if value.is_a?(Array) - value.join(', ') - else - value - end - end + store_attribute :options, :request_attributes, :json, default: -> { DEFAULT_REQUESTED_ATTRIBUTES } - define_method("#{accessor}_mapping=") do |newval| - parsed = newval.split(/\s*,\s*/) - attribute_statements[accessor] = parsed - end - end + attr_accessor :readonly + validates_presence_of :display_name - def new_record? - !persisted? + def slug + "saml-#{id}" end - def persisted? - uuid.present? + def assertion_consumer_service_url + URI.join(url_helpers.root_url, "/auth/#{name}/callback").to_s end def limit_self_registration? limit_self_registration end - def save - return false unless valid? - - Setting.plugin_openproject_auth_saml = setting_with_provider - - true - end - - def destroy - Setting.plugin_openproject_auth_saml = setting_without_provider - - true - end - - def setting_with_provider - setting.deep_merge "providers" => { name => to_h.stringify_keys } - end - def to_h - super - .reverse_merge(uuid: SecureRandom.uuid) - .merge(derived_attributes) - end - - def setting_without_provider - setting.tap do |s| - s["providers"].delete_if { |_, config| config['id'] == id } - end - end - - def setting - Hash(Setting.plugin_openproject_auth_saml).tap do |h| - h["providers"] ||= Hash.new - end + options + .merge( + name: slug, + display_name:, + assertion_consumer_service_url:, + ) end private - def set_default_attributes(attributes) - self.limit_self_registration = attributes.fetch(:limit_self_registration, false) - self.name_identifier_format = attributes.fetch(:name_identifier_format, DEFAULT_NAME_IDENTIFIER_FORMAT) - self.issuer = attributes.fetch(:issuer, url_helpers.root_url) - self.request_attributes = attributes.fetch(:request_attributes, default_request_attributes) - self.uuid ||= SecureRandom.hex(4) - end - - def default_request_attributes - [ - { - "name" => "mail", - "name_format" => "urn:oasis:names:tc:SAML:2.0:attrname-format:basic", - "friendly_name" => "Email address", - "is_required" => true - }, - { - "name" => "givenName", - "name_format" => "urn:oasis:names:tc:SAML:2.0:attrname-format:basic", - "friendly_name" => "Given name", - "is_required" => true - }, - { - "name" => "sn", - "name_format" => "urn:oasis:names:tc:SAML:2.0:attrname-format:basic", - "friendly_name" => "Family name", - "is_required" => true - }, - { - "name" => "uid", - "name_format" => "urn:oasis:names:tc:SAML:2.0:attrname-format:basic", - "friendly_name" => "Stable unique ID / login of the user", - "is_required" => true - } - ] - end - - def derived_attributes - { - assertion_consumer_service_url: URI.join(url_helpers.root_url, "/auth/#{name}/callback").to_s, - } - end - - def url_helpers - @url_helpers ||= OpenProject::StaticRouting::StaticUrlHelpers.new - end end end diff --git a/modules/auth_saml/app/views/saml/providers/index.html.erb b/modules/auth_saml/app/views/saml/providers/index.html.erb index 98c082840584..16c1bcfb4ef5 100644 --- a/modules/auth_saml/app/views/saml/providers/index.html.erb +++ b/modules/auth_saml/app/views/saml/providers/index.html.erb @@ -20,4 +20,4 @@ %> <% end %> -<%= render ::Saml::Providers::TableComponent.new(rows: providers) %> +<%= render ::Saml::Providers::TableComponent.new(rows: @providers) %> diff --git a/modules/auth_saml/app/views/saml/providers/new.html.erb b/modules/auth_saml/app/views/saml/providers/new.html.erb index 6973dcdd6c84..34ada5f06889 100644 --- a/modules/auth_saml/app/views/saml/providers/new.html.erb +++ b/modules/auth_saml/app/views/saml/providers/new.html.erb @@ -18,6 +18,4 @@ <% end %> <% end %> -<%= error_messages_for @provider %> - <%= render(Saml::Providers::ViewComponent.new(@provider, edit_state: :name)) %> diff --git a/modules/auth_saml/config/locales/en.yml b/modules/auth_saml/config/locales/en.yml index c0dd0a05ae8d..5baa6aeea475 100644 --- a/modules/auth_saml/config/locales/en.yml +++ b/modules/auth_saml/config/locales/en.yml @@ -21,6 +21,8 @@ en: invalid_url: "Provided metadata URL is invalid. Provide a HTTP(s) URL." error: "Failed to retrieve the identity provider metadata: %{error}" providers: + label_empty_title: "No SAML providers configured yet." + label_empty_description: "Add a provider to see them here." label_automatic_configuration: Automatic configuration label_metadata: Metadata label_add_new: New SAML identity provider diff --git a/modules/auth_saml/db/migrate/20240801105918_add_saml_provider.rb b/modules/auth_saml/db/migrate/20240801105918_add_saml_provider.rb new file mode 100644 index 000000000000..2eac33eae9f6 --- /dev/null +++ b/modules/auth_saml/db/migrate/20240801105918_add_saml_provider.rb @@ -0,0 +1,13 @@ +class AddSamlProvider < ActiveRecord::Migration[7.1] + def change + create_table :saml_providers do |t| + t.string :display_name, null: false, index: { unique: true } + t.boolean :available, null: false, default: true + t.boolean :limit_self_registration, null: false, default: false + t.jsonb :options, default: {}, null: false + t.references :creator, null: false, index: true, foreign_key: { to_table: :users } + + t.timestamps + end + end +end From 69946a637c73700046a56b258a3a349861be3a0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 1 Aug 2024 14:02:05 +0200 Subject: [PATCH 09/80] Services + Contracts --- .../auth_saml/app/constants/saml/defaults.rb | 64 ++++++++++++++++++ .../contracts/saml/providers/base_contract.rb | 40 +++++++++++ .../saml/providers/create_contract.rb | 33 ++++++++++ .../saml/providers/delete_contract.rb | 35 ++++++++++ .../saml/providers/update_contract.rb | 34 ++++++++++ .../controllers/saml/providers_controller.rb | 28 ++++---- modules/auth_saml/app/models/saml/provider.rb | 45 +++---------- .../services/saml/providers/create_service.rb | 34 ++++++++++ .../services/saml/providers/delete_service.rb | 33 ++++++++++ .../saml/providers/set_attributes_service.rb | 66 +++++++++++++++++++ .../services/saml/providers/update_service.rb | 34 ++++++++++ 11 files changed, 395 insertions(+), 51 deletions(-) create mode 100644 modules/auth_saml/app/constants/saml/defaults.rb create mode 100644 modules/auth_saml/app/contracts/saml/providers/base_contract.rb create mode 100644 modules/auth_saml/app/contracts/saml/providers/create_contract.rb create mode 100644 modules/auth_saml/app/contracts/saml/providers/delete_contract.rb create mode 100644 modules/auth_saml/app/contracts/saml/providers/update_contract.rb create mode 100644 modules/auth_saml/app/services/saml/providers/create_service.rb create mode 100644 modules/auth_saml/app/services/saml/providers/delete_service.rb create mode 100644 modules/auth_saml/app/services/saml/providers/set_attributes_service.rb create mode 100644 modules/auth_saml/app/services/saml/providers/update_service.rb diff --git a/modules/auth_saml/app/constants/saml/defaults.rb b/modules/auth_saml/app/constants/saml/defaults.rb new file mode 100644 index 000000000000..c6e930816af6 --- /dev/null +++ b/modules/auth_saml/app/constants/saml/defaults.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml + module Defaults + NAME_IDENTIFIER_FORMAT = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'.freeze + MAIL_MAPPING = %w[mail email Email emailAddress emailaddress + urn:oid:0.9.2342.19200300.100.1.3 http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress].freeze + + FIRSTNAME_MAPPING = %w[givenName givenname given_name given_name + urn:oid:2.5.4.42 http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname].freeze + + LASTNAME_MAPPING = %w[surname sur_name sn + urn:oid:2.5.4.4 http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname].freeze + + REQUESTED_ATTRIBUTES = [ + { + "name" => "mail", + "name_format" => "urn:oasis:names:tc:SAML:2.0:attrname-format:basic", + "friendly_name" => "Email address", + "is_required" => true + }, + { + "name" => "givenName", + "name_format" => "urn:oasis:names:tc:SAML:2.0:attrname-format:basic", + "friendly_name" => "Given name", + "is_required" => true + }, + { + "name" => "sn", + "name_format" => "urn:oasis:names:tc:SAML:2.0:attrname-format:basic", + "friendly_name" => "Family name", + "is_required" => true + } + ].freeze + end +end diff --git a/modules/auth_saml/app/contracts/saml/providers/base_contract.rb b/modules/auth_saml/app/contracts/saml/providers/base_contract.rb new file mode 100644 index 000000000000..63a5c164f0bc --- /dev/null +++ b/modules/auth_saml/app/contracts/saml/providers/base_contract.rb @@ -0,0 +1,40 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml + module Providers + class BaseContract < ModelContract + include RequiresAdminGuard + + def self.model + Saml::Provider + end + + attribute :display_name + end + end +end diff --git a/modules/auth_saml/app/contracts/saml/providers/create_contract.rb b/modules/auth_saml/app/contracts/saml/providers/create_contract.rb new file mode 100644 index 000000000000..555aeac3604f --- /dev/null +++ b/modules/auth_saml/app/contracts/saml/providers/create_contract.rb @@ -0,0 +1,33 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml + module Providers + class CreateContract < BaseContract + end + end +end diff --git a/modules/auth_saml/app/contracts/saml/providers/delete_contract.rb b/modules/auth_saml/app/contracts/saml/providers/delete_contract.rb new file mode 100644 index 000000000000..aae9539da9f0 --- /dev/null +++ b/modules/auth_saml/app/contracts/saml/providers/delete_contract.rb @@ -0,0 +1,35 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml + module Providers + class DeleteContract < ::DeleteContract + delete_permission :admin + end + end +end diff --git a/modules/auth_saml/app/contracts/saml/providers/update_contract.rb b/modules/auth_saml/app/contracts/saml/providers/update_contract.rb new file mode 100644 index 000000000000..90e4bf6fd0f6 --- /dev/null +++ b/modules/auth_saml/app/contracts/saml/providers/update_contract.rb @@ -0,0 +1,34 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml + module Providers + class UpdateContract < News::BaseContract + end + end +end diff --git a/modules/auth_saml/app/controllers/saml/providers_controller.rb b/modules/auth_saml/app/controllers/saml/providers_controller.rb index 2e9e42b006db..5a1086bd237f 100644 --- a/modules/auth_saml/app/controllers/saml/providers_controller.rb +++ b/modules/auth_saml/app/controllers/saml/providers_controller.rb @@ -29,12 +29,15 @@ def import end def create - @provider = ::Saml::Provider.new(**create_params) + call = ::Saml::Providers::CreateService + .new(user: User.current) + .call(**create_params) - if @provider.save + if call.success? flash[:notice] = I18n.t(:notice_successful_create) - redirect_to saml_provider_path(@provider) + redirect_to saml_provider_path(call.result) else + @provider = call.result render action: :new end end @@ -52,7 +55,10 @@ def update end def destroy - if @provider.destroy + call = ::Saml::Providers::DeleteService + .new(model: @provider, user: User.current) + + if call.success? flash[:notice] = I18n.t(:notice_successful_delete) else flash[:error] = I18n.t(:error_failed_to_delete_entry) @@ -100,7 +106,7 @@ def import_metadata end def create_params - params.require(:saml_provider).permit(:name, :display_name, :identifier, :secret, :limit_self_registration) + params.require(:saml_provider).permit(:display_name) end def update_params @@ -108,15 +114,9 @@ def update_params end def find_provider - @provider = providers.find { |provider| provider.id.to_s == params[:id].to_s } - if @provider.nil? - render_404 - end - end - - def providers - @providers ||= OpenProject::AuthSaml.providers + @provider = Saml::Provider.find(params[:id]) + rescue ActiveRecord::RecordNotFound + render_404 end - helper_method :providers end end diff --git a/modules/auth_saml/app/models/saml/provider.rb b/modules/auth_saml/app/models/saml/provider.rb index b4166f4b6bf3..abc2e786405b 100644 --- a/modules/auth_saml/app/models/saml/provider.rb +++ b/modules/auth_saml/app/models/saml/provider.rb @@ -2,49 +2,20 @@ module Saml class Provider < ApplicationRecord self.table_name = "saml_providers" - DEFAULT_NAME_IDENTIFIER_FORMAT = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'.freeze - DEFAULT_MAIL_MAPPING = %w[mail email Email emailAddress emailaddress - urn:oid:0.9.2342.19200300.100.1.3 http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress].freeze + belongs_to :creator, class_name: "User" - DEFAULT_FIRSTNAME_MAPPING = %w[givenName givenname given_name given_name - urn:oid:2.5.4.42 http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname].freeze - - DEFAULT_LASTNAME_MAPPING = %w[surname sur_name sn - urn:oid:2.5.4.4 http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname].freeze - - DEFAULT_REQUESTED_ATTRIBUTES = [ - { - "name" => "mail", - "name_format" => "urn:oasis:names:tc:SAML:2.0:attrname-format:basic", - "friendly_name" => "Email address", - "is_required" => true - }, - { - "name" => "givenName", - "name_format" => "urn:oasis:names:tc:SAML:2.0:attrname-format:basic", - "friendly_name" => "Given name", - "is_required" => true - }, - { - "name" => "sn", - "name_format" => "urn:oasis:names:tc:SAML:2.0:attrname-format:basic", - "friendly_name" => "Family name", - "is_required" => true - } - ].freeze - - store_attribute :options, :sp_entity_id, :string, default: -> { OpenProject::StaticRouting::StaticUrlHelpers.new.root_url } - store_attribute :options, :name_identifier_format, :string, default: -> { DEFAULT_NAME_IDENTIFIER_FORMAT } + store_attribute :options, :sp_entity_id, :string + store_attribute :options, :name_identifier_format, :string store_attribute :options, :metadata_url, :string store_attribute :options, :metadata_xml, :string - store_attribute :options, :mapping_login, :json, default: -> { DEFAULT_MAIL_MAPPING } - store_attribute :options, :mapping_mail, :json, default: -> { DEFAULT_MAIL_MAPPING } - store_attribute :options, :mapping_firstname, :json, default: -> { DEFAULT_FIRSTNAME_MAPPING } - store_attribute :options, :mapping_lastname, :json, default: -> { DEFAULT_LASTNAME_MAPPING } + store_attribute :options, :mapping_login, :json + store_attribute :options, :mapping_mail, :json + store_attribute :options, :mapping_firstname, :json + store_attribute :options, :mapping_lastname, :json store_attribute :options, :mapping_uid, :json - store_attribute :options, :request_attributes, :json, default: -> { DEFAULT_REQUESTED_ATTRIBUTES } + store_attribute :options, :request_attributes, :json attr_accessor :readonly validates_presence_of :display_name diff --git a/modules/auth_saml/app/services/saml/providers/create_service.rb b/modules/auth_saml/app/services/saml/providers/create_service.rb new file mode 100644 index 000000000000..45edbfdcf926 --- /dev/null +++ b/modules/auth_saml/app/services/saml/providers/create_service.rb @@ -0,0 +1,34 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml + module Providers + class CreateService < BaseServices::Create + end + end +end diff --git a/modules/auth_saml/app/services/saml/providers/delete_service.rb b/modules/auth_saml/app/services/saml/providers/delete_service.rb new file mode 100644 index 000000000000..ea5bae1a4454 --- /dev/null +++ b/modules/auth_saml/app/services/saml/providers/delete_service.rb @@ -0,0 +1,33 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml + module Providers + class DeleteService < BaseServices::Delete + end + end +end diff --git a/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb b/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb new file mode 100644 index 000000000000..3ecb58d06dff --- /dev/null +++ b/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb @@ -0,0 +1,66 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml + module Providers + class SetAttributesService < BaseServices::SetAttributes + include Attachments::SetReplacements + + private + + def set_default_attributes(*) + model.change_by_system do + set_default_creator + set_attribute_mapping + set_issuer + set_name_identifier_format + end + end + + def set_name_identifier_format + model.name_identifier_format ||= Saml::Defaults::NAME_IDENTIFIER_FORMAT + end + + def set_default_creator + model.creator = user + end + + def set_attribute_mapping + model.mapping_login ||= Saml::Defaults::MAIL_MAPPING + model.mapping_mail ||= Saml::Defaults::MAIL_MAPPING + model.mapping_firstname ||= Saml::Defaults::FIRSTNAME_MAPPING + model.mapping_lastname ||= Saml::Defaults::LASTNAME_MAPPING + model.request_attributes ||= Saml::Defaults::REQUESTED_ATTRIBUTES + end + + def set_issuer + model.sp_entity_id ||= OpenProject::StaticRouting::StaticUrlHelpers.new.root_url + end + end + end +end diff --git a/modules/auth_saml/app/services/saml/providers/update_service.rb b/modules/auth_saml/app/services/saml/providers/update_service.rb new file mode 100644 index 000000000000..558bd3cb02a2 --- /dev/null +++ b/modules/auth_saml/app/services/saml/providers/update_service.rb @@ -0,0 +1,34 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml + module Providers + class UpdateService < BaseServices::Update + end + end +end From a4c9860730af227dfcb8bf66422081af4dec7c95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 1 Aug 2024 15:16:03 +0200 Subject: [PATCH 10/80] Better table columns --- .../op_primer/border_box_table_component.html.erb | 2 +- .../app/components/saml/providers/row_component.rb | 14 +++++++++++--- .../components/saml/providers/table_component.rb | 6 ++++-- modules/auth_saml/app/models/saml/provider.rb | 3 +++ 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/app/components/op_primer/border_box_table_component.html.erb b/app/components/op_primer/border_box_table_component.html.erb index 60a5ca6177b0..f7488e61583d 100644 --- a/app/components/op_primer/border_box_table_component.html.erb +++ b/app/components/op_primer/border_box_table_component.html.erb @@ -49,7 +49,7 @@ See COPYRIGHT and LICENSE files for more details. render_blank_slate else rows.each do |row| - render(row_class.new(row:, table: self)) + concat render(row_class.new(row:, table: self)) end end end diff --git a/modules/auth_saml/app/components/saml/providers/row_component.rb b/modules/auth_saml/app/components/saml/providers/row_component.rb index ef5ef2f5eb4d..d76e40b5f184 100644 --- a/modules/auth_saml/app/components/saml/providers/row_component.rb +++ b/modules/auth_saml/app/components/saml/providers/row_component.rb @@ -11,12 +11,12 @@ def name href: url_for(action: :show, id: provider.id) )) { provider.display_name || provider.name } - if provider.idp_sso_target_url + if provider.idp_sso_service_url concat render(Primer::Beta::Text.new( tag: :p, font_size: :small, color: :subtle - )) { provider.idp_sso_target_url } + )) { provider.idp_sso_service_url } end end @@ -33,7 +33,15 @@ def edit_link end def users - "1234" + User.where("identity_url LIKE ?", "%#{provider.slug}%").count.to_s + end + + def creator + helpers.avatar(provider.creator, size: :mini, hide_name: false) + end + + def created_at + helpers.format_time provider.created_at end def delete_link diff --git a/modules/auth_saml/app/components/saml/providers/table_component.rb b/modules/auth_saml/app/components/saml/providers/table_component.rb index fde31d890dbe..564072dc3dc0 100644 --- a/modules/auth_saml/app/components/saml/providers/table_component.rb +++ b/modules/auth_saml/app/components/saml/providers/table_component.rb @@ -1,7 +1,7 @@ module Saml module Providers class TableComponent < ::OpPrimer::BorderBoxTableComponent - columns :name, :users + columns :name, :users, :creator, :created_at def initial_sort %i[id asc] @@ -18,7 +18,9 @@ def empty_row_message def headers [ ['name', { caption: I18n.t('attributes.name') }], - ['users', { caption: I18n.t(:label_user_plural) }] + ['users', { caption: I18n.t(:label_user_plural) }], + ['creator', { caption: I18n.t("js.label_created_by") }], + ['created_at', { caption: Saml::Provider.human_attribute_name(:created_at) }] ] end diff --git a/modules/auth_saml/app/models/saml/provider.rb b/modules/auth_saml/app/models/saml/provider.rb index abc2e786405b..ff5db3dbd163 100644 --- a/modules/auth_saml/app/models/saml/provider.rb +++ b/modules/auth_saml/app/models/saml/provider.rb @@ -9,6 +9,9 @@ class Provider < ApplicationRecord store_attribute :options, :metadata_url, :string store_attribute :options, :metadata_xml, :string + store_attribute :options, :idp_sso_service_url, :string + store_attribute :options, :idp_slo_service_url, :string + store_attribute :options, :mapping_login, :json store_attribute :options, :mapping_mail, :json store_attribute :options, :mapping_firstname, :json From 50c8b4c2b8a11dfed84eef5e68ea6c3e0c76c9fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 1 Aug 2024 15:28:47 +0200 Subject: [PATCH 11/80] Edit state --- frontend/src/global_styles/openproject.sass | 1 + .../src/global_styles/primer/_overrides.sass | 3 + .../dynamic/admin/saml.controller.ts | 253 ++++++++++++++++++ .../show-when-checked.controller.ts | 30 +++ frontend/src/stimulus/setup.ts | 2 + modules/auth_saml/app/components/_index.sass | 1 + .../sections/metadata_component.html.erb | 38 ++- .../sections/metadata_form_component.html.erb | 23 +- .../sections/name_component.html.erb | 35 ++- .../saml/providers/view_component.html.erb | 52 ---- .../saml/providers/view_component.sass | 4 + .../contracts/saml/providers/base_contract.rb | 1 + .../saml/providers/update_contract.rb | 5 +- .../controllers/saml/providers_controller.rb | 67 +++-- .../saml/providers/metadata_checkbox_form.rb | 51 ++++ .../forms/saml/providers/metadata_xml_form.rb | 45 ++++ .../saml/providers/submit_or_cancel_form.rb | 1 + modules/auth_saml/app/models/saml/provider.rb | 11 +- ..._service.rb => update_metadata_service.rb} | 44 +-- .../app/views/saml/providers/edit.html.erb | 32 ++- modules/auth_saml/config/locales/en.yml | 10 +- modules/auth_saml/config/routes.rb | 4 +- 22 files changed, 576 insertions(+), 137 deletions(-) create mode 100644 frontend/src/stimulus/controllers/dynamic/admin/saml.controller.ts create mode 100644 frontend/src/stimulus/controllers/show-when-checked.controller.ts create mode 100644 modules/auth_saml/app/components/_index.sass create mode 100644 modules/auth_saml/app/components/saml/providers/view_component.sass create mode 100644 modules/auth_saml/app/forms/saml/providers/metadata_checkbox_form.rb create mode 100644 modules/auth_saml/app/forms/saml/providers/metadata_xml_form.rb rename modules/auth_saml/app/services/saml/{metadata_parser_service.rb => update_metadata_service.rb} (71%) diff --git a/frontend/src/global_styles/openproject.sass b/frontend/src/global_styles/openproject.sass index 6048a0657f7b..bcede07f87ec 100644 --- a/frontend/src/global_styles/openproject.sass +++ b/frontend/src/global_styles/openproject.sass @@ -22,6 +22,7 @@ @import "../../../modules/meeting/app/components/_index.sass" @import "../../../modules/overviews/app/components/_index.sass" @import "../../../modules/storages/app/components/_index.sass" +@import "../../../modules/auth_saml/app/components/_index.sass" // Component specific Styles @import "../../../app/components/_index.sass" diff --git a/frontend/src/global_styles/primer/_overrides.sass b/frontend/src/global_styles/primer/_overrides.sass index 570bc7c90340..227f32c61b26 100644 --- a/frontend/src/global_styles/primer/_overrides.sass +++ b/frontend/src/global_styles/primer/_overrides.sass @@ -52,6 +52,9 @@ action-menu @media screen and (min-width: $breakpoint-sm) scroll-behavior: smooth +ul.SegmentedControl + margin-left: 0 + /* Remove margin-left: 2rem from Breadcrumbs */ #breadcrumb, page-header, diff --git a/frontend/src/stimulus/controllers/dynamic/admin/saml.controller.ts b/frontend/src/stimulus/controllers/dynamic/admin/saml.controller.ts new file mode 100644 index 000000000000..4286e4765154 --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/admin/saml.controller.ts @@ -0,0 +1,253 @@ +/* + * -- copyright + * OpenProject is an open source project management software. + * Copyright (C) 2023 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. + * ++ + */ + +import { Controller } from '@hotwired/stimulus'; + +export default class CustomFieldsController extends Controller { + static targets = [ + 'format', + 'dragContainer', + + 'customOptionDefaults', + 'customOptionRow', + + 'allowNonOpenVersions', + 'defaultBool', + 'defaultLongText', + 'defaultText', + 'length', + 'multiSelect', + 'possibleValues', + 'regexp', + 'searchable', + 'textOrientation', + ]; + + static values = { + formatConfig: Array, + }; + + declare readonly formatConfigValue:[string, string, string[]][]; + + declare readonly formatTarget:HTMLInputElement; + declare readonly dragContainerTarget:HTMLElement; + declare readonly hasDragContainerTarget:boolean; + + declare readonly customOptionDefaultsTargets:HTMLInputElement[]; + declare readonly customOptionRowTargets:HTMLTableRowElement[]; + + declare readonly allowNonOpenVersionsTargets:HTMLElement[]; + declare readonly defaultBoolTargets:HTMLElement[]; + declare readonly defaultLongTextTargets:HTMLElement[]; + declare readonly defaultTextTargets:HTMLElement[]; + declare readonly lengthTargets:HTMLElement[]; + declare readonly multiSelectTargets:HTMLElement[]; + declare readonly possibleValuesTargets:HTMLElement[]; + declare readonly regexpTargets:HTMLElement[]; + declare readonly searchableTargets:HTMLInputElement[]; + declare readonly textOrientationTargets:HTMLElement[]; + + connect() { + if (this.hasDragContainerTarget) { + this.setupDragAndDrop(); + } + + this.formatChanged(); + } + + formatChanged() { + this.toggleFormat(this.formatTarget.value); + } + + moveRowUp(event:{ target:HTMLElement }) { + const row = event.target.closest('tr') as HTMLTableRowElement; + const idx = this.customOptionRowTargets.indexOf(row); + if (idx > 0) { + this.customOptionRowTargets[idx - 1].before(row); + } + + return false; + } + + moveRowDown(event:{ target:HTMLElement }) { + const row = event.target.closest('tr') as HTMLTableRowElement; + const idx = this.customOptionRowTargets.indexOf(row); + if (idx < this.customOptionRowTargets.length - 1) { + this.customOptionRowTargets[idx + 1].after(row); + } + + return false; + } + + moveRowToTheTop(event:{ target:HTMLElement }) { + const row = event.target.closest('tr') as HTMLTableRowElement; + const first = this.customOptionRowTargets[0]; + + if (first && first !== row) { + first.before(row); + } + + return false; + } + + moveRowToTheBottom(event:{ target:HTMLElement }) { + const row = event.target.closest('tr') as HTMLTableRowElement; + const last = this.customOptionRowTargets[this.customOptionRowTargets.length - 1]; + + if (last && last !== row) { + last.after(row); + } + + return false; + } + + removeOption(event:MouseEvent) { + const self = event.target as HTMLAnchorElement; + if (self.href === '#' || self.href.endsWith('/0')) { + const row = self.closest('tr'); + + if (row && this.customOptionRowTargets.length > 1) { + row.remove(); + } + + event.preventDefault(); + event.stopImmediatePropagation(); + } + return true; // send off deletion + } + + addOption() { + const count = this.customOptionRowTargets.length; + const last = this.customOptionRowTargets[count - 1]; + const dup = last.cloneNode(true) as HTMLElement; + + const input = dup.querySelector('.custom-option-value input') as HTMLInputElement; + + input.setAttribute('name', `custom_field[custom_options_attributes][${count}][value]`); + input.setAttribute('id', `custom_field_custom_options_attributes_${count}_value`); + input.value = ''; + + dup + .querySelector('.custom-option-id') + ?.remove(); + + const defaultValueCheckbox = dup.querySelector('input[type="checkbox"]') as HTMLInputElement; + const defaultValueHidden = dup.querySelector('input[type="hidden"]') as HTMLInputElement; + + defaultValueHidden.setAttribute('name', `custom_field[custom_options_attributes][${count}][default_value]`); + defaultValueHidden.removeAttribute('id'); + defaultValueCheckbox.setAttribute('name', `custom_field[custom_options_attributes][${count}][default_value]`); + defaultValueCheckbox.setAttribute('id', `custom_field_custom_options_attributes_${count}_default_value`); + defaultValueCheckbox.checked = false; + + last.insertAdjacentElement('afterend', dup); + + return false; + } + + uncheckOtherDefaults(event:{ target:HTMLElement }) { + const cb = event.target as HTMLInputElement; + + if (cb.checked) { + const multi = this.multiSelectTargets[0] as HTMLInputElement|undefined; + + if (multi?.checked === false) { + this.customOptionDefaultsTargets.forEach((el) => (el.checked = false)); + cb.checked = true; + } + } + } + + checkOnlyOne(event:{ target:HTMLElement }) { + const cb = event.target as HTMLInputElement; + + if (!cb.checked) { + this.customOptionDefaultsTargets + .filter((el) => el.checked) + .slice(1) + .forEach((el) => (el.checked = false)); + } + } + + private setupDragAndDrop() { + // Make custom fields draggable + // eslint-disable-next-line no-undef + const drake = dragula([this.dragContainerTarget], { + isContainer: () => false, + moves: (el, source, handle:HTMLElement) => handle.classList.contains('dragula-handle'), + accepts: () => true, + invalid: () => false, + direction: 'vertical', + copy: false, + copySortSource: false, + revertOnSpill: true, + removeOnSpill: false, + mirrorContainer: this.dragContainerTarget, + ignoreInputTextSelection: true, + }); + + // Setup autoscroll + void window.OpenProject.getPluginContext().then((pluginContext) => { + // eslint-disable-next-line no-new + new pluginContext.classes.DomAutoscrollService( + [ + document.getElementById('content-body') as HTMLElement, + ], + { + margin: 25, + maxSpeed: 10, + scrollWhenOutside: true, + autoScroll: () => drake.dragging, + }, + ); + }); + } + + private setActive(elements:HTMLElement[], active:boolean) { + elements.forEach((element) => { + element.hidden = !active; + element + .querySelectorAll('input, textarea') + .forEach((input) => { + input.disabled = !active; + }); + }); + } + + private toggleFormat(format:string) { + this.formatConfigValue.forEach(([targetsName, operator, formats]) => { + const active = operator === 'only' ? formats.includes(format) : !formats.includes(format); + const targets = this[`${targetsName}Targets` as keyof typeof this] as HTMLElement[]; + if (targets) { + this.setActive(targets, active); + } + }); + } +} diff --git a/frontend/src/stimulus/controllers/show-when-checked.controller.ts b/frontend/src/stimulus/controllers/show-when-checked.controller.ts new file mode 100644 index 000000000000..1674cc7a5272 --- /dev/null +++ b/frontend/src/stimulus/controllers/show-when-checked.controller.ts @@ -0,0 +1,30 @@ +import { ApplicationController } from 'stimulus-use'; + +export default class OpShowWhenCheckedController extends ApplicationController { + static targets = ['cause', 'effect']; + + static values = { + reversed: Boolean, + }; + + declare reversedValue:boolean; + + declare readonly hasReversedValue:boolean; + + declare readonly effectTargets:HTMLInputElement[]; + + causeTargetConnected(target:HTMLElement) { + target.addEventListener('change', this.toggleDisabled.bind(this)); + } + + causeTargetDisconnected(target:HTMLElement) { + target.removeEventListener('change', this.toggleDisabled.bind(this)); + } + + private toggleDisabled(evt:InputEvent):void { + const checked = (evt.target as HTMLInputElement).checked; + this.effectTargets.forEach((el) => { + el.hidden = (this.hasReversedValue && this.reversedValue) ? checked : !checked; + }); + } +} diff --git a/frontend/src/stimulus/setup.ts b/frontend/src/stimulus/setup.ts index a04e4bb5a18b..9d46a67f5ecc 100644 --- a/frontend/src/stimulus/setup.ts +++ b/frontend/src/stimulus/setup.ts @@ -8,6 +8,7 @@ import RefreshOnFormChangesController from './controllers/refresh-on-form-change import AsyncDialogController from './controllers/async-dialog.controller'; import PollForChangesController from './controllers/poll-for-changes.controller'; import TableHighlightingController from './controllers/table-highlighting.controller'; +import OpShowWhenCheckedController from "./controllers/show-when-checked.controller"; declare global { interface Window { @@ -26,6 +27,7 @@ instance.handleError = (error, message, detail) => { instance.register('application', OpApplicationController); instance.register('menus--main', MainMenuController); +instance.register('show-when-checked', OpShowWhenCheckedController); instance.register('disable-when-checked', OpDisableWhenCheckedController); instance.register('print', PrintController); instance.register('refresh-on-form-changes', RefreshOnFormChangesController); diff --git a/modules/auth_saml/app/components/_index.sass b/modules/auth_saml/app/components/_index.sass new file mode 100644 index 000000000000..49f9321ba2d8 --- /dev/null +++ b/modules/auth_saml/app/components/_index.sass @@ -0,0 +1 @@ +@import "saml/providers/view_component" diff --git a/modules/auth_saml/app/components/saml/providers/sections/metadata_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/metadata_component.html.erb index 8ef31fb231d1..48b387774e11 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/metadata_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/sections/metadata_component.html.erb @@ -1,18 +1,34 @@ <%= - flex_layout do |flex| - flex.with_column do - render(Primer::Beta::Text.new(font_weight: :semibold)) { t("saml.providers.label_metadata") } + grid_layout('op-saml-view-row', tag: :div, align_items: :center) do |grid| + grid.with_area(:title, mr: 3) do + concat render(Primer::Beta::Text.new(font_weight: :bold)) { t("saml.providers.label_metadata") } + if provider.has_metadata? + concat render(Primer::Beta::Label.new(scheme: :success, ml: 1)) { t(:label_completed) } + else + concat render(Primer::Beta::Label.new(scheme: :secondary, ml: 1)) { t(:label_incomplete) } + end end - if provider.metadata_url.present? - flex.with_column do - render(Primer::Beta::Label.new(scheme: :success, ml: 1)) { t(:label_completed) } + grid.with_area(:description) do + render(Primer::Beta::Text.new(tag: :p, font_size: :small, color: :subtle)) do + t("saml.providers.section_texts.metadata") + end + end + + grid.with_area(:action) do + flex_layout(justify_content: :flex_end) do |icons_container| + icons_container.with_column do + render( + Primer::Beta::IconButton.new( + icon: :pencil, + tag: :a, + scheme: :invisible, + href: edit_saml_provider_path(provider, edit_state: :metadata), + aria: { label: I18n.t(:label_edit) } + ) + ) + end end end - end -%> -<%= - render(Primer::Beta::Text.new(tag: :p, font_size: :small, color: :subtle)) do - t("saml.providers.section_texts.metadata") end %> diff --git a/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.html.erb index 1e8be68155e4..09b26b4060ea 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.html.erb @@ -1,24 +1,35 @@ <%= primer_form_with( model: provider, - url: saml_providers_path, + url: import_metadata_saml_provider_path(provider), + data: { + controller: "show-when-checked" + }, method: :post, ) do |form| flex_layout do |flex| flex.with_row do - render(Primer::Alpha::SegmentedControl.new("aria-label": "File view", full_width: full_width, hide_labels: hide_labels, size: size)) do |component| - component.with_item(label: "Preview", icon: icon, selected: true) - component.with_item(label: "Raw", icon: icon) - component.with_item(label: "Blame", icon: icon) + render(Primer::Beta::Text.new(tag: :p, mb: 4, font_size: :small)) do + t("saml.providers.section_texts.metadata_form") end end flex.with_row do + render(Saml::Providers::MetadataCheckboxForm.new(form, provider:)) + end + + flex.with_row(mt: 2, hidden: !provider.has_metadata?, data: { 'show-when-checked-target': "effect" }) do render(Saml::Providers::MetadataUrlForm.new(form)) end + flex.with_row(mt: 2, hidden: !provider.has_metadata?, data: { 'show-when-checked-target': "effect" }) do + render(Saml::Providers::MetadataXmlForm.new(form)) + end + flex.with_row(mt: 4) do - render(Saml::Providers::SubmitOrCancelForm.new(form, provider:, state: :metadata)) + render(Saml::Providers::SubmitOrCancelForm.new(form, + provider:, + state: :metadata)) end end end diff --git a/modules/auth_saml/app/components/saml/providers/sections/name_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/name_component.html.erb index a911047d0bb7..9564ef430504 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/name_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/sections/name_component.html.erb @@ -1,15 +1,30 @@ <%= - flex_layout do |flex| - flex.with_column do - render(Primer::Beta::Text.new(font_weight: :semibold)) { t("saml.providers.singular") } + grid_layout('op-saml-view-row', tag: :div, align_items: :center) do |grid| + grid.with_area(:title, mr: 3) do + concat render(Primer::Beta::Text.new(font_weight: :bold)) { t("saml.providers.singular") } + concat render(Primer::Beta::Label.new(scheme: :success, ml: 1)) { t(:label_completed) } end - flex.with_column do - render(Primer::Beta::Label.new(scheme: :success, ml: 1)) { t(:label_completed) } + + grid.with_area(:description) do + render(Primer::Beta::Text.new(tag: :p, font_size: :small, color: :subtle)) do + t("saml.providers.section_texts.display_name") + end + end + + grid.with_area(:action) do + flex_layout(justify_content: :flex_end) do |icons_container| + icons_container.with_column do + render( + Primer::Beta::IconButton.new( + icon: :pencil, + tag: :a, + scheme: :invisible, + href: edit_saml_provider_path(provider, edit_state: :name), + aria: { label: I18n.t(:label_edit) } + ) + ) + end + end end - end -%> -<%= - render(Primer::Beta::Text.new(tag: :p, font_size: :small, color: :subtle)) do - t("saml.providers.section_texts.display_name") end %> diff --git a/modules/auth_saml/app/components/saml/providers/view_component.html.erb b/modules/auth_saml/app/components/saml/providers/view_component.html.erb index 6231524a5266..58e9b882b0e1 100644 --- a/modules/auth_saml/app/components/saml/providers/view_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/view_component.html.erb @@ -12,7 +12,6 @@ end end - component.with_row(scheme: :neutral, color: :muted) do render(Primer::Beta::Text.new(font_weight: :semibold)) { I18n.t('saml.providers.label_automatic_configuration') } end @@ -24,56 +23,5 @@ render(Saml::Providers::Sections::MetadataComponent.new(provider)) end end - # - # component.with_row(scheme: :default) do - # render(Storages::Admin::OAuthClientInfoComponent.new(oauth_client: storage.oauth_client, storage:)) - # end - # - # component.with_row(scheme: :neutral, color: :muted) do - # grid_layout('op-storage-view--row', tag: :div, align_items: :center) do |grid| - # grid.with_area(:item, tag: :div) do - # render(Primer::Beta::Text.new(font_weight: :semibold, mr: 1)) { I18n.t('storages.file_storage_view.project_folders') } - # end - # end - # end - # - # component.with_row(scheme: :default) do - # if automatically_managed_project_folders_section_closed? - # render(Storages::Admin::AutomaticallyManagedProjectFoldersInfoComponent.new(storage)) - # else - # render(Storages::Admin::Forms::AutomaticallyManagedProjectFoldersFormComponent.new(storage)) - # end - # end - # end - # - # if storage.provider_type_one_drive? - # component.with_row(scheme: :neutral, color: :muted) do - # grid_layout('op-storage-view--row', tag: :div, align_items: :center) do |grid| - # grid.with_area(:item, tag: :div, mr: 3) do - # render(Primer::Beta::Text.new(font_weight: :semibold, mr: 1)) { I18n.t('storages.file_storage_view.access_management.title') } - # end - # end - # end - # - # component.with_row(scheme: :default) do - # render(Storages::Admin::AccessManagementComponent.new(storage)) - # end - # - # component.with_row(scheme: :neutral, color: :muted) do - # grid_layout('op-storage-view--row', tag: :div, align_items: :center) do |grid| - # grid.with_area(:item, tag: :div, mr: 3) do - # render(Primer::Beta::Text.new(font_weight: :semibold, mr: 1)) { I18n.t('storages.file_storage_view.oauth_applications') } - # end - # end - # end - # - # component.with_row(scheme: :default) do - # render(Storages::Admin::OAuthClientInfoComponent.new(oauth_client: storage.oauth_client, storage:)) - # end - # - # component.with_row(scheme: :default) do - # render(Storages::Admin::RedirectUriComponent.new(oauth_client: storage.oauth_client, storage:)) - # end - # end end %> diff --git a/modules/auth_saml/app/components/saml/providers/view_component.sass b/modules/auth_saml/app/components/saml/providers/view_component.sass new file mode 100644 index 000000000000..c17632077ee2 --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/view_component.sass @@ -0,0 +1,4 @@ +.op-saml-view-row + display: grid + grid-template-columns: 3fr 1fr + grid-template-areas: "title action" "description action" diff --git a/modules/auth_saml/app/contracts/saml/providers/base_contract.rb b/modules/auth_saml/app/contracts/saml/providers/base_contract.rb index 63a5c164f0bc..617876207b32 100644 --- a/modules/auth_saml/app/contracts/saml/providers/base_contract.rb +++ b/modules/auth_saml/app/contracts/saml/providers/base_contract.rb @@ -35,6 +35,7 @@ def self.model end attribute :display_name + attribute :options end end end diff --git a/modules/auth_saml/app/contracts/saml/providers/update_contract.rb b/modules/auth_saml/app/contracts/saml/providers/update_contract.rb index 90e4bf6fd0f6..17ade1f291f7 100644 --- a/modules/auth_saml/app/contracts/saml/providers/update_contract.rb +++ b/modules/auth_saml/app/contracts/saml/providers/update_contract.rb @@ -28,7 +28,10 @@ module Saml module Providers - class UpdateContract < News::BaseContract + class UpdateContract < BaseContract + attribute :metadata_url + validates :metadata_url, + url: { allow_blank: true, allow_nil: true, schemes: %w[http https] } end end end diff --git a/modules/auth_saml/app/controllers/saml/providers_controller.rb b/modules/auth_saml/app/controllers/saml/providers_controller.rb index 5a1086bd237f..7cde74aade1f 100644 --- a/modules/auth_saml/app/controllers/saml/providers_controller.rb +++ b/modules/auth_saml/app/controllers/saml/providers_controller.rb @@ -5,27 +5,28 @@ class ProvidersController < ::ApplicationController before_action :require_admin before_action :check_ee - before_action :find_provider, only: %i[show edit update destroy] + before_action :find_provider, only: %i[show edit import_metadata update destroy] def index @providers = Saml::Provider.all end - def edit; end + def edit + @edit_state = params[:edit_state].to_sym if params.key?(:edit_state) + end + def show; end def new @provider = ::Saml::Provider.new end - def import - @provider = ::Saml::Provider.new(name: import_params[:name]) - - if import_params[:metadata_url].present? - import_metadata + def import_metadata + if import_params.present? + update_provider_metadata end - render action: :edit + redirect_to edit_saml_provider_path(@provider, edit_state: :configuration) unless performed? end def create @@ -88,23 +89,59 @@ def check_ee end end - def import_params - params.require(:saml_provider).permit(:name, :metadata_url) + def update_provider_metadata + call = Saml::Providers::UpdateService + .new(model: @provider, user: User.current) + .call(import_params) + + if call.success? + load_and_apply_metadata + else + @provider = call.result + @edit_state = :metadata + + flash[:error] = call.message + render action: :edit + end end - def import_metadata - call = Saml::MetadataParserService - .new(user: User.current) - .parse_url(import_params[:metadata_url]) + def load_and_apply_metadata + call = Saml::UpdateMetadataService + .new(provider: @provider, user: User.current) + .call + + if call.success? + apply_metadata(call.result) + else + @edit_state = :metadata + + flash[:error] = call.message + render action: :edit + end + end + + def apply_metadata(params) + call = Saml::Providers::UpdateService + .new(model: @provider, user: User.current) + .call({ options: params}) if call.success? flash[:notice] = I18n.t("saml.metadata_parser.success") - @provider = ::Saml::Provider.new(**call.result.merge(name: import_params[:name])) else + @provider = call.result + @edit_state = :configuration + flash[:error] = call.message + render action: :edit end end + def import_params + params + .require(:saml_provider) + .permit(:metadata_url, :metadata_xml) + end + def create_params params.require(:saml_provider).permit(:display_name) end diff --git a/modules/auth_saml/app/forms/saml/providers/metadata_checkbox_form.rb b/modules/auth_saml/app/forms/saml/providers/metadata_checkbox_form.rb new file mode 100644 index 000000000000..79a580d2ac00 --- /dev/null +++ b/modules/auth_saml/app/forms/saml/providers/metadata_checkbox_form.rb @@ -0,0 +1,51 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml + module Providers + class MetadataCheckboxForm < ApplicationForm + form do |f| + f.check_box( + name: :metadata_url, + checked: @provider.has_metadata?, + label: I18n.t("saml.settings.metadata_checkbox"), + required: false, + input_width: :medium, + data: { + 'show-when-checked-target': 'cause' + } + ) + end + + def initialize(provider:) + super() + @provider = provider + end + end + end +end diff --git a/modules/auth_saml/app/forms/saml/providers/metadata_xml_form.rb b/modules/auth_saml/app/forms/saml/providers/metadata_xml_form.rb new file mode 100644 index 000000000000..86a2718d51d4 --- /dev/null +++ b/modules/auth_saml/app/forms/saml/providers/metadata_xml_form.rb @@ -0,0 +1,45 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml + module Providers + class MetadataXmlForm < ApplicationForm + form do |f| + f.text_area( + name: :metadata_xml, + label: I18n.t("saml.settings.metadata_xml"), + caption: I18n.t("saml.instructions.metadata_xml"), + required: false, + full_width: false, + rows: 10, + input_width: :medium + ) + end + end + end +end diff --git a/modules/auth_saml/app/forms/saml/providers/submit_or_cancel_form.rb b/modules/auth_saml/app/forms/saml/providers/submit_or_cancel_form.rb index 519d287eb022..0dc350c91932 100644 --- a/modules/auth_saml/app/forms/saml/providers/submit_or_cancel_form.rb +++ b/modules/auth_saml/app/forms/saml/providers/submit_or_cancel_form.rb @@ -33,6 +33,7 @@ class SubmitOrCancelForm < ApplicationForm if @state f.hidden( name: :edit_state, + scope_name_to_model: false, value: @state ) end diff --git a/modules/auth_saml/app/models/saml/provider.rb b/modules/auth_saml/app/models/saml/provider.rb index ff5db3dbd163..c5d678be92e0 100644 --- a/modules/auth_saml/app/models/saml/provider.rb +++ b/modules/auth_saml/app/models/saml/provider.rb @@ -27,14 +27,14 @@ def slug "saml-#{id}" end - def assertion_consumer_service_url - URI.join(url_helpers.root_url, "/auth/#{name}/callback").to_s - end - def limit_self_registration? limit_self_registration end + def has_metadata? + metadata_xml.present? || metadata_url.present? + end + def to_h options .merge( @@ -43,8 +43,5 @@ def to_h assertion_consumer_service_url:, ) end - - private - end end diff --git a/modules/auth_saml/app/services/saml/metadata_parser_service.rb b/modules/auth_saml/app/services/saml/update_metadata_service.rb similarity index 71% rename from modules/auth_saml/app/services/saml/metadata_parser_service.rb rename to modules/auth_saml/app/services/saml/update_metadata_service.rb index 1186204679a8..6168077faac0 100644 --- a/modules/auth_saml/app/services/saml/metadata_parser_service.rb +++ b/modules/auth_saml/app/services/saml/update_metadata_service.rb @@ -27,35 +27,45 @@ #++ module Saml - class MetadataParserService - attr_reader :user + class UpdateMetadataService + attr_reader :user, :provider - def initialize(user:) + def initialize(user:, provider:) @user = user + @provider = provider end - def parse_url(url) - validate_url!(url) - parse_remote_metadata(url) - rescue URI::InvalidURIError - ServiceResult.failure(message: I18n.t('saml.metadata_parser.invalid_url')) - rescue OneLogin::RubySaml::HttpError => e - ServiceResult.failure(message: I18n.t('saml.metadata_parser.error', error: e.message)) + def call + ServiceResult.success(result: updated_metadata) rescue StandardError => e OpenProject.logger.error(e) ServiceResult.failure(message: I18n.t('saml.metadata_parser.error', error: e.class.name)) end - def parse_remote_metadata(metadata_url) - idp_metadata_parser = OneLogin::RubySaml::IdpMetadataParser.new - result = idp_metadata_parser.parse_remote_to_hash(metadata_url) + private + + def updated_metadata + if provider.metadata_url.present? + parse_url + elsif provider.metadata_xml.present? + parse_xml + else + {} + end + end + + def parse_xml + parser_instance.parse_to_hash(provider.metadata_xml) + end - ServiceResult.success(result:) + def parse_url + parser.parse_remote_to_hash(metadata_url) + rescue OneLogin::RubySaml::HttpError => e + ServiceResult.failure(message: I18n.t('saml.metadata_parser.error', error: e.message)) end - def validate_url!(url) - uri = URI.parse(url) - raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) + def parser_instance + OneLogin::RubySaml::IdpMetadataParser.new end end end diff --git a/modules/auth_saml/app/views/saml/providers/edit.html.erb b/modules/auth_saml/app/views/saml/providers/edit.html.erb index 25d092111a53..5859fc6f240f 100644 --- a/modules/auth_saml/app/views/saml/providers/edit.html.erb +++ b/modules/auth_saml/app/views/saml/providers/edit.html.erb @@ -1,19 +1,25 @@ -<% page_title = t('saml.providers.label_edit', name: @provider.name) %> -<% local_assigns[:additional_breadcrumb] = @provider.name %> +<% page_title = t('saml.providers.label_edit', name: @provider.display_name) %> +<% local_assigns[:additional_breadcrumb] = @provider.display_name %> <% html_title(t(:label_administration), page_title) -%> +<% content_controller "admin--saml", dynamic: true %> -<%= toolbar title: page_title %> +<%= render(Primer::OpenProject::PageHeader.new) do |header| %> + <% header.with_title { page_title } %> -<%= error_messages_for @provider %> + <% header.with_breadcrumbs([{ href: admin_index_path, text: t("label_administration") }, + { href: saml_providers_path, text: t('saml.providers.plural') }, + page_title]) %> -<%= @provider.assertion_consumer_service_url %> - -<%= labelled_tabular_form_for @provider, - html: { class: 'form', autocomplete: 'off' } do |f| %> - <%= render partial: "form", locals: { f: f } %> -

- <%= styled_button_tag t(:button_save), class: '-highlight -with-icon icon-checkmark' %> - <%= link_to t(:button_cancel), { action: :index }, class: 'button -with-icon icon-cancel' %> -

+ <% header.with_description do %> + <%= link_translate( + 'saml.instructions.new_description', + links: { + docs_url: ::OpenProject::Static::Links[:sysadmin_docs][:saml][:href], + }, + target: "_blank" + ) %> + <% end %> <% end %> + +<%= render(Saml::Providers::ViewComponent.new(@provider, edit_state: @edit_state)) %> diff --git a/modules/auth_saml/config/locales/en.yml b/modules/auth_saml/config/locales/en.yml index 5baa6aeea475..b15892f3545d 100644 --- a/modules/auth_saml/config/locales/en.yml +++ b/modules/auth_saml/config/locales/en.yml @@ -17,7 +17,7 @@ en: description: > Use these parameters to configure your identity provider connection to OpenProject. metadata_parser: - success: "Successfully retrieved the identity provider metadata. Please review and save the configuration." + success: "Successfully updated the configuration using the identity provider metadata. Please review and save the configuration." invalid_url: "Provided metadata URL is invalid. Provide a HTTP(s) URL." error: "Failed to retrieve the identity provider metadata: %{error}" providers: @@ -44,8 +44,11 @@ en: section_texts: display_name: "Configure the display name of the SAML provider." metadata: "Pre-fill configuration using a metadata URL or by pasting metadata XML" + metadata_form: "If your identity provider has a metadata endpoint or XML download, add it below to pre-fill configuration." settings: - metadata_url: Identity provider metadata URL (Optional) + metadata_checkbox: "I have metadata (optional)" + metadata_url: "Metadata URL" + metadata_xml: "Metadata XML" instructions: new_description: > Add a new SAML provider. Please refer to our [documentation on configuring SAML providers](docs_url). @@ -53,6 +56,7 @@ en: The name of the provider. This will be displayed as the login button and in the list of providers. metadata_url: > Enter the URL of the identity provider XML metadata endpoint. - If provided, OpenProject will try to prefill all available configuration options from your identity provider. + metadata_xml: > + Alternatively, enter the SAML metadata XML from your identity provider. limit_self_registration: > If enabled users can only register using this provider if the self registration setting allows for it. diff --git a/modules/auth_saml/config/routes.rb b/modules/auth_saml/config/routes.rb index e09a10516a07..926f2427ae98 100644 --- a/modules/auth_saml/config/routes.rb +++ b/modules/auth_saml/config/routes.rb @@ -2,8 +2,8 @@ scope :admin do namespace :saml do resources :providers do - collection do - post :import + member do + post :import_metadata end end end From 1970bed7ffbb6b28077dfc6787c627a99a6bd1f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 2 Aug 2024 08:50:02 +0200 Subject: [PATCH 12/80] Better table rendering --- .../border_box_table_component.html.erb | 14 +++++----- .../saml/providers/row_component.rb | 27 ++++++++++++------- .../saml/providers/table_component.rb | 22 ++++++++++----- .../saml/providers/view_component.html.erb | 8 ++++++ 4 files changed, 48 insertions(+), 23 deletions(-) diff --git a/app/components/op_primer/border_box_table_component.html.erb b/app/components/op_primer/border_box_table_component.html.erb index f7488e61583d..293aa2d79ed5 100644 --- a/app/components/op_primer/border_box_table_component.html.erb +++ b/app/components/op_primer/border_box_table_component.html.erb @@ -44,13 +44,13 @@ See COPYRIGHT and LICENSE files for more details. end end - component.with_row(scheme: :default) do - if rows.empty? - render_blank_slate - else - rows.each do |row| - concat render(row_class.new(row:, table: self)) - end + if rows.empty? + component.with_row(scheme: :default) { render_blank_slate } + end + + rows.each do |row| + component.with_row(scheme: :default) do + render(row_class.new(row:, table: self)) end end end diff --git a/modules/auth_saml/app/components/saml/providers/row_component.rb b/modules/auth_saml/app/components/saml/providers/row_component.rb index d76e40b5f184..bf52b4957d00 100644 --- a/modules/auth_saml/app/components/saml/providers/row_component.rb +++ b/modules/auth_saml/app/components/saml/providers/row_component.rb @@ -5,18 +5,27 @@ def provider model end + def column_args(column) + if column == :name + { style: "grid-column: span 3" } + else + super + end + end + def name concat render(Primer::Beta::Link.new( - scheme: :primary, - href: url_for(action: :show, id: provider.id) - )) { provider.display_name || provider.name } + scheme: :primary, + href: url_for(action: :show, id: provider.id) + )) { provider.display_name || provider.name } if provider.idp_sso_service_url concat render(Primer::Beta::Text.new( - tag: :p, - font_size: :small, - color: :subtle - )) { provider.idp_sso_service_url } + tag: :p, + classes: "-break-word", + font_size: :small, + color: :subtle + )) { provider.idp_sso_service_url } end end @@ -26,7 +35,7 @@ def button_links def edit_link link_to( - helpers.op_icon('icon icon-edit button--link'), + helpers.op_icon("icon icon-edit button--link"), url_for(action: :edit, id: provider.id), title: t(:button_edit) ) @@ -48,7 +57,7 @@ def delete_link return if provider.readonly link_to( - helpers.op_icon('icon icon-delete button--link'), + helpers.op_icon("icon icon-delete button--link"), url_for(action: :destroy, id: provider.id), method: :delete, data: { confirm: I18n.t(:text_are_you_sure) }, diff --git a/modules/auth_saml/app/components/saml/providers/table_component.rb b/modules/auth_saml/app/components/saml/providers/table_component.rb index 564072dc3dc0..fd9d403a57ea 100644 --- a/modules/auth_saml/app/components/saml/providers/table_component.rb +++ b/modules/auth_saml/app/components/saml/providers/table_component.rb @@ -7,29 +7,37 @@ def initial_sort %i[id asc] end + def header_args(column) + if column == :name + { style: "grid-column: span 3" } + else + super + end + end + def sortable? false end def empty_row_message - I18n.t 'saml.providers.no_results_table' + I18n.t "saml.providers.no_results_table" end def headers [ - ['name', { caption: I18n.t('attributes.name') }], - ['users', { caption: I18n.t(:label_user_plural) }], - ['creator', { caption: I18n.t("js.label_created_by") }], - ['created_at', { caption: Saml::Provider.human_attribute_name(:created_at) }] + [:name, { caption: I18n.t("attributes.name") }], + [:users, { caption: I18n.t(:label_user_plural) }], + [:creator, { caption: I18n.t("js.label_created_by") }], + [:created_at, { caption: Saml::Provider.human_attribute_name(:created_at) }] ] end def blank_title - I18n.t('saml.providers.label_empty_title') + I18n.t("saml.providers.label_empty_title") end def blank_description - I18n.t('saml.providers.label_empty_description') + I18n.t("saml.providers.label_empty_description") end def blank_icon diff --git a/modules/auth_saml/app/components/saml/providers/view_component.html.erb b/modules/auth_saml/app/components/saml/providers/view_component.html.erb index 58e9b882b0e1..32e51ae50ee4 100644 --- a/modules/auth_saml/app/components/saml/providers/view_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/view_component.html.erb @@ -23,5 +23,13 @@ render(Saml::Providers::Sections::MetadataComponent.new(provider)) end end + + component.with_row(scheme: :default) do + if edit_state == :configuration + render(Saml::Providers::Sections::ConfigurationFormComponent.new(provider)) + else + render(Saml::Providers::Sections::ConfigurationComponent.new(provider)) + end + end end %> From bf8193967d63682d011f4ad9ad52a9e4fcf45396 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 2 Aug 2024 08:50:05 +0200 Subject: [PATCH 13/80] Configuration --- .../sections/configuration_component.html.erb | 34 +++++++++++++++ .../sections/configuration_component.rb | 41 +++++++++++++++++++ .../configuration_form_component.html.erb | 36 ++++++++++++++++ .../sections/configuration_form_component.rb | 41 +++++++++++++++++++ modules/auth_saml/app/models/saml/provider.rb | 14 ++++++- modules/auth_saml/config/locales/en.yml | 3 ++ 6 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 modules/auth_saml/app/components/saml/providers/sections/configuration_component.html.erb create mode 100644 modules/auth_saml/app/components/saml/providers/sections/configuration_component.rb create mode 100644 modules/auth_saml/app/components/saml/providers/sections/configuration_form_component.html.erb create mode 100644 modules/auth_saml/app/components/saml/providers/sections/configuration_form_component.rb diff --git a/modules/auth_saml/app/components/saml/providers/sections/configuration_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/configuration_component.html.erb new file mode 100644 index 000000000000..2ccdef7b0089 --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/sections/configuration_component.html.erb @@ -0,0 +1,34 @@ +<%= + grid_layout('op-saml-view-row', tag: :div, align_items: :center) do |grid| + grid.with_area(:title, mr: 3) do + concat render(Primer::Beta::Text.new(font_weight: :bold)) { t("saml.providers.section_headers.configuration") } + if provider.configured? + concat render(Primer::Beta::Label.new(scheme: :success, ml: 1)) { t(:label_completed) } + else + concat render(Primer::Beta::Label.new(scheme: :attention, ml: 1)) { t(:label_incomplete) } + end + end + + grid.with_area(:description) do + render(Primer::Beta::Text.new(tag: :p, font_size: :small, color: :subtle)) do + t("saml.providers.section_texts.configuration") + end + end + + grid.with_area(:action) do + flex_layout(justify_content: :flex_end) do |icons_container| + icons_container.with_column do + render( + Primer::Beta::IconButton.new( + icon: :pencil, + tag: :a, + scheme: :invisible, + href: edit_saml_provider_path(provider, edit_state: :configuration), + aria: { label: I18n.t(:label_edit) } + ) + ) + end + end + end + end +%> diff --git a/modules/auth_saml/app/components/saml/providers/sections/configuration_component.rb b/modules/auth_saml/app/components/saml/providers/sections/configuration_component.rb new file mode 100644 index 000000000000..a6cc07f82f18 --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/sections/configuration_component.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml::Providers::Sections + class ConfigurationComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + attr_reader :provider + + def initialize(provider) + @provider = provider + end + end +end diff --git a/modules/auth_saml/app/components/saml/providers/sections/configuration_form_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/configuration_form_component.html.erb new file mode 100644 index 000000000000..09b26b4060ea --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/sections/configuration_form_component.html.erb @@ -0,0 +1,36 @@ +<%= + primer_form_with( + model: provider, + url: import_metadata_saml_provider_path(provider), + data: { + controller: "show-when-checked" + }, + method: :post, + ) do |form| + flex_layout do |flex| + flex.with_row do + render(Primer::Beta::Text.new(tag: :p, mb: 4, font_size: :small)) do + t("saml.providers.section_texts.metadata_form") + end + end + + flex.with_row do + render(Saml::Providers::MetadataCheckboxForm.new(form, provider:)) + end + + flex.with_row(mt: 2, hidden: !provider.has_metadata?, data: { 'show-when-checked-target': "effect" }) do + render(Saml::Providers::MetadataUrlForm.new(form)) + end + + flex.with_row(mt: 2, hidden: !provider.has_metadata?, data: { 'show-when-checked-target': "effect" }) do + render(Saml::Providers::MetadataXmlForm.new(form)) + end + + flex.with_row(mt: 4) do + render(Saml::Providers::SubmitOrCancelForm.new(form, + provider:, + state: :metadata)) + end + end + end +%> diff --git a/modules/auth_saml/app/components/saml/providers/sections/configuration_form_component.rb b/modules/auth_saml/app/components/saml/providers/sections/configuration_form_component.rb new file mode 100644 index 000000000000..f2506fd3e043 --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/sections/configuration_form_component.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml::Providers::Sections + class ConfigurationFormComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + attr_reader :provider + + def initialize(provider) + @provider = provider + end + end +end diff --git a/modules/auth_saml/app/models/saml/provider.rb b/modules/auth_saml/app/models/saml/provider.rb index c5d678be92e0..5681a963e5e1 100644 --- a/modules/auth_saml/app/models/saml/provider.rb +++ b/modules/auth_saml/app/models/saml/provider.rb @@ -12,6 +12,9 @@ class Provider < ApplicationRecord store_attribute :options, :idp_sso_service_url, :string store_attribute :options, :idp_slo_service_url, :string + store_attribute :options, :idp_cert, :string + store_attribute :options, :idp_cert_fingerprint, :string + store_attribute :options, :mapping_login, :json store_attribute :options, :mapping_mail, :json store_attribute :options, :mapping_firstname, :json @@ -21,6 +24,7 @@ class Provider < ApplicationRecord store_attribute :options, :request_attributes, :json attr_accessor :readonly + validates_presence_of :display_name def slug @@ -35,12 +39,20 @@ def has_metadata? metadata_xml.present? || metadata_url.present? end + def configured? + idp_sso_service_url.present? && certificate_configured? + end + + def certificate_configured? + idp_cert.present? || idp_cert_fingerprint.present? + end + def to_h options .merge( name: slug, display_name:, - assertion_consumer_service_url:, + assertion_consumer_service_url: ) end end diff --git a/modules/auth_saml/config/locales/en.yml b/modules/auth_saml/config/locales/en.yml index b15892f3545d..558918bfc5fb 100644 --- a/modules/auth_saml/config/locales/en.yml +++ b/modules/auth_saml/config/locales/en.yml @@ -41,10 +41,13 @@ en: legend: 'These attributes are added to the SAML XML metadata to signify to the identify provider which attributes OpenProject requires.' name: 'Requested attribute key' format: 'Attribute format' + section_headers: + configuration: "Primary configuration" section_texts: display_name: "Configure the display name of the SAML provider." metadata: "Pre-fill configuration using a metadata URL or by pasting metadata XML" metadata_form: "If your identity provider has a metadata endpoint or XML download, add it below to pre-fill configuration." + configuration: "Configure the endpoint URLs for the identity provider, certificates, and further SAML options." settings: metadata_checkbox: "I have metadata (optional)" metadata_url: "Metadata URL" From 68422d4e60754df3a67aaf24515a0cbf3efcf666 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 2 Aug 2024 08:50:54 +0200 Subject: [PATCH 14/80] Use not configured for metadata --- config/locales/en.yml | 1 + .../dynamic/admin/saml.controller.ts | 253 ------------------ .../sections/configuration_component.html.erb | 30 ++- .../configuration_form_component.html.erb | 20 +- .../sections/metadata_component.html.erb | 28 +- .../saml/providers/view_component.html.erb | 4 + .../auth_saml/app/constants/saml/defaults.rb | 16 +- .../saml/providers/update_contract.rb | 13 +- .../controllers/saml/providers_controller.rb | 24 +- .../saml/providers/configuration_form.rb | 87 ++++++ modules/auth_saml/app/models/saml/provider.rb | 7 +- .../saml/providers/set_attributes_service.rb | 2 - .../services/saml/update_metadata_service.rb | 7 +- .../app/views/saml/providers/edit.html.erb | 6 +- modules/auth_saml/config/locales/en.yml | 19 +- 15 files changed, 194 insertions(+), 323 deletions(-) delete mode 100644 frontend/src/stimulus/controllers/dynamic/admin/saml.controller.ts create mode 100644 modules/auth_saml/app/forms/saml/providers/configuration_form.rb diff --git a/config/locales/en.yml b/config/locales/en.yml index 557d50419564..075ba237f51e 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2392,6 +2392,7 @@ en: label_no_parent_page: "No parent page" label_nothing_display: "Nothing to display" label_nobody: "nobody" + label_not_configured: "Not configured" label_not_found: "not found" label_none: "none" label_none_parentheses: "(none)" diff --git a/frontend/src/stimulus/controllers/dynamic/admin/saml.controller.ts b/frontend/src/stimulus/controllers/dynamic/admin/saml.controller.ts deleted file mode 100644 index 4286e4765154..000000000000 --- a/frontend/src/stimulus/controllers/dynamic/admin/saml.controller.ts +++ /dev/null @@ -1,253 +0,0 @@ -/* - * -- copyright - * OpenProject is an open source project management software. - * Copyright (C) 2023 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. - * ++ - */ - -import { Controller } from '@hotwired/stimulus'; - -export default class CustomFieldsController extends Controller { - static targets = [ - 'format', - 'dragContainer', - - 'customOptionDefaults', - 'customOptionRow', - - 'allowNonOpenVersions', - 'defaultBool', - 'defaultLongText', - 'defaultText', - 'length', - 'multiSelect', - 'possibleValues', - 'regexp', - 'searchable', - 'textOrientation', - ]; - - static values = { - formatConfig: Array, - }; - - declare readonly formatConfigValue:[string, string, string[]][]; - - declare readonly formatTarget:HTMLInputElement; - declare readonly dragContainerTarget:HTMLElement; - declare readonly hasDragContainerTarget:boolean; - - declare readonly customOptionDefaultsTargets:HTMLInputElement[]; - declare readonly customOptionRowTargets:HTMLTableRowElement[]; - - declare readonly allowNonOpenVersionsTargets:HTMLElement[]; - declare readonly defaultBoolTargets:HTMLElement[]; - declare readonly defaultLongTextTargets:HTMLElement[]; - declare readonly defaultTextTargets:HTMLElement[]; - declare readonly lengthTargets:HTMLElement[]; - declare readonly multiSelectTargets:HTMLElement[]; - declare readonly possibleValuesTargets:HTMLElement[]; - declare readonly regexpTargets:HTMLElement[]; - declare readonly searchableTargets:HTMLInputElement[]; - declare readonly textOrientationTargets:HTMLElement[]; - - connect() { - if (this.hasDragContainerTarget) { - this.setupDragAndDrop(); - } - - this.formatChanged(); - } - - formatChanged() { - this.toggleFormat(this.formatTarget.value); - } - - moveRowUp(event:{ target:HTMLElement }) { - const row = event.target.closest('tr') as HTMLTableRowElement; - const idx = this.customOptionRowTargets.indexOf(row); - if (idx > 0) { - this.customOptionRowTargets[idx - 1].before(row); - } - - return false; - } - - moveRowDown(event:{ target:HTMLElement }) { - const row = event.target.closest('tr') as HTMLTableRowElement; - const idx = this.customOptionRowTargets.indexOf(row); - if (idx < this.customOptionRowTargets.length - 1) { - this.customOptionRowTargets[idx + 1].after(row); - } - - return false; - } - - moveRowToTheTop(event:{ target:HTMLElement }) { - const row = event.target.closest('tr') as HTMLTableRowElement; - const first = this.customOptionRowTargets[0]; - - if (first && first !== row) { - first.before(row); - } - - return false; - } - - moveRowToTheBottom(event:{ target:HTMLElement }) { - const row = event.target.closest('tr') as HTMLTableRowElement; - const last = this.customOptionRowTargets[this.customOptionRowTargets.length - 1]; - - if (last && last !== row) { - last.after(row); - } - - return false; - } - - removeOption(event:MouseEvent) { - const self = event.target as HTMLAnchorElement; - if (self.href === '#' || self.href.endsWith('/0')) { - const row = self.closest('tr'); - - if (row && this.customOptionRowTargets.length > 1) { - row.remove(); - } - - event.preventDefault(); - event.stopImmediatePropagation(); - } - return true; // send off deletion - } - - addOption() { - const count = this.customOptionRowTargets.length; - const last = this.customOptionRowTargets[count - 1]; - const dup = last.cloneNode(true) as HTMLElement; - - const input = dup.querySelector('.custom-option-value input') as HTMLInputElement; - - input.setAttribute('name', `custom_field[custom_options_attributes][${count}][value]`); - input.setAttribute('id', `custom_field_custom_options_attributes_${count}_value`); - input.value = ''; - - dup - .querySelector('.custom-option-id') - ?.remove(); - - const defaultValueCheckbox = dup.querySelector('input[type="checkbox"]') as HTMLInputElement; - const defaultValueHidden = dup.querySelector('input[type="hidden"]') as HTMLInputElement; - - defaultValueHidden.setAttribute('name', `custom_field[custom_options_attributes][${count}][default_value]`); - defaultValueHidden.removeAttribute('id'); - defaultValueCheckbox.setAttribute('name', `custom_field[custom_options_attributes][${count}][default_value]`); - defaultValueCheckbox.setAttribute('id', `custom_field_custom_options_attributes_${count}_default_value`); - defaultValueCheckbox.checked = false; - - last.insertAdjacentElement('afterend', dup); - - return false; - } - - uncheckOtherDefaults(event:{ target:HTMLElement }) { - const cb = event.target as HTMLInputElement; - - if (cb.checked) { - const multi = this.multiSelectTargets[0] as HTMLInputElement|undefined; - - if (multi?.checked === false) { - this.customOptionDefaultsTargets.forEach((el) => (el.checked = false)); - cb.checked = true; - } - } - } - - checkOnlyOne(event:{ target:HTMLElement }) { - const cb = event.target as HTMLInputElement; - - if (!cb.checked) { - this.customOptionDefaultsTargets - .filter((el) => el.checked) - .slice(1) - .forEach((el) => (el.checked = false)); - } - } - - private setupDragAndDrop() { - // Make custom fields draggable - // eslint-disable-next-line no-undef - const drake = dragula([this.dragContainerTarget], { - isContainer: () => false, - moves: (el, source, handle:HTMLElement) => handle.classList.contains('dragula-handle'), - accepts: () => true, - invalid: () => false, - direction: 'vertical', - copy: false, - copySortSource: false, - revertOnSpill: true, - removeOnSpill: false, - mirrorContainer: this.dragContainerTarget, - ignoreInputTextSelection: true, - }); - - // Setup autoscroll - void window.OpenProject.getPluginContext().then((pluginContext) => { - // eslint-disable-next-line no-new - new pluginContext.classes.DomAutoscrollService( - [ - document.getElementById('content-body') as HTMLElement, - ], - { - margin: 25, - maxSpeed: 10, - scrollWhenOutside: true, - autoScroll: () => drake.dragging, - }, - ); - }); - } - - private setActive(elements:HTMLElement[], active:boolean) { - elements.forEach((element) => { - element.hidden = !active; - element - .querySelectorAll('input, textarea') - .forEach((input) => { - input.disabled = !active; - }); - }); - } - - private toggleFormat(format:string) { - this.formatConfigValue.forEach(([targetsName, operator, formats]) => { - const active = operator === 'only' ? formats.includes(format) : !formats.includes(format); - const targets = this[`${targetsName}Targets` as keyof typeof this] as HTMLElement[]; - if (targets) { - this.setActive(targets, active); - } - }); - } -} diff --git a/modules/auth_saml/app/components/saml/providers/sections/configuration_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/configuration_component.html.erb index 2ccdef7b0089..a7f66752e9e4 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/configuration_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/sections/configuration_component.html.erb @@ -1,32 +1,34 @@ <%= grid_layout('op-saml-view-row', tag: :div, align_items: :center) do |grid| grid.with_area(:title, mr: 3) do - concat render(Primer::Beta::Text.new(font_weight: :bold)) { t("saml.providers.section_headers.configuration") } + concat render(Primer::Beta::Text.new(font_weight: :bold)) { t("saml.providers.label_configuration_details") } if provider.configured? concat render(Primer::Beta::Label.new(scheme: :success, ml: 1)) { t(:label_completed) } - else + elsif provider.persisted? concat render(Primer::Beta::Label.new(scheme: :attention, ml: 1)) { t(:label_incomplete) } end end grid.with_area(:description) do render(Primer::Beta::Text.new(tag: :p, font_size: :small, color: :subtle)) do - t("saml.providers.section_texts.configuration") + t("saml.providers.section_texts.label_configuration_details") end end - grid.with_area(:action) do - flex_layout(justify_content: :flex_end) do |icons_container| - icons_container.with_column do - render( - Primer::Beta::IconButton.new( - icon: :pencil, - tag: :a, - scheme: :invisible, - href: edit_saml_provider_path(provider, edit_state: :configuration), - aria: { label: I18n.t(:label_edit) } + if provider.persisted? + grid.with_area(:action) do + flex_layout(justify_content: :flex_end) do |icons_container| + icons_container.with_column do + render( + Primer::Beta::IconButton.new( + icon: :pencil, + tag: :a, + scheme: :invisible, + href: edit_saml_provider_path(provider, edit_state: :configuration), + aria: { label: I18n.t(:label_edit) } + ) ) - ) + end end end end diff --git a/modules/auth_saml/app/components/saml/providers/sections/configuration_form_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/configuration_form_component.html.erb index 09b26b4060ea..e31e20b07a23 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/configuration_form_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/sections/configuration_form_component.html.erb @@ -1,11 +1,8 @@ <%= primer_form_with( model: provider, - url: import_metadata_saml_provider_path(provider), - data: { - controller: "show-when-checked" - }, - method: :post, + url: saml_provider_path(provider, state: :configuration), + method: :put ) do |form| flex_layout do |flex| flex.with_row do @@ -15,21 +12,12 @@ end flex.with_row do - render(Saml::Providers::MetadataCheckboxForm.new(form, provider:)) - end - - flex.with_row(mt: 2, hidden: !provider.has_metadata?, data: { 'show-when-checked-target': "effect" }) do - render(Saml::Providers::MetadataUrlForm.new(form)) - end - - flex.with_row(mt: 2, hidden: !provider.has_metadata?, data: { 'show-when-checked-target': "effect" }) do - render(Saml::Providers::MetadataXmlForm.new(form)) + render(Saml::Providers::ConfigurationForm.new(form, provider:)) end flex.with_row(mt: 4) do render(Saml::Providers::SubmitOrCancelForm.new(form, - provider:, - state: :metadata)) + provider:)) end end end diff --git a/modules/auth_saml/app/components/saml/providers/sections/metadata_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/metadata_component.html.erb index 48b387774e11..61ac7fb6f06d 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/metadata_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/sections/metadata_component.html.erb @@ -4,8 +4,8 @@ concat render(Primer::Beta::Text.new(font_weight: :bold)) { t("saml.providers.label_metadata") } if provider.has_metadata? concat render(Primer::Beta::Label.new(scheme: :success, ml: 1)) { t(:label_completed) } - else - concat render(Primer::Beta::Label.new(scheme: :secondary, ml: 1)) { t(:label_incomplete) } + elsif provider.persisted? + concat render(Primer::Beta::Label.new(scheme: :secondary, ml: 1)) { t(:label_not_configured) } end end @@ -15,18 +15,20 @@ end end - grid.with_area(:action) do - flex_layout(justify_content: :flex_end) do |icons_container| - icons_container.with_column do - render( - Primer::Beta::IconButton.new( - icon: :pencil, - tag: :a, - scheme: :invisible, - href: edit_saml_provider_path(provider, edit_state: :metadata), - aria: { label: I18n.t(:label_edit) } + if provider.persisted? + grid.with_area(:action) do + flex_layout(justify_content: :flex_end) do |icons_container| + icons_container.with_column do + render( + Primer::Beta::IconButton.new( + icon: :pencil, + tag: :a, + scheme: :invisible, + href: edit_saml_provider_path(provider, edit_state: :metadata), + aria: { label: I18n.t(:label_edit) } + ) ) - ) + end end end end diff --git a/modules/auth_saml/app/components/saml/providers/view_component.html.erb b/modules/auth_saml/app/components/saml/providers/view_component.html.erb index 32e51ae50ee4..ac1ee941d605 100644 --- a/modules/auth_saml/app/components/saml/providers/view_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/view_component.html.erb @@ -24,6 +24,10 @@ end end + component.with_row(scheme: :neutral, color: :muted) do + render(Primer::Beta::Text.new(font_weight: :semibold)) { I18n.t('saml.providers.section_headers.configuration') } + end + component.with_row(scheme: :default) do if edit_state == :configuration render(Saml::Providers::Sections::ConfigurationFormComponent.new(provider)) diff --git a/modules/auth_saml/app/constants/saml/defaults.rb b/modules/auth_saml/app/constants/saml/defaults.rb index c6e930816af6..b6bd3eb35ff4 100644 --- a/modules/auth_saml/app/constants/saml/defaults.rb +++ b/modules/auth_saml/app/constants/saml/defaults.rb @@ -30,15 +30,23 @@ module Saml module Defaults - NAME_IDENTIFIER_FORMAT = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'.freeze + NAME_IDENTIFIER_FORMAT = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" + + NAME_IDENTIFIER_FORMATS = [ + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", + "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", + "urn:oasis:names:tc:SAML:2.0:nameid-format:transient" + ] + MAIL_MAPPING = %w[mail email Email emailAddress emailaddress - urn:oid:0.9.2342.19200300.100.1.3 http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress].freeze + urn:oid:0.9.2342.19200300.100.1.3 http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress].freeze FIRSTNAME_MAPPING = %w[givenName givenname given_name given_name - urn:oid:2.5.4.42 http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname].freeze + urn:oid:2.5.4.42 http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname].freeze LASTNAME_MAPPING = %w[surname sur_name sn - urn:oid:2.5.4.4 http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname].freeze + urn:oid:2.5.4.4 http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname].freeze REQUESTED_ATTRIBUTES = [ { diff --git a/modules/auth_saml/app/contracts/saml/providers/update_contract.rb b/modules/auth_saml/app/contracts/saml/providers/update_contract.rb index 17ade1f291f7..b86e86c0b2a6 100644 --- a/modules/auth_saml/app/contracts/saml/providers/update_contract.rb +++ b/modules/auth_saml/app/contracts/saml/providers/update_contract.rb @@ -31,7 +31,18 @@ module Providers class UpdateContract < BaseContract attribute :metadata_url validates :metadata_url, - url: { allow_blank: true, allow_nil: true, schemes: %w[http https] } + url: { allow_blank: true, allow_nil: true, schemes: %w[http https] }, + if: -> { model.metadata_url_changed? } + + attribute :idp_sso_service_url + validates :idp_sso_service_url, + url: { schemes: %w[http https] }, + if: -> { model.idp_sso_service_url_changed? } + + attribute :idp_slo_service_url + validates :idp_slo_service_url, + url: { schemes: %w[http https] }, + if: -> { model.idp_slo_service_url_changed? } end end end diff --git a/modules/auth_saml/app/controllers/saml/providers_controller.rb b/modules/auth_saml/app/controllers/saml/providers_controller.rb index 7cde74aade1f..dbeeaf9a95ed 100644 --- a/modules/auth_saml/app/controllers/saml/providers_controller.rb +++ b/modules/auth_saml/app/controllers/saml/providers_controller.rb @@ -36,7 +36,7 @@ def create if call.success? flash[:notice] = I18n.t(:notice_successful_create) - redirect_to saml_provider_path(call.result) + redirect_to edit_saml_provider_path(call.result, edit_state: :metadata) else @provider = call.result render action: :new @@ -44,13 +44,16 @@ def create end def update - @provider = ::Saml::Provider.initialize_with( - update_params.merge("name" => params[:id]) - ) - if @provider.save + call = Saml::Providers::UpdateService + .new(model: @provider, user: User.current) + .call(update_params) + + if call.success? flash[:notice] = I18n.t(:notice_successful_update) - success_redirect + redirect_to saml_provider_path(call.result) else + @provider = call.result + @edit_state = params[:state].to_sym render action: :edit end end @@ -58,6 +61,7 @@ def update def destroy call = ::Saml::Providers::DeleteService .new(model: @provider, user: User.current) + .call if call.success? flash[:notice] = I18n.t(:notice_successful_delete) @@ -121,9 +125,10 @@ def load_and_apply_metadata end def apply_metadata(params) + new_options = @provider.options.merge(params.compact_blank) call = Saml::Providers::UpdateService .new(model: @provider, user: User.current) - .call({ options: params}) + .call({ options: new_options }) if call.success? flash[:notice] = I18n.t("saml.metadata_parser.success") @@ -147,7 +152,10 @@ def create_params end def update_params - params.require(:saml_provider).permit(:display_name, :identifier, :secret, :limit_self_registration) + params + .require(:saml_provider) + .permit(:display_name, :sp_entity_id, :idp_sso_service_url, :idp_slo_service_url, :idp_cert, + :name_identifier_format, :limit_self_registration) end def find_provider diff --git a/modules/auth_saml/app/forms/saml/providers/configuration_form.rb b/modules/auth_saml/app/forms/saml/providers/configuration_form.rb new file mode 100644 index 000000000000..eeac93067e36 --- /dev/null +++ b/modules/auth_saml/app/forms/saml/providers/configuration_form.rb @@ -0,0 +1,87 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml + module Providers + class ConfigurationForm < ApplicationForm + form do |f| + f.text_field( + name: :sp_entity_id, + label: I18n.t("activemodel.attributes.saml/provider.sp_entity_id"), + caption: I18n.t("saml.instructions.sp_entity_id"), + required: true, + input_width: :large + ) + f.text_field( + name: :idp_sso_service_url, + label: I18n.t("activemodel.attributes.saml/provider.idp_sso_service_url"), + caption: I18n.t("saml.instructions.idp_sso_service_url"), + required: true, + input_width: :large + ) + f.text_field( + name: :idp_slo_service_url, + label: I18n.t("activemodel.attributes.saml/provider.idp_slo_service_url"), + caption: I18n.t("saml.instructions.idp_slo_service_url"), + required: false, + input_width: :large + ) + f.text_area( + name: :idp_cert, + rows: 10, + label: I18n.t("activemodel.attributes.saml/provider.idp_cert"), + caption: I18n.t("saml.instructions.idp_cert"), + required: false, + input_width: :large + ) + f.select_list( + name: "name_identifier_format", + label: I18n.t("activemodel.attributes.saml/provider.name_identifier_format"), + input_width: :large, + caption: I18n.t("saml.instructions.name_identifier_format") + ) do |list| + Saml::Defaults::NAME_IDENTIFIER_FORMATS.each do |format| + list.option(label: format, value: format) + end + end + f.check_box( + name: :limit_self_registration, + label: I18n.t("activemodel.attributes.saml/provider.limit_self_registration"), + caption: I18n.t("saml.instructions.limit_self_registration"), + required: false, + input_width: :large + ) + end + + def initialize(provider:) + super() + @provider = provider + end + end + end +end diff --git a/modules/auth_saml/app/models/saml/provider.rb b/modules/auth_saml/app/models/saml/provider.rb index 5681a963e5e1..82c629915f3e 100644 --- a/modules/auth_saml/app/models/saml/provider.rb +++ b/modules/auth_saml/app/models/saml/provider.rb @@ -26,9 +26,10 @@ class Provider < ApplicationRecord attr_accessor :readonly validates_presence_of :display_name + validates_uniqueness_of :display_name def slug - "saml-#{id}" + options.fetch(:name) { "saml-#{id}" } end def limit_self_registration? @@ -40,11 +41,11 @@ def has_metadata? end def configured? - idp_sso_service_url.present? && certificate_configured? + sp_entity_id.present? && idp_sso_service_url.present? && certificate_configured? end def certificate_configured? - idp_cert.present? || idp_cert_fingerprint.present? + idp_cert.present? end def to_h diff --git a/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb b/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb index 3ecb58d06dff..d6703bee347d 100644 --- a/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb +++ b/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb @@ -29,8 +29,6 @@ module Saml module Providers class SetAttributesService < BaseServices::SetAttributes - include Attachments::SetReplacements - private def set_default_attributes(*) diff --git a/modules/auth_saml/app/services/saml/update_metadata_service.rb b/modules/auth_saml/app/services/saml/update_metadata_service.rb index 6168077faac0..525294ae09db 100644 --- a/modules/auth_saml/app/services/saml/update_metadata_service.rb +++ b/modules/auth_saml/app/services/saml/update_metadata_service.rb @@ -38,8 +38,9 @@ def initialize(user:, provider:) def call ServiceResult.success(result: updated_metadata) rescue StandardError => e + binding.pry OpenProject.logger.error(e) - ServiceResult.failure(message: I18n.t('saml.metadata_parser.error', error: e.class.name)) + ServiceResult.failure(message: I18n.t("saml.metadata_parser.error", error: e.class.name)) end private @@ -59,9 +60,9 @@ def parse_xml end def parse_url - parser.parse_remote_to_hash(metadata_url) + parser_instance.parse_remote_to_hash(provider.metadata_url) rescue OneLogin::RubySaml::HttpError => e - ServiceResult.failure(message: I18n.t('saml.metadata_parser.error', error: e.message)) + ServiceResult.failure(message: I18n.t("saml.metadata_parser.error", error: e.message)) end def parser_instance diff --git a/modules/auth_saml/app/views/saml/providers/edit.html.erb b/modules/auth_saml/app/views/saml/providers/edit.html.erb index 5859fc6f240f..edaa869215e5 100644 --- a/modules/auth_saml/app/views/saml/providers/edit.html.erb +++ b/modules/auth_saml/app/views/saml/providers/edit.html.erb @@ -1,15 +1,13 @@ <% page_title = t('saml.providers.label_edit', name: @provider.display_name) %> -<% local_assigns[:additional_breadcrumb] = @provider.display_name %> <% html_title(t(:label_administration), page_title) -%> -<% content_controller "admin--saml", dynamic: true %> <%= render(Primer::OpenProject::PageHeader.new) do |header| %> - <% header.with_title { page_title } %> + <% header.with_title { I18n.t(:label_edit_x, x: @provider.display_name) } %> <% header.with_breadcrumbs([{ href: admin_index_path, text: t("label_administration") }, { href: saml_providers_path, text: t('saml.providers.plural') }, - page_title]) %> + I18n.t(:label_edit_x, x: @provider.display_name)]) %> <% header.with_description do %> <%= link_translate( diff --git a/modules/auth_saml/config/locales/en.yml b/modules/auth_saml/config/locales/en.yml index 558918bfc5fb..5c57046d79a5 100644 --- a/modules/auth_saml/config/locales/en.yml +++ b/modules/auth_saml/config/locales/en.yml @@ -2,14 +2,17 @@ en: activemodel: attributes: saml/provider: - name: Identifier - display_name: Display name + display_name: Name identifier: Identifier secret: Secret scope: Scope limit_self_registration: Limit self registration sp_entity_id: Service entity ID metadata_url: Identity provider metadata URL + name_identifier_format: Name identifier format + idp_sso_service_url: Identity provider login endpoint + idp_slo_service_url: Identity provider logout endpoint + idp_cert: Public certificate of identity provider saml: menu_title: SAML providers info: @@ -25,6 +28,7 @@ en: label_empty_description: "Add a provider to see them here." label_automatic_configuration: Automatic configuration label_metadata: Metadata + label_configuration_details: "Identity provider endpoints and certificates" label_add_new: New SAML identity provider label_edit: Edit SAML identity provider %{name} no_results_table: No SAML identity providers have been defined yet. @@ -63,3 +67,14 @@ en: Alternatively, enter the SAML metadata XML from your identity provider. limit_self_registration: > If enabled users can only register using this provider if the self registration setting allows for it. + sp_entity_id: > + The entity ID of the service provider. This is the unique client identifier of the OpenProject instance. + idp_sso_service_url: > + The URL of the identity provider login endpoint. + idp_slo_service_url: > + The URL of the identity provider login endpoint. + idp_cert: > + Enter the X509 PEM-formatted public certificate of the identity provider. + You can enter multiple certificates by separating them with a newline. + name_identifier_format: > + Set the name identifier format to be used for the SAML assertion. From 82f9571206db5d42d34203391358a0ff25348e86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 2 Aug 2024 11:58:12 +0200 Subject: [PATCH 15/80] Sidepanel --- .../side_panel/metadata_component.html.erb | 14 ++++++ .../side_panel/metadata_component.rb | 47 +++++++++++++++++++ .../providers/side_panel_component.html.erb | 11 +++++ .../saml/providers/side_panel_component.rb | 43 +++++++++++++++++ .../app/views/saml/providers/edit.html.erb | 6 +-- .../app/views/saml/providers/new.html.erb | 2 +- .../app/views/saml/providers/show.html.erb | 12 ++++- modules/auth_saml/config/locales/en.yml | 8 +++- 8 files changed, 136 insertions(+), 7 deletions(-) create mode 100644 modules/auth_saml/app/components/saml/providers/side_panel/metadata_component.html.erb create mode 100644 modules/auth_saml/app/components/saml/providers/side_panel/metadata_component.rb create mode 100644 modules/auth_saml/app/components/saml/providers/side_panel_component.html.erb create mode 100644 modules/auth_saml/app/components/saml/providers/side_panel_component.rb diff --git a/modules/auth_saml/app/components/saml/providers/side_panel/metadata_component.html.erb b/modules/auth_saml/app/components/saml/providers/side_panel/metadata_component.html.erb new file mode 100644 index 000000000000..51d7f2b7e180 --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/side_panel/metadata_component.html.erb @@ -0,0 +1,14 @@ +<%= + render(Primer::OpenProject::SidePanel::Section.new) do |section| + section.with_title { I18n.t("saml.providers.label_metadata_endpoint") } + section.with_description { I18n.t("saml.instructions.sp_metadata_endpoint") } + + component_wrapper do + render(Primer::Beta::Link.new( + href: metadata_endpoint, + classes: "-break-word", + target: :_blank + )) { metadata_endpoint } + end + end +%> diff --git a/modules/auth_saml/app/components/saml/providers/side_panel/metadata_component.rb b/modules/auth_saml/app/components/saml/providers/side_panel/metadata_component.rb new file mode 100644 index 000000000000..b04fd9e7709a --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/side_panel/metadata_component.rb @@ -0,0 +1,47 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml::Providers + module SidePanel + class MetadataComponent < ApplicationComponent + include ApplicationHelper + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + def initialize(provider:) + super() + + @provider = provider + end + + def metadata_endpoint + URI.join(helpers.root_url, "/auth/#{@provider.slug}/metadata").to_s + end + end + end +end diff --git a/modules/auth_saml/app/components/saml/providers/side_panel_component.html.erb b/modules/auth_saml/app/components/saml/providers/side_panel_component.html.erb new file mode 100644 index 000000000000..d2182f1a908f --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/side_panel_component.html.erb @@ -0,0 +1,11 @@ +<%= + component_wrapper do + render(Primer::OpenProject::SidePanel.new(spacious: true)) do |panel| + [ + Saml::Providers::SidePanel::MetadataComponent.new(provider: @provider), + ].each do |component| + panel.with_section(component) + end + end + end +%> diff --git a/modules/auth_saml/app/components/saml/providers/side_panel_component.rb b/modules/auth_saml/app/components/saml/providers/side_panel_component.rb new file mode 100644 index 000000000000..6c7328908b2f --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/side_panel_component.rb @@ -0,0 +1,43 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml + module Providers + class SidePanelComponent < ApplicationComponent + include ApplicationHelper + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + def initialize(provider) + super() + + @provider = provider + end + end + end +end diff --git a/modules/auth_saml/app/views/saml/providers/edit.html.erb b/modules/auth_saml/app/views/saml/providers/edit.html.erb index edaa869215e5..414c1d1d508f 100644 --- a/modules/auth_saml/app/views/saml/providers/edit.html.erb +++ b/modules/auth_saml/app/views/saml/providers/edit.html.erb @@ -3,15 +3,15 @@ <% html_title(t(:label_administration), page_title) -%> <%= render(Primer::OpenProject::PageHeader.new) do |header| %> - <% header.with_title { I18n.t(:label_edit_x, x: @provider.display_name) } %> + <% header.with_title { @provider.display_name } %> <% header.with_breadcrumbs([{ href: admin_index_path, text: t("label_administration") }, { href: saml_providers_path, text: t('saml.providers.plural') }, - I18n.t(:label_edit_x, x: @provider.display_name)]) %> + @provider.display_name]) %> <% header.with_description do %> <%= link_translate( - 'saml.instructions.new_description', + 'saml.instructions.documentation_link', links: { docs_url: ::OpenProject::Static::Links[:sysadmin_docs][:saml][:href], }, diff --git a/modules/auth_saml/app/views/saml/providers/new.html.erb b/modules/auth_saml/app/views/saml/providers/new.html.erb index 34ada5f06889..9826181dbdf5 100644 --- a/modules/auth_saml/app/views/saml/providers/new.html.erb +++ b/modules/auth_saml/app/views/saml/providers/new.html.erb @@ -9,7 +9,7 @@ <% header.with_description do %> <%= link_translate( - 'saml.instructions.new_description', + 'saml.instructions.documentation_link', links: { docs_url: ::OpenProject::Static::Links[:sysadmin_docs][:saml][:href], }, diff --git a/modules/auth_saml/app/views/saml/providers/show.html.erb b/modules/auth_saml/app/views/saml/providers/show.html.erb index 1b37c4b73442..d687831a72d4 100644 --- a/modules/auth_saml/app/views/saml/providers/show.html.erb +++ b/modules/auth_saml/app/views/saml/providers/show.html.erb @@ -27,4 +27,14 @@ <% end %> -<%= render(Saml::Providers::ViewComponent.new(@provider)) %> +<%= + render(Primer::Alpha::Layout.new(stacking_breakpoint: :md)) do |content| + content.with_main do + render Saml::Providers::ViewComponent.new(@provider) + end + + content.with_sidebar(row_placement: :start, col_placement: :end) do + render Saml::Providers::SidePanelComponent.new(@provider) + end + end +%> diff --git a/modules/auth_saml/config/locales/en.yml b/modules/auth_saml/config/locales/en.yml index 5c57046d79a5..d58290f4f18a 100644 --- a/modules/auth_saml/config/locales/en.yml +++ b/modules/auth_saml/config/locales/en.yml @@ -28,6 +28,7 @@ en: label_empty_description: "Add a provider to see them here." label_automatic_configuration: Automatic configuration label_metadata: Metadata + label_metadata_endpoint: Metadata endpoint label_configuration_details: "Identity provider endpoints and certificates" label_add_new: New SAML identity provider label_edit: Edit SAML identity provider %{name} @@ -57,8 +58,8 @@ en: metadata_url: "Metadata URL" metadata_xml: "Metadata XML" instructions: - new_description: > - Add a new SAML provider. Please refer to our [documentation on configuring SAML providers](docs_url). + documentation_link: > + Please refer to our [documentation on configuring SAML providers](docs_url) for more information on these configuration options. display_name: > The name of the provider. This will be displayed as the login button and in the list of providers. metadata_url: > @@ -78,3 +79,6 @@ en: You can enter multiple certificates by separating them with a newline. name_identifier_format: > Set the name identifier format to be used for the SAML assertion. + sp_metadata_endpoint: > + This is the URL where the OpenProject SAML metadata is available. + Optionally use it to configure your identity provider. From 8cf8e868de0282f7be52e7e4160879fcbcca13e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 2 Aug 2024 12:05:17 +0200 Subject: [PATCH 16/80] Incomplete label --- .../auth_saml/app/components/saml/providers/row_component.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/modules/auth_saml/app/components/saml/providers/row_component.rb b/modules/auth_saml/app/components/saml/providers/row_component.rb index bf52b4957d00..debdc4ec7682 100644 --- a/modules/auth_saml/app/components/saml/providers/row_component.rb +++ b/modules/auth_saml/app/components/saml/providers/row_component.rb @@ -19,6 +19,10 @@ def name href: url_for(action: :show, id: provider.id) )) { provider.display_name || provider.name } + unless provider.configured? + concat render(Primer::Beta::Label.new(ml: 2, scheme: :attention, size: :medium)) { t(:label_incomplete) } + end + if provider.idp_sso_service_url concat render(Primer::Beta::Text.new( tag: :p, From d2700a44b4d446a3b0cf3d56cbcfe46c285cae21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 2 Aug 2024 12:23:32 +0200 Subject: [PATCH 17/80] Mapping form --- .../sections/mapping_component.html.erb | 31 +++++++ .../providers/sections/mapping_component.rb | 41 ++++++++++ .../sections/mapping_form_component.html.erb | 24 ++++++ .../sections/mapping_form_component.rb | 41 ++++++++++ .../saml/providers/view_component.html.erb | 12 +++ .../auth_saml/app/constants/saml/defaults.rb | 31 +++++-- .../app/forms/saml/providers/mapping_form.rb | 81 +++++++++++++++++++ modules/auth_saml/app/models/saml/provider.rb | 10 +-- modules/auth_saml/config/locales/en.yml | 22 +++++ 9 files changed, 282 insertions(+), 11 deletions(-) create mode 100644 modules/auth_saml/app/components/saml/providers/sections/mapping_component.html.erb create mode 100644 modules/auth_saml/app/components/saml/providers/sections/mapping_component.rb create mode 100644 modules/auth_saml/app/components/saml/providers/sections/mapping_form_component.html.erb create mode 100644 modules/auth_saml/app/components/saml/providers/sections/mapping_form_component.rb create mode 100644 modules/auth_saml/app/forms/saml/providers/mapping_form.rb diff --git a/modules/auth_saml/app/components/saml/providers/sections/mapping_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/mapping_component.html.erb new file mode 100644 index 000000000000..11c93ab2f2fb --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/sections/mapping_component.html.erb @@ -0,0 +1,31 @@ +<%= + grid_layout('op-saml-view-row', tag: :div, align_items: :center) do |grid| + grid.with_area(:title, mr: 3) do + concat render(Primer::Beta::Text.new(font_weight: :bold)) { t("saml.providers.label_mapping") } + end + + grid.with_area(:description) do + render(Primer::Beta::Text.new(tag: :p, font_size: :small, color: :subtle)) do + t("saml.providers.section_texts.mapping") + end + end + + if provider.persisted? + grid.with_area(:action) do + flex_layout(justify_content: :flex_end) do |icons_container| + icons_container.with_column do + render( + Primer::Beta::IconButton.new( + icon: :pencil, + tag: :a, + scheme: :invisible, + href: edit_saml_provider_path(provider, edit_state: :mapping), + aria: { label: I18n.t(:label_edit) } + ) + ) + end + end + end + end + end +%> diff --git a/modules/auth_saml/app/components/saml/providers/sections/mapping_component.rb b/modules/auth_saml/app/components/saml/providers/sections/mapping_component.rb new file mode 100644 index 000000000000..ceaeb06c8d8b --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/sections/mapping_component.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml::Providers::Sections + class MappingComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + attr_reader :provider + + def initialize(provider) + @provider = provider + end + end +end diff --git a/modules/auth_saml/app/components/saml/providers/sections/mapping_form_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/mapping_form_component.html.erb new file mode 100644 index 000000000000..b2a6258a1914 --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/sections/mapping_form_component.html.erb @@ -0,0 +1,24 @@ +<%= + primer_form_with( + model: provider, + url: saml_provider_path(provider, state: :configuration), + method: :put + ) do |form| + flex_layout do |flex| + flex.with_row do + render(Primer::Beta::Text.new(tag: :p, mb: 4, font_size: :small)) do + t("saml.instructions.mapping") + end + end + + flex.with_row do + render(Saml::Providers::MappingForm.new(form, provider:)) + end + + flex.with_row(mt: 4) do + render(Saml::Providers::SubmitOrCancelForm.new(form, + provider:)) + end + end + end +%> diff --git a/modules/auth_saml/app/components/saml/providers/sections/mapping_form_component.rb b/modules/auth_saml/app/components/saml/providers/sections/mapping_form_component.rb new file mode 100644 index 000000000000..bb6ebe1d1e60 --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/sections/mapping_form_component.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml::Providers::Sections + class MappingFormComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + attr_reader :provider + + def initialize(provider) + @provider = provider + end + end +end diff --git a/modules/auth_saml/app/components/saml/providers/view_component.html.erb b/modules/auth_saml/app/components/saml/providers/view_component.html.erb index ac1ee941d605..51252bd0b954 100644 --- a/modules/auth_saml/app/components/saml/providers/view_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/view_component.html.erb @@ -35,5 +35,17 @@ render(Saml::Providers::Sections::ConfigurationComponent.new(provider)) end end + + component.with_row(scheme: :neutral, color: :muted) do + render(Primer::Beta::Text.new(font_weight: :semibold)) { I18n.t('saml.providers.section_headers.attributes') } + end + + component.with_row(scheme: :default) do + if edit_state == :mapping + render(Saml::Providers::Sections::MappingFormComponent.new(provider)) + else + render(Saml::Providers::Sections::MappingComponent.new(provider)) + end + end end %> diff --git a/modules/auth_saml/app/constants/saml/defaults.rb b/modules/auth_saml/app/constants/saml/defaults.rb index b6bd3eb35ff4..58c76e1c9d40 100644 --- a/modules/auth_saml/app/constants/saml/defaults.rb +++ b/modules/auth_saml/app/constants/saml/defaults.rb @@ -39,14 +39,33 @@ module Defaults "urn:oasis:names:tc:SAML:2.0:nameid-format:transient" ] - MAIL_MAPPING = %w[mail email Email emailAddress emailaddress - urn:oid:0.9.2342.19200300.100.1.3 http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress].freeze + MAIL_MAPPING = <<~STR + mail + email + Email + emailAddress + emailaddress + urn:oid:0.9.2342.19200300.100.1.3 + http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress + STR - FIRSTNAME_MAPPING = %w[givenName givenname given_name given_name - urn:oid:2.5.4.42 http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname].freeze + FIRSTNAME_MAPPING = <<~STR + givenName + givenname + given_name + given_name + urn:oid:2.5.4.42 + http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname + STR - LASTNAME_MAPPING = %w[surname sur_name sn - urn:oid:2.5.4.4 http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname].freeze + LASTNAME_MAPPING = <<~STR + surname + sur_name + sn + given_name + urn:oid:2.5.4.4 + http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname + STR REQUESTED_ATTRIBUTES = [ { diff --git a/modules/auth_saml/app/forms/saml/providers/mapping_form.rb b/modules/auth_saml/app/forms/saml/providers/mapping_form.rb new file mode 100644 index 000000000000..5a0202a4f21b --- /dev/null +++ b/modules/auth_saml/app/forms/saml/providers/mapping_form.rb @@ -0,0 +1,81 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml + module Providers + class MappingForm < ApplicationForm + form do |f| + f.text_area( + name: :mapping_login, + label: I18n.t("activemodel.attributes.saml/provider.mapping_login"), + caption: I18n.t("saml.instructions.mapping_login"), + required: true, + rows: 8, + input_width: :large + ) + f.text_area( + name: :mapping_mail, + label: I18n.t("activemodel.attributes.saml/provider.mapping_mail"), + caption: I18n.t("saml.instructions.mapping_mail"), + required: true, + rows: 8, + input_width: :large + ) + f.text_area( + name: :mapping_firstname, + label: I18n.t("activemodel.attributes.saml/provider.mapping_firstname"), + caption: I18n.t("saml.instructions.mapping_firstname"), + required: true, + rows: 8, + input_width: :large + ) + f.text_area( + name: :mapping_lastname, + label: I18n.t("activemodel.attributes.saml/provider.mapping_lastname"), + caption: I18n.t("saml.instructions.mapping_lastname"), + required: true, + rows: 8, + input_width: :large + ) + f.text_field( + name: :mapping_uid, + label: I18n.t("activemodel.attributes.saml/provider.mapping_uid"), + caption: I18n.t("saml.instructions.mapping_uid"), + rows: 8, + required: true, + input_width: :large + ) + end + + def initialize(provider:) + super() + @provider = provider + end + end + end +end diff --git a/modules/auth_saml/app/models/saml/provider.rb b/modules/auth_saml/app/models/saml/provider.rb index 82c629915f3e..2f67640ad8e8 100644 --- a/modules/auth_saml/app/models/saml/provider.rb +++ b/modules/auth_saml/app/models/saml/provider.rb @@ -15,11 +15,11 @@ class Provider < ApplicationRecord store_attribute :options, :idp_cert, :string store_attribute :options, :idp_cert_fingerprint, :string - store_attribute :options, :mapping_login, :json - store_attribute :options, :mapping_mail, :json - store_attribute :options, :mapping_firstname, :json - store_attribute :options, :mapping_lastname, :json - store_attribute :options, :mapping_uid, :json + store_attribute :options, :mapping_login, :string + store_attribute :options, :mapping_mail, :string + store_attribute :options, :mapping_firstname, :string + store_attribute :options, :mapping_lastname, :string + store_attribute :options, :mapping_uid, :string store_attribute :options, :request_attributes, :json diff --git a/modules/auth_saml/config/locales/en.yml b/modules/auth_saml/config/locales/en.yml index d58290f4f18a..3616686a665d 100644 --- a/modules/auth_saml/config/locales/en.yml +++ b/modules/auth_saml/config/locales/en.yml @@ -13,6 +13,11 @@ en: idp_sso_service_url: Identity provider login endpoint idp_slo_service_url: Identity provider logout endpoint idp_cert: Public certificate of identity provider + mapping_login: "Login mapping" + mapping_mail: "Email mapping" + mapping_firstname: "First name mapping" + mapping_lastname: "Last name mapping" + mapping_uid: "Mapping for internal user ID" saml: menu_title: SAML providers info: @@ -32,6 +37,7 @@ en: label_configuration_details: "Identity provider endpoints and certificates" label_add_new: New SAML identity provider label_edit: Edit SAML identity provider %{name} + label_mapping: Mapping no_results_table: No SAML identity providers have been defined yet. plural: SAML identity providers singular: SAML identity provider @@ -48,11 +54,13 @@ en: format: 'Attribute format' section_headers: configuration: "Primary configuration" + attributes: "Attributes" section_texts: display_name: "Configure the display name of the SAML provider." metadata: "Pre-fill configuration using a metadata URL or by pasting metadata XML" metadata_form: "If your identity provider has a metadata endpoint or XML download, add it below to pre-fill configuration." configuration: "Configure the endpoint URLs for the identity provider, certificates, and further SAML options." + mapping: "Manually adjust the mapping between the SAML response and user attributes in OpenProject." settings: metadata_checkbox: "I have metadata (optional)" metadata_url: "Metadata URL" @@ -82,3 +90,17 @@ en: sp_metadata_endpoint: > This is the URL where the OpenProject SAML metadata is available. Optionally use it to configure your identity provider. + mapping: > + Configure the mapping between the SAML response and user attributes in OpenProject. + You can configure multiple attribute names to look for. OpenProject will choose the first available attribute + from the SAML response. + mapping_login: > + SAML attributes from the response used for the login. + mapping_mail: > + SAML attributes from the response used for the email of the user. + mapping_firstname: > + SAML attributes from the response used for the given name. + mapping_lastname: > + SAML attributes from the response used for the last name. + mapping_uid: > + SAML attribute to use for the internal user ID. Leave empty to use the name_id attribute instead From b9bcafc58152ac6aafed8222d84c7e054fc0bd4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 2 Aug 2024 14:38:29 +0200 Subject: [PATCH 18/80] Clear up mapping and validate --- .../saml/providers/update_contract.rb | 5 ++++ .../app/forms/saml/providers/mapping_form.rb | 2 +- .../saml/providers/set_attributes_service.rb | 24 +++++++++++++++++-- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/modules/auth_saml/app/contracts/saml/providers/update_contract.rb b/modules/auth_saml/app/contracts/saml/providers/update_contract.rb index b86e86c0b2a6..8d32027998bf 100644 --- a/modules/auth_saml/app/contracts/saml/providers/update_contract.rb +++ b/modules/auth_saml/app/contracts/saml/providers/update_contract.rb @@ -43,6 +43,11 @@ class UpdateContract < BaseContract validates :idp_slo_service_url, url: { schemes: %w[http https] }, if: -> { model.idp_slo_service_url_changed? } + + %i[mapping_mail mapping_login mapping_firstname mapping_lastname].each do |attr| + attribute attr + validates_presence_of attr + end end end end diff --git a/modules/auth_saml/app/forms/saml/providers/mapping_form.rb b/modules/auth_saml/app/forms/saml/providers/mapping_form.rb index 5a0202a4f21b..7f449a6c10f6 100644 --- a/modules/auth_saml/app/forms/saml/providers/mapping_form.rb +++ b/modules/auth_saml/app/forms/saml/providers/mapping_form.rb @@ -67,7 +67,7 @@ class MappingForm < ApplicationForm label: I18n.t("activemodel.attributes.saml/provider.mapping_uid"), caption: I18n.t("saml.instructions.mapping_uid"), rows: 8, - required: true, + required: false, input_width: :large ) end diff --git a/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb b/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb index d6703bee347d..fbd7b418714c 100644 --- a/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb +++ b/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb @@ -31,10 +31,16 @@ module Providers class SetAttributesService < BaseServices::SetAttributes private + def set_attributes(params) + update_mapping(params) + + super + end + def set_default_attributes(*) model.change_by_system do set_default_creator - set_attribute_mapping + set_default_mapping set_issuer set_name_identifier_format end @@ -48,7 +54,21 @@ def set_default_creator model.creator = user end - def set_attribute_mapping + ## + # Clean up provided mapping, reducing whitespace + def update_mapping(params) + %i[mapping_mail mapping_login mapping_firstname mapping_lastname].each do |attr| + next unless params.key?(attr) + + mapping = params.delete(attr) + mapping.gsub!("\r\n", "\n") + mapping.gsub!(/^\s*(.+?)\s*$/, '\1') + + model.public_send(:"#{attr}=", mapping) + end + end + + def set_default_mapping model.mapping_login ||= Saml::Defaults::MAIL_MAPPING model.mapping_mail ||= Saml::Defaults::MAIL_MAPPING model.mapping_firstname ||= Saml::Defaults::FIRSTNAME_MAPPING From b6e2450c706d1026be3d2da7d5f78a353f6d22a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 2 Aug 2024 14:43:13 +0200 Subject: [PATCH 19/80] Move attributes to base contract --- .../sections/mapping_form_component.html.erb | 2 +- .../contracts/saml/providers/base_contract.rb | 19 +++++++++++++++++++ .../saml/providers/update_contract.rb | 19 ------------------- .../controllers/saml/providers_controller.rb | 3 ++- 4 files changed, 22 insertions(+), 21 deletions(-) diff --git a/modules/auth_saml/app/components/saml/providers/sections/mapping_form_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/mapping_form_component.html.erb index b2a6258a1914..6717765444a7 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/mapping_form_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/sections/mapping_form_component.html.erb @@ -1,7 +1,7 @@ <%= primer_form_with( model: provider, - url: saml_provider_path(provider, state: :configuration), + url: saml_provider_path(provider, state: :mapping), method: :put ) do |form| flex_layout do |flex| diff --git a/modules/auth_saml/app/contracts/saml/providers/base_contract.rb b/modules/auth_saml/app/contracts/saml/providers/base_contract.rb index 617876207b32..9d097ec4f4bf 100644 --- a/modules/auth_saml/app/contracts/saml/providers/base_contract.rb +++ b/modules/auth_saml/app/contracts/saml/providers/base_contract.rb @@ -36,6 +36,25 @@ def self.model attribute :display_name attribute :options + attribute :metadata_url + validates :metadata_url, + url: { allow_blank: true, allow_nil: true, schemes: %w[http https] }, + if: -> { model.metadata_url_changed? } + + attribute :idp_sso_service_url + validates :idp_sso_service_url, + url: { schemes: %w[http https] }, + if: -> { model.idp_sso_service_url_changed? } + + attribute :idp_slo_service_url + validates :idp_slo_service_url, + url: { schemes: %w[http https] }, + if: -> { model.idp_slo_service_url_changed? } + + %i[mapping_mail mapping_login mapping_firstname mapping_lastname].each do |attr| + attribute attr + validates_presence_of attr + end end end end diff --git a/modules/auth_saml/app/contracts/saml/providers/update_contract.rb b/modules/auth_saml/app/contracts/saml/providers/update_contract.rb index 8d32027998bf..44a17f81feac 100644 --- a/modules/auth_saml/app/contracts/saml/providers/update_contract.rb +++ b/modules/auth_saml/app/contracts/saml/providers/update_contract.rb @@ -29,25 +29,6 @@ module Saml module Providers class UpdateContract < BaseContract - attribute :metadata_url - validates :metadata_url, - url: { allow_blank: true, allow_nil: true, schemes: %w[http https] }, - if: -> { model.metadata_url_changed? } - - attribute :idp_sso_service_url - validates :idp_sso_service_url, - url: { schemes: %w[http https] }, - if: -> { model.idp_sso_service_url_changed? } - - attribute :idp_slo_service_url - validates :idp_slo_service_url, - url: { schemes: %w[http https] }, - if: -> { model.idp_slo_service_url_changed? } - - %i[mapping_mail mapping_login mapping_firstname mapping_lastname].each do |attr| - attribute attr - validates_presence_of attr - end end end end diff --git a/modules/auth_saml/app/controllers/saml/providers_controller.rb b/modules/auth_saml/app/controllers/saml/providers_controller.rb index dbeeaf9a95ed..575cec376688 100644 --- a/modules/auth_saml/app/controllers/saml/providers_controller.rb +++ b/modules/auth_saml/app/controllers/saml/providers_controller.rb @@ -155,7 +155,8 @@ def update_params params .require(:saml_provider) .permit(:display_name, :sp_entity_id, :idp_sso_service_url, :idp_slo_service_url, :idp_cert, - :name_identifier_format, :limit_self_registration) + :name_identifier_format, :limit_self_registration, + :mapping_login, :mapping_mail, :mapping_firstname, :mapping_lastname, :mapping_uid) end def find_provider From 0038f797a365d1a9e199905842a3eb06b91b8002 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 2 Aug 2024 14:45:08 +0200 Subject: [PATCH 20/80] Fix http error result --- .../app/services/saml/update_metadata_service.rb | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/modules/auth_saml/app/services/saml/update_metadata_service.rb b/modules/auth_saml/app/services/saml/update_metadata_service.rb index 525294ae09db..8e8779d5ca73 100644 --- a/modules/auth_saml/app/services/saml/update_metadata_service.rb +++ b/modules/auth_saml/app/services/saml/update_metadata_service.rb @@ -36,9 +36,9 @@ def initialize(user:, provider:) end def call - ServiceResult.success(result: updated_metadata) + updated_metadata + updated_metadata rescue StandardError => e - binding.pry OpenProject.logger.error(e) ServiceResult.failure(message: I18n.t("saml.metadata_parser.error", error: e.class.name)) end @@ -56,11 +56,13 @@ def updated_metadata end def parse_xml - parser_instance.parse_to_hash(provider.metadata_xml) + result = parser_instance.parse_to_hash(provider.metadata_xml) + ServiceResult.success(result:) end def parse_url - parser_instance.parse_remote_to_hash(provider.metadata_url) + result = parser_instance.parse_remote_to_hash(provider.metadata_url) + ServiceResult.success(result:) rescue OneLogin::RubySaml::HttpError => e ServiceResult.failure(message: I18n.t("saml.metadata_parser.error", error: e.message)) end From 38039202f4ce7c4fcc8b5dad5a429af5c2424788 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 2 Aug 2024 14:51:25 +0200 Subject: [PATCH 21/80] Fix label for config --- .../saml/providers/sections/configuration_component.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/auth_saml/app/components/saml/providers/sections/configuration_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/configuration_component.html.erb index a7f66752e9e4..e8c01321fd7c 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/configuration_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/sections/configuration_component.html.erb @@ -11,7 +11,7 @@ grid.with_area(:description) do render(Primer::Beta::Text.new(tag: :p, font_size: :small, color: :subtle)) do - t("saml.providers.section_texts.label_configuration_details") + t("saml.providers.section_texts.configuration") end end From 300ec603383e312624037aa30aee3b4c0a18645c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 5 Aug 2024 08:25:40 +0200 Subject: [PATCH 22/80] RequestAttributes form --- .../request_attributes_component.html.erb | 31 ++++++++ .../sections/request_attributes_component.rb | 41 ++++++++++ ...request_attributes_form_component.html.erb | 24 ++++++ .../request_attributes_form_component.rb | 41 ++++++++++ .../saml/providers/view_component.html.erb | 7 ++ .../auth_saml/app/constants/saml/defaults.rb | 41 ++++------ .../controllers/saml/providers_controller.rb | 6 +- .../app/forms/saml/providers/mapping_form.rb | 10 +-- .../saml/providers/request_attributes_form.rb | 75 +++++++++++++++++++ modules/auth_saml/app/models/saml/provider.rb | 12 ++- .../saml/providers/set_attributes_service.rb | 14 +++- modules/auth_saml/config/locales/en.yml | 18 +++-- 12 files changed, 279 insertions(+), 41 deletions(-) create mode 100644 modules/auth_saml/app/components/saml/providers/sections/request_attributes_component.html.erb create mode 100644 modules/auth_saml/app/components/saml/providers/sections/request_attributes_component.rb create mode 100644 modules/auth_saml/app/components/saml/providers/sections/request_attributes_form_component.html.erb create mode 100644 modules/auth_saml/app/components/saml/providers/sections/request_attributes_form_component.rb create mode 100644 modules/auth_saml/app/forms/saml/providers/request_attributes_form.rb diff --git a/modules/auth_saml/app/components/saml/providers/sections/request_attributes_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/request_attributes_component.html.erb new file mode 100644 index 000000000000..8cf99919e831 --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/sections/request_attributes_component.html.erb @@ -0,0 +1,31 @@ +<%= + grid_layout('op-saml-view-row', tag: :div, align_items: :center) do |grid| + grid.with_area(:title, mr: 3) do + concat render(Primer::Beta::Text.new(font_weight: :bold)) { t("saml.providers.requested_attributes") } + end + + grid.with_area(:description) do + render(Primer::Beta::Text.new(tag: :p, font_size: :small, color: :subtle)) do + t("saml.providers.section_texts.requested_attributes") + end + end + + if provider.persisted? + grid.with_area(:action) do + flex_layout(justify_content: :flex_end) do |icons_container| + icons_container.with_column do + render( + Primer::Beta::IconButton.new( + icon: :pencil, + tag: :a, + scheme: :invisible, + href: edit_saml_provider_path(provider, edit_state: :requested_attributes), + aria: { label: I18n.t(:label_edit) } + ) + ) + end + end + end + end + end +%> diff --git a/modules/auth_saml/app/components/saml/providers/sections/request_attributes_component.rb b/modules/auth_saml/app/components/saml/providers/sections/request_attributes_component.rb new file mode 100644 index 000000000000..69548e99d33d --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/sections/request_attributes_component.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml::Providers::Sections + class RequestAttributesComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + attr_reader :provider + + def initialize(provider) + @provider = provider + end + end +end diff --git a/modules/auth_saml/app/components/saml/providers/sections/request_attributes_form_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/request_attributes_form_component.html.erb new file mode 100644 index 000000000000..1149272abb58 --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/sections/request_attributes_form_component.html.erb @@ -0,0 +1,24 @@ +<%= + primer_form_with( + model: provider, + url: saml_provider_path(provider, state: :mapping), + method: :put + ) do |form| + flex_layout do |flex| + flex.with_row do + render(Primer::Beta::Text.new(tag: :p, mb: 4, font_size: :small)) do + t("saml.instructions.requested_attributes") + end + end + + flex.with_row do + render(Saml::Providers::RequestAttributesForm.new(form, provider:)) + end + + flex.with_row(mt: 4) do + render(Saml::Providers::SubmitOrCancelForm.new(form, + provider:)) + end + end + end +%> diff --git a/modules/auth_saml/app/components/saml/providers/sections/request_attributes_form_component.rb b/modules/auth_saml/app/components/saml/providers/sections/request_attributes_form_component.rb new file mode 100644 index 000000000000..52e02e7b28c8 --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/sections/request_attributes_form_component.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml::Providers::Sections + class RequestAttributesFormComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + attr_reader :provider + + def initialize(provider) + @provider = provider + end + end +end diff --git a/modules/auth_saml/app/components/saml/providers/view_component.html.erb b/modules/auth_saml/app/components/saml/providers/view_component.html.erb index 51252bd0b954..ae7efeec42c1 100644 --- a/modules/auth_saml/app/components/saml/providers/view_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/view_component.html.erb @@ -47,5 +47,12 @@ render(Saml::Providers::Sections::MappingComponent.new(provider)) end end + component.with_row(scheme: :default) do + if edit_state == :requested_attributes + render(Saml::Providers::Sections::RequestAttributesFormComponent.new(provider)) + else + render(Saml::Providers::Sections::RequestAttributesComponent.new(provider)) + end + end end %> diff --git a/modules/auth_saml/app/constants/saml/defaults.rb b/modules/auth_saml/app/constants/saml/defaults.rb index 58c76e1c9d40..f66dcadc7934 100644 --- a/modules/auth_saml/app/constants/saml/defaults.rb +++ b/modules/auth_saml/app/constants/saml/defaults.rb @@ -32,12 +32,18 @@ module Saml module Defaults NAME_IDENTIFIER_FORMAT = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" - NAME_IDENTIFIER_FORMATS = [ - "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", - "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", - "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", - "urn:oasis:names:tc:SAML:2.0:nameid-format:transient" - ] + NAME_IDENTIFIER_FORMATS = %w[ + urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress + urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified + urn:oasis:names:tc:SAML:2.0:nameid-format:persistent + urn:oasis:names:tc:SAML:2.0:nameid-format:transient + ].freeze + + ATTRIBUTE_FORMATS = %w[ + urn:oasis:names:tc:SAML:2.0:attrname-format:basic + urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified + urn:oasis:names:tc:SAML:2.0:attrname-format:uri + ].freeze MAIL_MAPPING = <<~STR mail @@ -59,33 +65,12 @@ module Defaults STR LASTNAME_MAPPING = <<~STR + sn surname sur_name - sn given_name urn:oid:2.5.4.4 http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname STR - - REQUESTED_ATTRIBUTES = [ - { - "name" => "mail", - "name_format" => "urn:oasis:names:tc:SAML:2.0:attrname-format:basic", - "friendly_name" => "Email address", - "is_required" => true - }, - { - "name" => "givenName", - "name_format" => "urn:oasis:names:tc:SAML:2.0:attrname-format:basic", - "friendly_name" => "Given name", - "is_required" => true - }, - { - "name" => "sn", - "name_format" => "urn:oasis:names:tc:SAML:2.0:attrname-format:basic", - "friendly_name" => "Family name", - "is_required" => true - } - ].freeze end end diff --git a/modules/auth_saml/app/controllers/saml/providers_controller.rb b/modules/auth_saml/app/controllers/saml/providers_controller.rb index 575cec376688..5041fc0c8710 100644 --- a/modules/auth_saml/app/controllers/saml/providers_controller.rb +++ b/modules/auth_saml/app/controllers/saml/providers_controller.rb @@ -156,7 +156,11 @@ def update_params .require(:saml_provider) .permit(:display_name, :sp_entity_id, :idp_sso_service_url, :idp_slo_service_url, :idp_cert, :name_identifier_format, :limit_self_registration, - :mapping_login, :mapping_mail, :mapping_firstname, :mapping_lastname, :mapping_uid) + :mapping_login, :mapping_mail, :mapping_firstname, :mapping_lastname, :mapping_uid, + :requested_login_attribute, :requested_mail_attribute, :requested_firstname_attribute, + :requested_lastname_attribute, :requested_uid_attribute, + :requested_login_format, :requested_mail_format, :requested_firstname_format, + :requested_lastname_format, :requested_uid_format) end def find_provider diff --git a/modules/auth_saml/app/forms/saml/providers/mapping_form.rb b/modules/auth_saml/app/forms/saml/providers/mapping_form.rb index 7f449a6c10f6..833db7a51ed8 100644 --- a/modules/auth_saml/app/forms/saml/providers/mapping_form.rb +++ b/modules/auth_saml/app/forms/saml/providers/mapping_form.rb @@ -32,7 +32,7 @@ class MappingForm < ApplicationForm form do |f| f.text_area( name: :mapping_login, - label: I18n.t("activemodel.attributes.saml/provider.mapping_login"), + label: I18n.t("saml.providers.label_mapping_for", attribute: User.human_attribute_name(:login)), caption: I18n.t("saml.instructions.mapping_login"), required: true, rows: 8, @@ -40,7 +40,7 @@ class MappingForm < ApplicationForm ) f.text_area( name: :mapping_mail, - label: I18n.t("activemodel.attributes.saml/provider.mapping_mail"), + label: I18n.t("saml.providers.label_mapping_for", attribute: User.human_attribute_name(:mail)), caption: I18n.t("saml.instructions.mapping_mail"), required: true, rows: 8, @@ -48,7 +48,7 @@ class MappingForm < ApplicationForm ) f.text_area( name: :mapping_firstname, - label: I18n.t("activemodel.attributes.saml/provider.mapping_firstname"), + label: I18n.t("saml.providers.label_mapping_for", attribute: User.human_attribute_name(:first_name)), caption: I18n.t("saml.instructions.mapping_firstname"), required: true, rows: 8, @@ -56,7 +56,7 @@ class MappingForm < ApplicationForm ) f.text_area( name: :mapping_lastname, - label: I18n.t("activemodel.attributes.saml/provider.mapping_lastname"), + label: I18n.t("saml.providers.label_mapping_for", attribute: User.human_attribute_name(:last_name)), caption: I18n.t("saml.instructions.mapping_lastname"), required: true, rows: 8, @@ -64,7 +64,7 @@ class MappingForm < ApplicationForm ) f.text_field( name: :mapping_uid, - label: I18n.t("activemodel.attributes.saml/provider.mapping_uid"), + label: I18n.t("saml.providers.label_mapping_for", attribute: I18n.t("saml.providers.label_uid")), caption: I18n.t("saml.instructions.mapping_uid"), rows: 8, required: false, diff --git a/modules/auth_saml/app/forms/saml/providers/request_attributes_form.rb b/modules/auth_saml/app/forms/saml/providers/request_attributes_form.rb new file mode 100644 index 000000000000..b5c9b97f445b --- /dev/null +++ b/modules/auth_saml/app/forms/saml/providers/request_attributes_form.rb @@ -0,0 +1,75 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml + module Providers + class RequestAttributesForm < ApplicationForm + include Redmine::I18n + + form do |f| + %i[login mail firstname lastname uid].each do |attribute| + f.group do |form_group| + uid = attribute == :uid + label = uid ? I18n.t("saml.providers.label_uid") : User.human_attribute_name(attribute) + form_group.text_field( + name: :"requested_#{attribute}_attribute", + label: I18n.t("saml.providers.label_requested_attribute_for", attribute: label), + required: !uid, + caption: uid ? I18n.t("saml.instructions.request_uid") : nil, + input_width: :large + ) + + form_group.select_list( + name: :"requested_#{attribute}_format", + label: I18n.t("activemodel.attributes.saml/provider.format"), + input_width: :large, + caption: link_translate( + "saml.instructions.documentation_link", + links: { + docs_url: ::OpenProject::Static::Links[:sysadmin_docs][:saml][:href] + }, + target: "_blank" + ) + ) do |list| + Saml::Defaults::ATTRIBUTE_FORMATS.each do |format| + list.option(label: format, value: format) + end + end + end + + f.separator unless attribute == :uid + end + end + + def initialize(provider:) + super() + @provider = provider + end + end + end +end diff --git a/modules/auth_saml/app/models/saml/provider.rb b/modules/auth_saml/app/models/saml/provider.rb index 2f67640ad8e8..c56b90f6db7b 100644 --- a/modules/auth_saml/app/models/saml/provider.rb +++ b/modules/auth_saml/app/models/saml/provider.rb @@ -21,7 +21,17 @@ class Provider < ApplicationRecord store_attribute :options, :mapping_lastname, :string store_attribute :options, :mapping_uid, :string - store_attribute :options, :request_attributes, :json + store_attribute :options, :requested_login_attribute, :string + store_attribute :options, :requested_mail_attribute, :string + store_attribute :options, :requested_firstname_attribute, :string + store_attribute :options, :requested_lastname_attribute, :string + store_attribute :options, :requested_uid_attribute, :string + + store_attribute :options, :requested_login_format, :string + store_attribute :options, :requested_mail_format, :string + store_attribute :options, :requested_firstname_format, :string + store_attribute :options, :requested_lastname_format, :string + store_attribute :options, :requested_uid_format, :string attr_accessor :readonly diff --git a/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb b/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb index fbd7b418714c..9e08db9f2b8a 100644 --- a/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb +++ b/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb @@ -41,6 +41,7 @@ def set_default_attributes(*) model.change_by_system do set_default_creator set_default_mapping + set_default_requested_attributes set_issuer set_name_identifier_format end @@ -73,7 +74,18 @@ def set_default_mapping model.mapping_mail ||= Saml::Defaults::MAIL_MAPPING model.mapping_firstname ||= Saml::Defaults::FIRSTNAME_MAPPING model.mapping_lastname ||= Saml::Defaults::LASTNAME_MAPPING - model.request_attributes ||= Saml::Defaults::REQUESTED_ATTRIBUTES + end + + def set_default_requested_attributes + model.requested_login_attribute ||= Saml::Defaults::MAIL_MAPPING.split("\n").first + model.requested_mail_attribute ||= Saml::Defaults::MAIL_MAPPING.split("\n").first + model.requested_firstname_attribute ||= Saml::Defaults::FIRSTNAME_MAPPING.split("\n").first + model.requested_lastname_attribute ||= Saml::Defaults::LASTNAME_MAPPING.split("\n").first + + model.requested_login_format ||= Saml::Defaults::ATTRIBUTE_FORMATS.first + model.requested_mail_format ||= Saml::Defaults::ATTRIBUTE_FORMATS.first + model.requested_firstname_format ||= Saml::Defaults::ATTRIBUTE_FORMATS.first + model.requested_lastname_format ||= Saml::Defaults::ATTRIBUTE_FORMATS.first end def set_issuer diff --git a/modules/auth_saml/config/locales/en.yml b/modules/auth_saml/config/locales/en.yml index 3616686a665d..4043a5398bb0 100644 --- a/modules/auth_saml/config/locales/en.yml +++ b/modules/auth_saml/config/locales/en.yml @@ -13,11 +13,7 @@ en: idp_sso_service_url: Identity provider login endpoint idp_slo_service_url: Identity provider logout endpoint idp_cert: Public certificate of identity provider - mapping_login: "Login mapping" - mapping_mail: "Email mapping" - mapping_firstname: "First name mapping" - mapping_lastname: "Last name mapping" - mapping_uid: "Mapping for internal user ID" + format: "Format" saml: menu_title: SAML providers info: @@ -37,10 +33,14 @@ en: label_configuration_details: "Identity provider endpoints and certificates" label_add_new: New SAML identity provider label_edit: Edit SAML identity provider %{name} + label_uid: Internal user id label_mapping: Mapping + label_mapping_for: "Mapping for: %{attribute}" + label_requested_attribute_for: "Requested attribute for: %{attribute}" no_results_table: No SAML identity providers have been defined yet. plural: SAML identity providers singular: SAML identity provider + requested_attributes: Requested attributes attribute_mapping: Attribute mapping attribute_mapping_text: > The following fields control which attributes provided by the SAML identity provider @@ -61,6 +61,7 @@ en: metadata_form: "If your identity provider has a metadata endpoint or XML download, add it below to pre-fill configuration." configuration: "Configure the endpoint URLs for the identity provider, certificates, and further SAML options." mapping: "Manually adjust the mapping between the SAML response and user attributes in OpenProject." + requested_attributes: "Define the set of attributes to be requested in the SAML request sent to your identity provider." settings: metadata_checkbox: "I have metadata (optional)" metadata_url: "Metadata URL" @@ -104,3 +105,10 @@ en: SAML attributes from the response used for the last name. mapping_uid: > SAML attribute to use for the internal user ID. Leave empty to use the name_id attribute instead + request_uid: > + SAML attribute to request for the internal user ID. By default, the name_id will be used for this field. + requested_attributes: > + These attributes are added to the SAML request XML to communicate to the identity provider which attributes OpenProject requires. + requested_format: > + The format of the requested attribute. This is used to specify the format of the attribute in the SAML request. + Please see [documentation on configuring requested attributes](docs_url) for more information. From e04897454e24bc2cff8185dd2009c61084dd867a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 6 Aug 2024 09:28:08 +0200 Subject: [PATCH 23/80] Sections --- .../sections/configuration_component.rb | 9 +-- .../sections/configuration_form_component.rb | 9 +-- .../sections/encryption_component.html.erb | 36 +++++++++ .../sections/encryption_component.rb | 34 ++++++++ .../encryption_form_component.html.erb | 24 ++++++ .../sections/encryption_form_component.rb | 34 ++++++++ .../providers/sections/mapping_component.rb | 9 +-- .../sections/mapping_form_component.rb | 9 +-- .../providers/sections/metadata_component.rb | 9 +-- .../sections/metadata_form_component.rb | 9 +-- .../saml/providers/sections/name_component.rb | 9 +-- .../providers/sections/name_form_component.rb | 9 +-- .../sections/request_attributes_component.rb | 9 +-- .../request_attributes_form_component.rb | 9 +-- .../providers/sections/section_component.rb | 41 ++++++++++ .../saml/providers/view_component.html.erb | 8 ++ .../forms/saml/providers/encryption_form.rb | 80 +++++++++++++++++++ modules/auth_saml/app/models/saml/provider.rb | 9 ++- modules/auth_saml/config/locales/en.yml | 6 ++ 19 files changed, 281 insertions(+), 81 deletions(-) create mode 100644 modules/auth_saml/app/components/saml/providers/sections/encryption_component.html.erb create mode 100644 modules/auth_saml/app/components/saml/providers/sections/encryption_component.rb create mode 100644 modules/auth_saml/app/components/saml/providers/sections/encryption_form_component.html.erb create mode 100644 modules/auth_saml/app/components/saml/providers/sections/encryption_form_component.rb create mode 100644 modules/auth_saml/app/components/saml/providers/sections/section_component.rb create mode 100644 modules/auth_saml/app/forms/saml/providers/encryption_form.rb diff --git a/modules/auth_saml/app/components/saml/providers/sections/configuration_component.rb b/modules/auth_saml/app/components/saml/providers/sections/configuration_component.rb index a6cc07f82f18..8d7c13d4bb24 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/configuration_component.rb +++ b/modules/auth_saml/app/components/saml/providers/sections/configuration_component.rb @@ -29,13 +29,6 @@ #++ # module Saml::Providers::Sections - class ConfigurationComponent < ApplicationComponent - include OpPrimer::ComponentHelpers - - attr_reader :provider - - def initialize(provider) - @provider = provider - end + class ConfigurationComponent < SectionComponent end end diff --git a/modules/auth_saml/app/components/saml/providers/sections/configuration_form_component.rb b/modules/auth_saml/app/components/saml/providers/sections/configuration_form_component.rb index f2506fd3e043..dd773546a626 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/configuration_form_component.rb +++ b/modules/auth_saml/app/components/saml/providers/sections/configuration_form_component.rb @@ -29,13 +29,6 @@ #++ # module Saml::Providers::Sections - class ConfigurationFormComponent < ApplicationComponent - include OpPrimer::ComponentHelpers - - attr_reader :provider - - def initialize(provider) - @provider = provider - end + class ConfigurationFormComponent < SectionComponent end end diff --git a/modules/auth_saml/app/components/saml/providers/sections/encryption_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/encryption_component.html.erb new file mode 100644 index 000000000000..0f2690194211 --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/sections/encryption_component.html.erb @@ -0,0 +1,36 @@ +<%= + grid_layout('op-saml-view-row', tag: :div, align_items: :center) do |grid| + grid.with_area(:title, mr: 3) do + concat render(Primer::Beta::Text.new(font_weight: :bold)) { t("saml.providers.label_configuration_encryption") } + if provider.configured? + concat render(Primer::Beta::Label.new(scheme: :success, ml: 1)) { t(:label_completed) } + elsif provider.persisted? + concat render(Primer::Beta::Label.new(scheme: :attention, ml: 1)) { t(:label_incomplete) } + end + end + + grid.with_area(:description) do + render(Primer::Beta::Text.new(tag: :p, font_size: :small, color: :subtle)) do + t("saml.providers.section_texts.encryption") + end + end + + if provider.persisted? + grid.with_area(:action) do + flex_layout(justify_content: :flex_end) do |icons_container| + icons_container.with_column do + render( + Primer::Beta::IconButton.new( + icon: :pencil, + tag: :a, + scheme: :invisible, + href: edit_saml_provider_path(provider, edit_state: :encryption), + aria: { label: I18n.t(:label_edit) } + ) + ) + end + end + end + end + end +%> diff --git a/modules/auth_saml/app/components/saml/providers/sections/encryption_component.rb b/modules/auth_saml/app/components/saml/providers/sections/encryption_component.rb new file mode 100644 index 000000000000..d863aa41499a --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/sections/encryption_component.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml::Providers::Sections + class EncryptionComponent < SectionComponent + end +end diff --git a/modules/auth_saml/app/components/saml/providers/sections/encryption_form_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/encryption_form_component.html.erb new file mode 100644 index 000000000000..e6cbdf4dd7b4 --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/sections/encryption_form_component.html.erb @@ -0,0 +1,24 @@ +<%= + primer_form_with( + model: provider, + url: saml_provider_path(provider, state: :encryption), + method: :put + ) do |form| + flex_layout do |flex| + flex.with_row do + render(Primer::Beta::Text.new(tag: :p, mb: 4, font_size: :small)) do + t("saml.providers.section_texts.encryption_form") + end + end + + flex.with_row do + render(Saml::Providers::EncryptionForm.new(form, provider:)) + end + + flex.with_row(mt: 4) do + render(Saml::Providers::SubmitOrCancelForm.new(form, + provider:)) + end + end + end +%> diff --git a/modules/auth_saml/app/components/saml/providers/sections/encryption_form_component.rb b/modules/auth_saml/app/components/saml/providers/sections/encryption_form_component.rb new file mode 100644 index 000000000000..9b904da0affd --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/sections/encryption_form_component.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml::Providers::Sections + class EncryptionFormComponent < SectionComponent + end +end diff --git a/modules/auth_saml/app/components/saml/providers/sections/mapping_component.rb b/modules/auth_saml/app/components/saml/providers/sections/mapping_component.rb index ceaeb06c8d8b..572d79b76fe2 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/mapping_component.rb +++ b/modules/auth_saml/app/components/saml/providers/sections/mapping_component.rb @@ -29,13 +29,6 @@ #++ # module Saml::Providers::Sections - class MappingComponent < ApplicationComponent - include OpPrimer::ComponentHelpers - - attr_reader :provider - - def initialize(provider) - @provider = provider - end + class MappingComponent < SectionComponent end end diff --git a/modules/auth_saml/app/components/saml/providers/sections/mapping_form_component.rb b/modules/auth_saml/app/components/saml/providers/sections/mapping_form_component.rb index bb6ebe1d1e60..bcc2eb7ff3d9 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/mapping_form_component.rb +++ b/modules/auth_saml/app/components/saml/providers/sections/mapping_form_component.rb @@ -29,13 +29,6 @@ #++ # module Saml::Providers::Sections - class MappingFormComponent < ApplicationComponent - include OpPrimer::ComponentHelpers - - attr_reader :provider - - def initialize(provider) - @provider = provider - end + class MappingFormComponent < SectionComponent end end diff --git a/modules/auth_saml/app/components/saml/providers/sections/metadata_component.rb b/modules/auth_saml/app/components/saml/providers/sections/metadata_component.rb index 15e825edfd1b..601e831e1856 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/metadata_component.rb +++ b/modules/auth_saml/app/components/saml/providers/sections/metadata_component.rb @@ -29,13 +29,6 @@ #++ # module Saml::Providers::Sections - class MetadataComponent < ApplicationComponent - include OpPrimer::ComponentHelpers - - attr_reader :provider - - def initialize(provider) - @provider = provider - end + class MetadataComponent < SectionComponent end end diff --git a/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.rb b/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.rb index a5065554b682..516a559e0b4e 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.rb +++ b/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.rb @@ -29,13 +29,6 @@ #++ # module Saml::Providers::Sections - class MetadataFormComponent < ApplicationComponent - include OpPrimer::ComponentHelpers - - attr_reader :provider - - def initialize(provider) - @provider = provider - end + class MetadataFormComponent < SectionComponent end end diff --git a/modules/auth_saml/app/components/saml/providers/sections/name_component.rb b/modules/auth_saml/app/components/saml/providers/sections/name_component.rb index 0d33cb2e5dc4..d7ea52c9b496 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/name_component.rb +++ b/modules/auth_saml/app/components/saml/providers/sections/name_component.rb @@ -29,13 +29,6 @@ #++ # module Saml::Providers::Sections - class NameComponent < ApplicationComponent - include OpPrimer::ComponentHelpers - - attr_reader :provider - - def initialize(provider) - @provider = provider - end + class NameComponent < SectionComponent end end diff --git a/modules/auth_saml/app/components/saml/providers/sections/name_form_component.rb b/modules/auth_saml/app/components/saml/providers/sections/name_form_component.rb index 4f0abfb05231..1a5f6b98557a 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/name_form_component.rb +++ b/modules/auth_saml/app/components/saml/providers/sections/name_form_component.rb @@ -29,13 +29,6 @@ #++ # module Saml::Providers::Sections - class NameFormComponent < ApplicationComponent - include OpPrimer::ComponentHelpers - - attr_reader :provider - - def initialize(provider) - @provider = provider - end + class NameFormComponent < SectionComponent end end diff --git a/modules/auth_saml/app/components/saml/providers/sections/request_attributes_component.rb b/modules/auth_saml/app/components/saml/providers/sections/request_attributes_component.rb index 69548e99d33d..578831c8c502 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/request_attributes_component.rb +++ b/modules/auth_saml/app/components/saml/providers/sections/request_attributes_component.rb @@ -29,13 +29,6 @@ #++ # module Saml::Providers::Sections - class RequestAttributesComponent < ApplicationComponent - include OpPrimer::ComponentHelpers - - attr_reader :provider - - def initialize(provider) - @provider = provider - end + class RequestAttributesComponent < SectionComponent end end diff --git a/modules/auth_saml/app/components/saml/providers/sections/request_attributes_form_component.rb b/modules/auth_saml/app/components/saml/providers/sections/request_attributes_form_component.rb index 52e02e7b28c8..14a1ef01dd7f 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/request_attributes_form_component.rb +++ b/modules/auth_saml/app/components/saml/providers/sections/request_attributes_form_component.rb @@ -29,13 +29,6 @@ #++ # module Saml::Providers::Sections - class RequestAttributesFormComponent < ApplicationComponent - include OpPrimer::ComponentHelpers - - attr_reader :provider - - def initialize(provider) - @provider = provider - end + class RequestAttributesFormComponent < SectionComponent end end diff --git a/modules/auth_saml/app/components/saml/providers/sections/section_component.rb b/modules/auth_saml/app/components/saml/providers/sections/section_component.rb new file mode 100644 index 000000000000..acad97d22cd9 --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/sections/section_component.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml::Providers::Sections + class SectionComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + attr_reader :provider + + def initialize(provider) + @provider = provider + end + end +end diff --git a/modules/auth_saml/app/components/saml/providers/view_component.html.erb b/modules/auth_saml/app/components/saml/providers/view_component.html.erb index ae7efeec42c1..703a20aac5db 100644 --- a/modules/auth_saml/app/components/saml/providers/view_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/view_component.html.erb @@ -36,6 +36,14 @@ end end + component.with_row(scheme: :default) do + if edit_state == :encryption + render(Saml::Providers::Sections::EncryptionFormComponent.new(provider)) + else + render(Saml::Providers::Sections::EncryptionComponent.new(provider)) + end + end + component.with_row(scheme: :neutral, color: :muted) do render(Primer::Beta::Text.new(font_weight: :semibold)) { I18n.t('saml.providers.section_headers.attributes') } end diff --git a/modules/auth_saml/app/forms/saml/providers/encryption_form.rb b/modules/auth_saml/app/forms/saml/providers/encryption_form.rb new file mode 100644 index 000000000000..c9efb9d9d4c0 --- /dev/null +++ b/modules/auth_saml/app/forms/saml/providers/encryption_form.rb @@ -0,0 +1,80 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml + module Providers + class EncryptionForm < ApplicationForm + form do |f| + f.check_box( + name: :authn_requests_signed, + label: I18n.t("activemodel.attributes.saml/provider.authn_requests_signed"), + caption: I18n.t("saml.instructions.authn_requests_signed"), + required: true + ) + f.text_area( + name: :sp_certificate, + rows: 10, + label: I18n.t("activemodel.attributes.saml/provider.sp_certificate"), + caption: I18n.t("saml.instructions.sp_certificate"), + required: false, + input_width: :large + ) + f.text_area( + name: :sp_certificate, + rows: 10, + label: I18n.t("activemodel.attributes.saml/provider.sp_certificate"), + caption: I18n.t("saml.instructions.idp_cert"), + required: false, + input_width: :large + ) + f.select_list( + name: "name_identifier_format", + label: I18n.t("activemodel.attributes.saml/provider.name_identifier_format"), + input_width: :large, + caption: I18n.t("saml.instructions.name_identifier_format") + ) do |list| + Saml::Defaults::NAME_IDENTIFIER_FORMATS.each do |format| + list.option(label: format, value: format) + end + end + f.check_box( + name: :limit_self_registration, + label: I18n.t("activemodel.attributes.saml/provider.limit_self_registration"), + caption: I18n.t("saml.instructions.limit_self_registration"), + required: false, + input_width: :large + ) + end + + def initialize(provider:) + super() + @provider = provider + end + end + end +end diff --git a/modules/auth_saml/app/models/saml/provider.rb b/modules/auth_saml/app/models/saml/provider.rb index c56b90f6db7b..3c7578fa4556 100644 --- a/modules/auth_saml/app/models/saml/provider.rb +++ b/modules/auth_saml/app/models/saml/provider.rb @@ -15,6 +15,10 @@ class Provider < ApplicationRecord store_attribute :options, :idp_cert, :string store_attribute :options, :idp_cert_fingerprint, :string + store_attribute :options, :authn_requests_signed, :boolean + store_attribute :options, :want_assertions_signed, :boolean + store_attribute :options, :want_assertions_encrypted, :boolean + store_attribute :options, :mapping_login, :string store_attribute :options, :mapping_mail, :string store_attribute :options, :mapping_firstname, :string @@ -63,7 +67,10 @@ def to_h .merge( name: slug, display_name:, - assertion_consumer_service_url: + assertion_consumer_service_url:, + check_idp_cert_expiration: true, + check_sp_cert_expiration: true, + metadata_signed: true ) end end diff --git a/modules/auth_saml/config/locales/en.yml b/modules/auth_saml/config/locales/en.yml index 4043a5398bb0..b8c4489de498 100644 --- a/modules/auth_saml/config/locales/en.yml +++ b/modules/auth_saml/config/locales/en.yml @@ -13,6 +13,7 @@ en: idp_sso_service_url: Identity provider login endpoint idp_slo_service_url: Identity provider logout endpoint idp_cert: Public certificate of identity provider + authn_requests_signed: Sign SAML AuthnRequests format: "Format" saml: menu_title: SAML providers @@ -31,6 +32,7 @@ en: label_metadata: Metadata label_metadata_endpoint: Metadata endpoint label_configuration_details: "Identity provider endpoints and certificates" + label_configuration_encryption: "Signatures and Encryption" label_add_new: New SAML identity provider label_edit: Edit SAML identity provider %{name} label_uid: Internal user id @@ -60,6 +62,8 @@ en: metadata: "Pre-fill configuration using a metadata URL or by pasting metadata XML" metadata_form: "If your identity provider has a metadata endpoint or XML download, add it below to pre-fill configuration." configuration: "Configure the endpoint URLs for the identity provider, certificates, and further SAML options." + encryption: "Configure assertion signatures and encryption for SAML requests and responses." + encryption_form: "You may optionally want to encrypt the assertion response, or have requests from OpenProject signed." mapping: "Manually adjust the mapping between the SAML response and user attributes in OpenProject." requested_attributes: "Define the set of attributes to be requested in the SAML request sent to your identity provider." settings: @@ -112,3 +116,5 @@ en: requested_format: > The format of the requested attribute. This is used to specify the format of the attribute in the SAML request. Please see [documentation on configuring requested attributes](docs_url) for more information. + authn_requests_signed: > + If enabled, OpenProject will sign the SAML AuthnRequest. You will have to provide a signing certificate and private key using the fields below. From 150d5d1f5b262952f7d6b2ad10e69e969de14afe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 6 Aug 2024 15:18:51 +0200 Subject: [PATCH 24/80] Setup providers --- .../lib/omni_auth/flexible_strategy.rb | 2 +- modules/auth_saml/app/models/saml/provider.rb | 6 +++++ .../lib/open_project/auth_saml/engine.rb | 22 +++++++++---------- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/modules/auth_plugins/lib/omni_auth/flexible_strategy.rb b/modules/auth_plugins/lib/omni_auth/flexible_strategy.rb index 0ed4caee53f5..1584fe66308e 100644 --- a/modules/auth_plugins/lib/omni_auth/flexible_strategy.rb +++ b/modules/auth_plugins/lib/omni_auth/flexible_strategy.rb @@ -41,7 +41,7 @@ def match_provider! return false unless providers @provider = providers.find do |p| - (current_path =~ /#{path_for_provider(p.to_hash[:name])}/) == 0 + current_path.match?(/#{path_for_provider(p.to_hash[:name])}(\/|\s*$)/) end if @provider diff --git a/modules/auth_saml/app/models/saml/provider.rb b/modules/auth_saml/app/models/saml/provider.rb index 3c7578fa4556..e2011c17f9e8 100644 --- a/modules/auth_saml/app/models/saml/provider.rb +++ b/modules/auth_saml/app/models/saml/provider.rb @@ -62,6 +62,11 @@ def certificate_configured? idp_cert.present? end + def assertion_consumer_service_url + root_url = OpenProject::StaticRouting::StaticUrlHelpers.new.root_url + URI.join(root_url, "/auth/#{slug}/callback").to_s + end + def to_h options .merge( @@ -72,6 +77,7 @@ def to_h check_sp_cert_expiration: true, metadata_signed: true ) + .symbolize_keys end end end diff --git a/modules/auth_saml/lib/open_project/auth_saml/engine.rb b/modules/auth_saml/lib/open_project/auth_saml/engine.rb index 34242d2b0322..92e445ce8649 100644 --- a/modules/auth_saml/lib/open_project/auth_saml/engine.rb +++ b/modules/auth_saml/lib/open_project/auth_saml/engine.rb @@ -3,7 +3,9 @@ module OpenProject module AuthSaml def self.configuration RequestStore.fetch(:openproject_omniauth_saml_provider) do - global_configuration.deep_merge(settings_from_db) + global_configuration + .deep_merge(settings_from_db) + .deep_merge(settings_from_providers) end end @@ -18,20 +20,18 @@ def self.global_configuration @global_configuration ||= Hash(settings_from_config || settings_from_yaml).with_indifferent_access end + def self.settings_from_providers + Saml::Provider.all.each_with_object({}) do |provider, hash| + hash[provider.slug] = provider.to_h + end + end + def self.settings_from_db value = Hash(Setting.plugin_openproject_auth_saml).with_indifferent_access[:providers] value.is_a?(Hash) ? value : {} end - def self.providers - configuration.map do |name, config| - config['name'] = name - readonly = global_configuration.keys.include?(name) - ::Saml::Provider.new(readonly:, **config) - end - end - def self.settings_from_config if OpenProject::Configuration["saml"].present? Rails.logger.info("[auth_saml] Registering saml integration from configuration.yml") @@ -62,8 +62,8 @@ class Engine < ::Rails::Engine :plugin_saml, :saml_providers_path, parent: :authentication, - caption: ->(*) { I18n.t('saml.menu_title') }, - enterprise_feature: 'openid_providers' + caption: ->(*) { I18n.t("saml.menu_title") }, + enterprise_feature: "openid_providers" end assets %w( From acce81ad2e78499376ea6189fbf8d45e471940f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 7 Aug 2024 09:08:26 +0200 Subject: [PATCH 25/80] Common section show component --- .../sections/configuration_component.html.erb | 36 --------------- .../sections/configuration_component.rb | 34 -------------- .../sections/encryption_component.html.erb | 36 --------------- .../sections/encryption_component.rb | 34 -------------- .../sections/mapping_component.html.erb | 31 ------------- .../sections/metadata_component.html.erb | 36 --------------- .../providers/sections/metadata_component.rb | 34 -------------- .../sections/name_component.html.erb | 30 ------------- .../saml/providers/sections/name_component.rb | 34 -------------- .../sections/request_attributes_component.rb | 34 -------------- ...onent.html.erb => show_component.html.erb} | 9 ++-- ...mapping_component.rb => show_component.rb} | 11 ++++- .../saml/providers/view_component.html.erb | 45 ++++++++++++++++--- .../forms/saml/providers/encryption_form.rb | 35 +++++++-------- modules/auth_saml/config/locales/en.yml | 11 ++++- 15 files changed, 80 insertions(+), 370 deletions(-) delete mode 100644 modules/auth_saml/app/components/saml/providers/sections/configuration_component.html.erb delete mode 100644 modules/auth_saml/app/components/saml/providers/sections/configuration_component.rb delete mode 100644 modules/auth_saml/app/components/saml/providers/sections/encryption_component.html.erb delete mode 100644 modules/auth_saml/app/components/saml/providers/sections/encryption_component.rb delete mode 100644 modules/auth_saml/app/components/saml/providers/sections/mapping_component.html.erb delete mode 100644 modules/auth_saml/app/components/saml/providers/sections/metadata_component.html.erb delete mode 100644 modules/auth_saml/app/components/saml/providers/sections/metadata_component.rb delete mode 100644 modules/auth_saml/app/components/saml/providers/sections/name_component.html.erb delete mode 100644 modules/auth_saml/app/components/saml/providers/sections/name_component.rb delete mode 100644 modules/auth_saml/app/components/saml/providers/sections/request_attributes_component.rb rename modules/auth_saml/app/components/saml/providers/sections/{request_attributes_component.html.erb => show_component.html.erb} (83%) rename modules/auth_saml/app/components/saml/providers/sections/{mapping_component.rb => show_component.rb} (80%) diff --git a/modules/auth_saml/app/components/saml/providers/sections/configuration_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/configuration_component.html.erb deleted file mode 100644 index e8c01321fd7c..000000000000 --- a/modules/auth_saml/app/components/saml/providers/sections/configuration_component.html.erb +++ /dev/null @@ -1,36 +0,0 @@ -<%= - grid_layout('op-saml-view-row', tag: :div, align_items: :center) do |grid| - grid.with_area(:title, mr: 3) do - concat render(Primer::Beta::Text.new(font_weight: :bold)) { t("saml.providers.label_configuration_details") } - if provider.configured? - concat render(Primer::Beta::Label.new(scheme: :success, ml: 1)) { t(:label_completed) } - elsif provider.persisted? - concat render(Primer::Beta::Label.new(scheme: :attention, ml: 1)) { t(:label_incomplete) } - end - end - - grid.with_area(:description) do - render(Primer::Beta::Text.new(tag: :p, font_size: :small, color: :subtle)) do - t("saml.providers.section_texts.configuration") - end - end - - if provider.persisted? - grid.with_area(:action) do - flex_layout(justify_content: :flex_end) do |icons_container| - icons_container.with_column do - render( - Primer::Beta::IconButton.new( - icon: :pencil, - tag: :a, - scheme: :invisible, - href: edit_saml_provider_path(provider, edit_state: :configuration), - aria: { label: I18n.t(:label_edit) } - ) - ) - end - end - end - end - end -%> diff --git a/modules/auth_saml/app/components/saml/providers/sections/configuration_component.rb b/modules/auth_saml/app/components/saml/providers/sections/configuration_component.rb deleted file mode 100644 index 8d7c13d4bb24..000000000000 --- a/modules/auth_saml/app/components/saml/providers/sections/configuration_component.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) 2012-2024 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 Saml::Providers::Sections - class ConfigurationComponent < SectionComponent - end -end diff --git a/modules/auth_saml/app/components/saml/providers/sections/encryption_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/encryption_component.html.erb deleted file mode 100644 index 0f2690194211..000000000000 --- a/modules/auth_saml/app/components/saml/providers/sections/encryption_component.html.erb +++ /dev/null @@ -1,36 +0,0 @@ -<%= - grid_layout('op-saml-view-row', tag: :div, align_items: :center) do |grid| - grid.with_area(:title, mr: 3) do - concat render(Primer::Beta::Text.new(font_weight: :bold)) { t("saml.providers.label_configuration_encryption") } - if provider.configured? - concat render(Primer::Beta::Label.new(scheme: :success, ml: 1)) { t(:label_completed) } - elsif provider.persisted? - concat render(Primer::Beta::Label.new(scheme: :attention, ml: 1)) { t(:label_incomplete) } - end - end - - grid.with_area(:description) do - render(Primer::Beta::Text.new(tag: :p, font_size: :small, color: :subtle)) do - t("saml.providers.section_texts.encryption") - end - end - - if provider.persisted? - grid.with_area(:action) do - flex_layout(justify_content: :flex_end) do |icons_container| - icons_container.with_column do - render( - Primer::Beta::IconButton.new( - icon: :pencil, - tag: :a, - scheme: :invisible, - href: edit_saml_provider_path(provider, edit_state: :encryption), - aria: { label: I18n.t(:label_edit) } - ) - ) - end - end - end - end - end -%> diff --git a/modules/auth_saml/app/components/saml/providers/sections/encryption_component.rb b/modules/auth_saml/app/components/saml/providers/sections/encryption_component.rb deleted file mode 100644 index d863aa41499a..000000000000 --- a/modules/auth_saml/app/components/saml/providers/sections/encryption_component.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) 2012-2024 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 Saml::Providers::Sections - class EncryptionComponent < SectionComponent - end -end diff --git a/modules/auth_saml/app/components/saml/providers/sections/mapping_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/mapping_component.html.erb deleted file mode 100644 index 11c93ab2f2fb..000000000000 --- a/modules/auth_saml/app/components/saml/providers/sections/mapping_component.html.erb +++ /dev/null @@ -1,31 +0,0 @@ -<%= - grid_layout('op-saml-view-row', tag: :div, align_items: :center) do |grid| - grid.with_area(:title, mr: 3) do - concat render(Primer::Beta::Text.new(font_weight: :bold)) { t("saml.providers.label_mapping") } - end - - grid.with_area(:description) do - render(Primer::Beta::Text.new(tag: :p, font_size: :small, color: :subtle)) do - t("saml.providers.section_texts.mapping") - end - end - - if provider.persisted? - grid.with_area(:action) do - flex_layout(justify_content: :flex_end) do |icons_container| - icons_container.with_column do - render( - Primer::Beta::IconButton.new( - icon: :pencil, - tag: :a, - scheme: :invisible, - href: edit_saml_provider_path(provider, edit_state: :mapping), - aria: { label: I18n.t(:label_edit) } - ) - ) - end - end - end - end - end -%> diff --git a/modules/auth_saml/app/components/saml/providers/sections/metadata_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/metadata_component.html.erb deleted file mode 100644 index 61ac7fb6f06d..000000000000 --- a/modules/auth_saml/app/components/saml/providers/sections/metadata_component.html.erb +++ /dev/null @@ -1,36 +0,0 @@ -<%= - grid_layout('op-saml-view-row', tag: :div, align_items: :center) do |grid| - grid.with_area(:title, mr: 3) do - concat render(Primer::Beta::Text.new(font_weight: :bold)) { t("saml.providers.label_metadata") } - if provider.has_metadata? - concat render(Primer::Beta::Label.new(scheme: :success, ml: 1)) { t(:label_completed) } - elsif provider.persisted? - concat render(Primer::Beta::Label.new(scheme: :secondary, ml: 1)) { t(:label_not_configured) } - end - end - - grid.with_area(:description) do - render(Primer::Beta::Text.new(tag: :p, font_size: :small, color: :subtle)) do - t("saml.providers.section_texts.metadata") - end - end - - if provider.persisted? - grid.with_area(:action) do - flex_layout(justify_content: :flex_end) do |icons_container| - icons_container.with_column do - render( - Primer::Beta::IconButton.new( - icon: :pencil, - tag: :a, - scheme: :invisible, - href: edit_saml_provider_path(provider, edit_state: :metadata), - aria: { label: I18n.t(:label_edit) } - ) - ) - end - end - end - end - end -%> diff --git a/modules/auth_saml/app/components/saml/providers/sections/metadata_component.rb b/modules/auth_saml/app/components/saml/providers/sections/metadata_component.rb deleted file mode 100644 index 601e831e1856..000000000000 --- a/modules/auth_saml/app/components/saml/providers/sections/metadata_component.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) 2012-2024 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 Saml::Providers::Sections - class MetadataComponent < SectionComponent - end -end diff --git a/modules/auth_saml/app/components/saml/providers/sections/name_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/name_component.html.erb deleted file mode 100644 index 9564ef430504..000000000000 --- a/modules/auth_saml/app/components/saml/providers/sections/name_component.html.erb +++ /dev/null @@ -1,30 +0,0 @@ -<%= - grid_layout('op-saml-view-row', tag: :div, align_items: :center) do |grid| - grid.with_area(:title, mr: 3) do - concat render(Primer::Beta::Text.new(font_weight: :bold)) { t("saml.providers.singular") } - concat render(Primer::Beta::Label.new(scheme: :success, ml: 1)) { t(:label_completed) } - end - - grid.with_area(:description) do - render(Primer::Beta::Text.new(tag: :p, font_size: :small, color: :subtle)) do - t("saml.providers.section_texts.display_name") - end - end - - grid.with_area(:action) do - flex_layout(justify_content: :flex_end) do |icons_container| - icons_container.with_column do - render( - Primer::Beta::IconButton.new( - icon: :pencil, - tag: :a, - scheme: :invisible, - href: edit_saml_provider_path(provider, edit_state: :name), - aria: { label: I18n.t(:label_edit) } - ) - ) - end - end - end - end -%> diff --git a/modules/auth_saml/app/components/saml/providers/sections/name_component.rb b/modules/auth_saml/app/components/saml/providers/sections/name_component.rb deleted file mode 100644 index d7ea52c9b496..000000000000 --- a/modules/auth_saml/app/components/saml/providers/sections/name_component.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) 2012-2024 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 Saml::Providers::Sections - class NameComponent < SectionComponent - end -end diff --git a/modules/auth_saml/app/components/saml/providers/sections/request_attributes_component.rb b/modules/auth_saml/app/components/saml/providers/sections/request_attributes_component.rb deleted file mode 100644 index 578831c8c502..000000000000 --- a/modules/auth_saml/app/components/saml/providers/sections/request_attributes_component.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) 2012-2024 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 Saml::Providers::Sections - class RequestAttributesComponent < SectionComponent - end -end diff --git a/modules/auth_saml/app/components/saml/providers/sections/request_attributes_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/show_component.html.erb similarity index 83% rename from modules/auth_saml/app/components/saml/providers/sections/request_attributes_component.html.erb rename to modules/auth_saml/app/components/saml/providers/sections/show_component.html.erb index 8cf99919e831..1d0734ca694c 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/request_attributes_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/sections/show_component.html.erb @@ -1,12 +1,15 @@ <%= grid_layout('op-saml-view-row', tag: :div, align_items: :center) do |grid| grid.with_area(:title, mr: 3) do - concat render(Primer::Beta::Text.new(font_weight: :bold)) { t("saml.providers.requested_attributes") } + concat render(Primer::Beta::Text.new(font_weight: :bold)) { @heading } + if @label + concat render(Primer::Beta::Label.new(scheme: @label_scheme, ml: 1)) { @label } + end end grid.with_area(:description) do render(Primer::Beta::Text.new(tag: :p, font_size: :small, color: :subtle)) do - t("saml.providers.section_texts.requested_attributes") + @description end end @@ -19,7 +22,7 @@ icon: :pencil, tag: :a, scheme: :invisible, - href: edit_saml_provider_path(provider, edit_state: :requested_attributes), + href: edit_saml_provider_path(provider, edit_state: @edit_state), aria: { label: I18n.t(:label_edit) } ) ) diff --git a/modules/auth_saml/app/components/saml/providers/sections/mapping_component.rb b/modules/auth_saml/app/components/saml/providers/sections/show_component.rb similarity index 80% rename from modules/auth_saml/app/components/saml/providers/sections/mapping_component.rb rename to modules/auth_saml/app/components/saml/providers/sections/show_component.rb index 572d79b76fe2..7ec0b13904e0 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/mapping_component.rb +++ b/modules/auth_saml/app/components/saml/providers/sections/show_component.rb @@ -29,6 +29,15 @@ #++ # module Saml::Providers::Sections - class MappingComponent < SectionComponent + class ShowComponent < SectionComponent + def initialize(provider, edit_state:, heading:, description:, label: nil, label_scheme: :attention) + super(provider) + + @edit_state = edit_state + @heading = heading + @description = description + @label = label + @label_scheme = label_scheme + end end end diff --git a/modules/auth_saml/app/components/saml/providers/view_component.html.erb b/modules/auth_saml/app/components/saml/providers/view_component.html.erb index 703a20aac5db..ac6410c16f8b 100644 --- a/modules/auth_saml/app/components/saml/providers/view_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/view_component.html.erb @@ -8,7 +8,12 @@ if edit_state == :name render(Saml::Providers::Sections::NameFormComponent.new(provider)) else - render(Saml::Providers::Sections::NameComponent.new(provider)) + render(Saml::Providers::Sections::ShowComponent.new( + provider, + edit_state: :name, + heading: t("saml.providers.singular"), + description: t("saml.providers.section_texts.display_name") + )) end end @@ -20,7 +25,14 @@ if edit_state == :metadata render(Saml::Providers::Sections::MetadataFormComponent.new(provider)) else - render(Saml::Providers::Sections::MetadataComponent.new(provider)) + render(Saml::Providers::Sections::ShowComponent.new( + provider, + edit_state: :metadata, + heading: t("saml.providers.label_metadata"), + description: t("saml.providers.section_texts.metadata"), + label: provider.has_metadata? ? t(:label_completed) : t(:label_not_configured), + label_scheme: provider.has_metadata? ? :success : :secondary + )) end end @@ -32,7 +44,13 @@ if edit_state == :configuration render(Saml::Providers::Sections::ConfigurationFormComponent.new(provider)) else - render(Saml::Providers::Sections::ConfigurationComponent.new(provider)) + render(Saml::Providers::Sections::ShowComponent.new( + provider, + edit_state: :configuration, + heading: t("saml.providers.label_configuration_details"), + description: t("saml.providers.section_texts.configuration"), + label: (provider.persisted? && !provider.configured?) ? t(:label_incomplete) : nil, + )) end end @@ -40,7 +58,12 @@ if edit_state == :encryption render(Saml::Providers::Sections::EncryptionFormComponent.new(provider)) else - render(Saml::Providers::Sections::EncryptionComponent.new(provider)) + render(Saml::Providers::Sections::ShowComponent.new( + provider, + edit_state: :configuration, + heading: t("saml.providers.label_configuration_encryption"), + description: t("saml.providers.section_texts.encryption") + )) end end @@ -52,14 +75,24 @@ if edit_state == :mapping render(Saml::Providers::Sections::MappingFormComponent.new(provider)) else - render(Saml::Providers::Sections::MappingComponent.new(provider)) + render(Saml::Providers::Sections::ShowComponent.new( + provider, + edit_state: :configuration, + heading: t("saml.providers.label_mapping"), + description: t("saml.providers.section_texts.mapping") + )) end end component.with_row(scheme: :default) do if edit_state == :requested_attributes render(Saml::Providers::Sections::RequestAttributesFormComponent.new(provider)) else - render(Saml::Providers::Sections::RequestAttributesComponent.new(provider)) + render(Saml::Providers::Sections::ShowComponent.new( + provider, + edit_state: :configuration, + heading: t("saml.providers.requested_attributes"), + description: t("saml.providers.section_texts.requested_attributes") + )) end end end diff --git a/modules/auth_saml/app/forms/saml/providers/encryption_form.rb b/modules/auth_saml/app/forms/saml/providers/encryption_form.rb index c9efb9d9d4c0..52d3507ccd5c 100644 --- a/modules/auth_saml/app/forms/saml/providers/encryption_form.rb +++ b/modules/auth_saml/app/forms/saml/providers/encryption_form.rb @@ -36,6 +36,18 @@ class EncryptionForm < ApplicationForm caption: I18n.t("saml.instructions.authn_requests_signed"), required: true ) + f.check_box( + name: :want_assertions_signed, + label: I18n.t("activemodel.attributes.saml/provider.want_assertions_signed"), + caption: I18n.t("saml.instructions.want_assertions_signed"), + required: true + ) + f.check_box( + name: :want_assertions_encrypted, + label: I18n.t("activemodel.attributes.saml/provider.want_assertions_encrypted"), + caption: I18n.t("saml.instructions.want_assertions_encrypted"), + required: true + ) f.text_area( name: :sp_certificate, rows: 10, @@ -45,27 +57,10 @@ class EncryptionForm < ApplicationForm input_width: :large ) f.text_area( - name: :sp_certificate, + name: :sp_private_key, rows: 10, - label: I18n.t("activemodel.attributes.saml/provider.sp_certificate"), - caption: I18n.t("saml.instructions.idp_cert"), - required: false, - input_width: :large - ) - f.select_list( - name: "name_identifier_format", - label: I18n.t("activemodel.attributes.saml/provider.name_identifier_format"), - input_width: :large, - caption: I18n.t("saml.instructions.name_identifier_format") - ) do |list| - Saml::Defaults::NAME_IDENTIFIER_FORMATS.each do |format| - list.option(label: format, value: format) - end - end - f.check_box( - name: :limit_self_registration, - label: I18n.t("activemodel.attributes.saml/provider.limit_self_registration"), - caption: I18n.t("saml.instructions.limit_self_registration"), + label: I18n.t("activemodel.attributes.saml/provider.sp_private_key"), + caption: I18n.t("saml.instructions.sp_private_key"), required: false, input_width: :large ) diff --git a/modules/auth_saml/config/locales/en.yml b/modules/auth_saml/config/locales/en.yml index b8c4489de498..c94f7741fb08 100644 --- a/modules/auth_saml/config/locales/en.yml +++ b/modules/auth_saml/config/locales/en.yml @@ -14,6 +14,10 @@ en: idp_slo_service_url: Identity provider logout endpoint idp_cert: Public certificate of identity provider authn_requests_signed: Sign SAML AuthnRequests + want_assertions_signed: Require signed responses + want_assertions_encrypted: Require encrypted responses + sp_certificate: Certificate used by OpenProject for SAML requests + sp_private_key: Corresponding private key for OpenProject SAML requests format: "Format" saml: menu_title: SAML providers @@ -117,4 +121,9 @@ en: The format of the requested attribute. This is used to specify the format of the attribute in the SAML request. Please see [documentation on configuring requested attributes](docs_url) for more information. authn_requests_signed: > - If enabled, OpenProject will sign the SAML AuthnRequest. You will have to provide a signing certificate and private key using the fields below. + If checked, OpenProject will sign the SAML AuthnRequest. You will have to provide a signing certificate and private key using the fields below. + want_assertions_signed: > + If checked, OpenProject will required signed responses from the identity provider using it's own certificate keypair. + OpenProject will verify the signature against the certificate from the basic configuration section. + want_assertions_encrypted: > + If enabled, require the identity provider to encrypt the assertion response using the certificate pair that you provide. From 5232f37830199a67056370cae0128c557090c4df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 7 Aug 2024 09:15:32 +0200 Subject: [PATCH 26/80] Common form component --- .../configuration_form_component.html.erb | 24 --------- .../encryption_form_component.html.erb | 24 --------- .../sections/encryption_form_component.rb | 34 ------------ ...onent.html.erb => form_component.html.erb} | 14 ++--- ...on_form_component.rb => form_component.rb} | 25 ++++++++- .../sections/metadata_form_component.html.erb | 4 +- .../sections/name_form_component.html.erb | 17 ------ .../providers/sections/name_form_component.rb | 34 ------------ ...request_attributes_form_component.html.erb | 24 --------- .../request_attributes_form_component.rb | 34 ------------ .../sections/show_component.html.erb | 4 +- .../saml/providers/sections/show_component.rb | 6 ++- .../saml/providers/view_component.html.erb | 53 +++++++++++++++---- .../saml/providers/view_component.rb | 2 +- .../saml/providers/base_form.rb} | 14 +++-- .../saml/providers/configuration_form.rb | 7 +-- .../forms/saml/providers/encryption_form.rb | 7 +-- .../app/forms/saml/providers/mapping_form.rb | 7 +-- .../saml/providers/metadata_checkbox_form.rb | 9 +--- .../forms/saml/providers/metadata_url_form.rb | 2 +- .../forms/saml/providers/metadata_xml_form.rb | 2 +- .../forms/saml/providers/name_input_form.rb | 2 +- .../saml/providers/request_attributes_form.rb | 7 +-- .../app/views/saml/providers/edit.html.erb | 2 +- .../app/views/saml/providers/show.html.erb | 2 +- 25 files changed, 103 insertions(+), 257 deletions(-) delete mode 100644 modules/auth_saml/app/components/saml/providers/sections/configuration_form_component.html.erb delete mode 100644 modules/auth_saml/app/components/saml/providers/sections/encryption_form_component.html.erb delete mode 100644 modules/auth_saml/app/components/saml/providers/sections/encryption_form_component.rb rename modules/auth_saml/app/components/saml/providers/sections/{mapping_form_component.html.erb => form_component.html.erb} (53%) rename modules/auth_saml/app/components/saml/providers/sections/{configuration_form_component.rb => form_component.rb} (71%) delete mode 100644 modules/auth_saml/app/components/saml/providers/sections/name_form_component.html.erb delete mode 100644 modules/auth_saml/app/components/saml/providers/sections/name_form_component.rb delete mode 100644 modules/auth_saml/app/components/saml/providers/sections/request_attributes_form_component.html.erb delete mode 100644 modules/auth_saml/app/components/saml/providers/sections/request_attributes_form_component.rb rename modules/auth_saml/app/{components/saml/providers/sections/mapping_form_component.rb => forms/saml/providers/base_form.rb} (88%) diff --git a/modules/auth_saml/app/components/saml/providers/sections/configuration_form_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/configuration_form_component.html.erb deleted file mode 100644 index e31e20b07a23..000000000000 --- a/modules/auth_saml/app/components/saml/providers/sections/configuration_form_component.html.erb +++ /dev/null @@ -1,24 +0,0 @@ -<%= - primer_form_with( - model: provider, - url: saml_provider_path(provider, state: :configuration), - method: :put - ) do |form| - flex_layout do |flex| - flex.with_row do - render(Primer::Beta::Text.new(tag: :p, mb: 4, font_size: :small)) do - t("saml.providers.section_texts.metadata_form") - end - end - - flex.with_row do - render(Saml::Providers::ConfigurationForm.new(form, provider:)) - end - - flex.with_row(mt: 4) do - render(Saml::Providers::SubmitOrCancelForm.new(form, - provider:)) - end - end - end -%> diff --git a/modules/auth_saml/app/components/saml/providers/sections/encryption_form_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/encryption_form_component.html.erb deleted file mode 100644 index e6cbdf4dd7b4..000000000000 --- a/modules/auth_saml/app/components/saml/providers/sections/encryption_form_component.html.erb +++ /dev/null @@ -1,24 +0,0 @@ -<%= - primer_form_with( - model: provider, - url: saml_provider_path(provider, state: :encryption), - method: :put - ) do |form| - flex_layout do |flex| - flex.with_row do - render(Primer::Beta::Text.new(tag: :p, mb: 4, font_size: :small)) do - t("saml.providers.section_texts.encryption_form") - end - end - - flex.with_row do - render(Saml::Providers::EncryptionForm.new(form, provider:)) - end - - flex.with_row(mt: 4) do - render(Saml::Providers::SubmitOrCancelForm.new(form, - provider:)) - end - end - end -%> diff --git a/modules/auth_saml/app/components/saml/providers/sections/encryption_form_component.rb b/modules/auth_saml/app/components/saml/providers/sections/encryption_form_component.rb deleted file mode 100644 index 9b904da0affd..000000000000 --- a/modules/auth_saml/app/components/saml/providers/sections/encryption_form_component.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) 2012-2024 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 Saml::Providers::Sections - class EncryptionFormComponent < SectionComponent - end -end diff --git a/modules/auth_saml/app/components/saml/providers/sections/mapping_form_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/form_component.html.erb similarity index 53% rename from modules/auth_saml/app/components/saml/providers/sections/mapping_form_component.html.erb rename to modules/auth_saml/app/components/saml/providers/sections/form_component.html.erb index 6717765444a7..5a7c0dce3f01 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/mapping_form_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/sections/form_component.html.erb @@ -1,18 +1,20 @@ <%= primer_form_with( model: provider, - url: saml_provider_path(provider, state: :mapping), - method: :put + url:, + method: form_method, ) do |form| flex_layout do |flex| - flex.with_row do - render(Primer::Beta::Text.new(tag: :p, mb: 4, font_size: :small)) do - t("saml.instructions.mapping") + if @heading + flex.with_row do + render(Primer::Beta::Text.new(tag: :p, mb: 4, font_size: :small)) do + @heading + end end end flex.with_row do - render(Saml::Providers::MappingForm.new(form, provider:)) + render(@form_class.new(form, provider:)) end flex.with_row(mt: 4) do diff --git a/modules/auth_saml/app/components/saml/providers/sections/configuration_form_component.rb b/modules/auth_saml/app/components/saml/providers/sections/form_component.rb similarity index 71% rename from modules/auth_saml/app/components/saml/providers/sections/configuration_form_component.rb rename to modules/auth_saml/app/components/saml/providers/sections/form_component.rb index dd773546a626..bcf8541645a1 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/configuration_form_component.rb +++ b/modules/auth_saml/app/components/saml/providers/sections/form_component.rb @@ -29,6 +29,29 @@ #++ # module Saml::Providers::Sections - class ConfigurationFormComponent < SectionComponent + class FormComponent < SectionComponent + def initialize(provider, edit_state:, form_class:, heading:) + super(provider) + + @edit_state = edit_state + @form_class = form_class + @heading = heading + end + + def url + if provider.new_record? + saml_providers_path(state: @edit_state) + else + saml_provider_path(provider, state: @edit_state) + end + end + + def form_method + if provider.new_record? + :post + else + :put + end + end end end diff --git a/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.html.erb index 09b26b4060ea..d51dbe4ef8b1 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.html.erb @@ -19,11 +19,11 @@ end flex.with_row(mt: 2, hidden: !provider.has_metadata?, data: { 'show-when-checked-target': "effect" }) do - render(Saml::Providers::MetadataUrlForm.new(form)) + render(Saml::Providers::MetadataUrlForm.new(form, provider:)) end flex.with_row(mt: 2, hidden: !provider.has_metadata?, data: { 'show-when-checked-target': "effect" }) do - render(Saml::Providers::MetadataXmlForm.new(form)) + render(Saml::Providers::MetadataXmlForm.new(form, provider:)) end flex.with_row(mt: 4) do diff --git a/modules/auth_saml/app/components/saml/providers/sections/name_form_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/name_form_component.html.erb deleted file mode 100644 index b02055fa0945..000000000000 --- a/modules/auth_saml/app/components/saml/providers/sections/name_form_component.html.erb +++ /dev/null @@ -1,17 +0,0 @@ -<%= - primer_form_with( - model: provider, - url: saml_providers_path, - method: :post, - ) do |form| - flex_layout do |flex| - flex.with_row do - render(Saml::Providers::NameInputForm.new(form)) - end - - flex.with_row(mt: 4) do - render(Saml::Providers::SubmitOrCancelForm.new(form, provider:, state: :name)) - end - end - end -%> diff --git a/modules/auth_saml/app/components/saml/providers/sections/name_form_component.rb b/modules/auth_saml/app/components/saml/providers/sections/name_form_component.rb deleted file mode 100644 index 1a5f6b98557a..000000000000 --- a/modules/auth_saml/app/components/saml/providers/sections/name_form_component.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) 2012-2024 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 Saml::Providers::Sections - class NameFormComponent < SectionComponent - end -end diff --git a/modules/auth_saml/app/components/saml/providers/sections/request_attributes_form_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/request_attributes_form_component.html.erb deleted file mode 100644 index 1149272abb58..000000000000 --- a/modules/auth_saml/app/components/saml/providers/sections/request_attributes_form_component.html.erb +++ /dev/null @@ -1,24 +0,0 @@ -<%= - primer_form_with( - model: provider, - url: saml_provider_path(provider, state: :mapping), - method: :put - ) do |form| - flex_layout do |flex| - flex.with_row do - render(Primer::Beta::Text.new(tag: :p, mb: 4, font_size: :small)) do - t("saml.instructions.requested_attributes") - end - end - - flex.with_row do - render(Saml::Providers::RequestAttributesForm.new(form, provider:)) - end - - flex.with_row(mt: 4) do - render(Saml::Providers::SubmitOrCancelForm.new(form, - provider:)) - end - end - end -%> diff --git a/modules/auth_saml/app/components/saml/providers/sections/request_attributes_form_component.rb b/modules/auth_saml/app/components/saml/providers/sections/request_attributes_form_component.rb deleted file mode 100644 index 14a1ef01dd7f..000000000000 --- a/modules/auth_saml/app/components/saml/providers/sections/request_attributes_form_component.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) 2012-2024 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 Saml::Providers::Sections - class RequestAttributesFormComponent < SectionComponent - end -end diff --git a/modules/auth_saml/app/components/saml/providers/sections/show_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/show_component.html.erb index 1d0734ca694c..40ad76cc1e1a 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/show_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/sections/show_component.html.erb @@ -13,7 +13,7 @@ end end - if provider.persisted? + if provider.persisted? && @view_mode == :show grid.with_area(:action) do flex_layout(justify_content: :flex_end) do |icons_container| icons_container.with_column do @@ -22,7 +22,7 @@ icon: :pencil, tag: :a, scheme: :invisible, - href: edit_saml_provider_path(provider, edit_state: @edit_state), + href: edit_saml_provider_path(provider, edit_state: @target_state), aria: { label: I18n.t(:label_edit) } ) ) diff --git a/modules/auth_saml/app/components/saml/providers/sections/show_component.rb b/modules/auth_saml/app/components/saml/providers/sections/show_component.rb index 7ec0b13904e0..a630a78d5355 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/show_component.rb +++ b/modules/auth_saml/app/components/saml/providers/sections/show_component.rb @@ -30,10 +30,12 @@ # module Saml::Providers::Sections class ShowComponent < SectionComponent - def initialize(provider, edit_state:, heading:, description:, label: nil, label_scheme: :attention) + def initialize(provider, view_mode:, target_state:, + heading:, description:, label: nil, label_scheme: :attention) super(provider) - @edit_state = edit_state + @target_state = target_state + @view_mode = view_mode @heading = heading @description = description @label = label diff --git a/modules/auth_saml/app/components/saml/providers/view_component.html.erb b/modules/auth_saml/app/components/saml/providers/view_component.html.erb index ac6410c16f8b..5c7e28f0e057 100644 --- a/modules/auth_saml/app/components/saml/providers/view_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/view_component.html.erb @@ -6,11 +6,17 @@ component.with_row(scheme: :default) do if edit_state == :name - render(Saml::Providers::Sections::NameFormComponent.new(provider)) + render(Saml::Providers::Sections::FormComponent.new( + provider, + form_class: Saml::Providers::NameInputForm, + edit_state:, + heading: nil + )) else render(Saml::Providers::Sections::ShowComponent.new( provider, - edit_state: :name, + view_mode:, + target_state: :name, heading: t("saml.providers.singular"), description: t("saml.providers.section_texts.display_name") )) @@ -27,7 +33,8 @@ else render(Saml::Providers::Sections::ShowComponent.new( provider, - edit_state: :metadata, + target_state: :metadata, + view_mode:, heading: t("saml.providers.label_metadata"), description: t("saml.providers.section_texts.metadata"), label: provider.has_metadata? ? t(:label_completed) : t(:label_not_configured), @@ -42,11 +49,17 @@ component.with_row(scheme: :default) do if edit_state == :configuration - render(Saml::Providers::Sections::ConfigurationFormComponent.new(provider)) + render(Saml::Providers::Sections::FormComponent.new( + provider, + form_class: Saml::Providers::ConfigurationForm, + edit_state:, + heading: t("saml.providers.section_texts.configuration_form") + )) else render(Saml::Providers::Sections::ShowComponent.new( provider, - edit_state: :configuration, + target_state: :configuration, + view_mode:, heading: t("saml.providers.label_configuration_details"), description: t("saml.providers.section_texts.configuration"), label: (provider.persisted? && !provider.configured?) ? t(:label_incomplete) : nil, @@ -56,11 +69,17 @@ component.with_row(scheme: :default) do if edit_state == :encryption - render(Saml::Providers::Sections::EncryptionFormComponent.new(provider)) + render(Saml::Providers::Sections::FormComponent.new( + provider, + form_class: Saml::Providers::EncryptionForm, + edit_state:, + heading: t("saml.providers.section_texts.encryption_form") + )) else render(Saml::Providers::Sections::ShowComponent.new( provider, - edit_state: :configuration, + target_state: :configuration, + view_mode:, heading: t("saml.providers.label_configuration_encryption"), description: t("saml.providers.section_texts.encryption") )) @@ -73,11 +92,17 @@ component.with_row(scheme: :default) do if edit_state == :mapping - render(Saml::Providers::Sections::MappingFormComponent.new(provider)) + render(Saml::Providers::Sections::FormComponent.new( + provider, + form_class: Saml::Providers::MappingForm, + edit_state:, + heading: t("saml.instructions.mapping") + )) else render(Saml::Providers::Sections::ShowComponent.new( provider, - edit_state: :configuration, + target_state: :mapping, + view_mode:, heading: t("saml.providers.label_mapping"), description: t("saml.providers.section_texts.mapping") )) @@ -85,11 +110,17 @@ end component.with_row(scheme: :default) do if edit_state == :requested_attributes - render(Saml::Providers::Sections::RequestAttributesFormComponent.new(provider)) + render(Saml::Providers::Sections::FormComponent.new( + provider, + form_class: Saml::Providers::RequestAttributesForm, + edit_state:, + heading: t("saml.instructions.requested_attributes") + )) else render(Saml::Providers::Sections::ShowComponent.new( provider, - edit_state: :configuration, + target_state: :configuration, + view_mode:, heading: t("saml.providers.requested_attributes"), description: t("saml.providers.section_texts.requested_attributes") )) diff --git a/modules/auth_saml/app/components/saml/providers/view_component.rb b/modules/auth_saml/app/components/saml/providers/view_component.rb index e5106a384d36..75ddf562dfd9 100644 --- a/modules/auth_saml/app/components/saml/providers/view_component.rb +++ b/modules/auth_saml/app/components/saml/providers/view_component.rb @@ -32,7 +32,7 @@ module Saml::Providers class ViewComponent < ApplicationComponent include OpPrimer::ComponentHelpers - options :edit_state + options :view_mode, :edit_state alias_method :provider, :model end diff --git a/modules/auth_saml/app/components/saml/providers/sections/mapping_form_component.rb b/modules/auth_saml/app/forms/saml/providers/base_form.rb similarity index 88% rename from modules/auth_saml/app/components/saml/providers/sections/mapping_form_component.rb rename to modules/auth_saml/app/forms/saml/providers/base_form.rb index bcc2eb7ff3d9..bea23db3abee 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/mapping_form_component.rb +++ b/modules/auth_saml/app/forms/saml/providers/base_form.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - #-- copyright # OpenProject is an open source project management software. # Copyright (C) 2012-2024 the OpenProject GmbH @@ -27,8 +25,14 @@ # # See COPYRIGHT and LICENSE files for more details. #++ -# -module Saml::Providers::Sections - class MappingFormComponent < SectionComponent + +module Saml + module Providers + class BaseForm < ApplicationForm + def initialize(provider:) + super() + @provider = provider + end + end end end diff --git a/modules/auth_saml/app/forms/saml/providers/configuration_form.rb b/modules/auth_saml/app/forms/saml/providers/configuration_form.rb index eeac93067e36..448028c4f923 100644 --- a/modules/auth_saml/app/forms/saml/providers/configuration_form.rb +++ b/modules/auth_saml/app/forms/saml/providers/configuration_form.rb @@ -28,7 +28,7 @@ module Saml module Providers - class ConfigurationForm < ApplicationForm + class ConfigurationForm < BaseForm form do |f| f.text_field( name: :sp_entity_id, @@ -77,11 +77,6 @@ class ConfigurationForm < ApplicationForm input_width: :large ) end - - def initialize(provider:) - super() - @provider = provider - end end end end diff --git a/modules/auth_saml/app/forms/saml/providers/encryption_form.rb b/modules/auth_saml/app/forms/saml/providers/encryption_form.rb index 52d3507ccd5c..2e99edb9b1da 100644 --- a/modules/auth_saml/app/forms/saml/providers/encryption_form.rb +++ b/modules/auth_saml/app/forms/saml/providers/encryption_form.rb @@ -28,7 +28,7 @@ module Saml module Providers - class EncryptionForm < ApplicationForm + class EncryptionForm < BaseForm form do |f| f.check_box( name: :authn_requests_signed, @@ -65,11 +65,6 @@ class EncryptionForm < ApplicationForm input_width: :large ) end - - def initialize(provider:) - super() - @provider = provider - end end end end diff --git a/modules/auth_saml/app/forms/saml/providers/mapping_form.rb b/modules/auth_saml/app/forms/saml/providers/mapping_form.rb index 833db7a51ed8..999a6a164bf4 100644 --- a/modules/auth_saml/app/forms/saml/providers/mapping_form.rb +++ b/modules/auth_saml/app/forms/saml/providers/mapping_form.rb @@ -28,7 +28,7 @@ module Saml module Providers - class MappingForm < ApplicationForm + class MappingForm < BaseForm form do |f| f.text_area( name: :mapping_login, @@ -71,11 +71,6 @@ class MappingForm < ApplicationForm input_width: :large ) end - - def initialize(provider:) - super() - @provider = provider - end end end end diff --git a/modules/auth_saml/app/forms/saml/providers/metadata_checkbox_form.rb b/modules/auth_saml/app/forms/saml/providers/metadata_checkbox_form.rb index 79a580d2ac00..7dc4448092f6 100644 --- a/modules/auth_saml/app/forms/saml/providers/metadata_checkbox_form.rb +++ b/modules/auth_saml/app/forms/saml/providers/metadata_checkbox_form.rb @@ -28,7 +28,7 @@ module Saml module Providers - class MetadataCheckboxForm < ApplicationForm + class MetadataCheckboxForm < BaseForm form do |f| f.check_box( name: :metadata_url, @@ -37,15 +37,10 @@ class MetadataCheckboxForm < ApplicationForm required: false, input_width: :medium, data: { - 'show-when-checked-target': 'cause' + "show-when-checked-target": "cause" } ) end - - def initialize(provider:) - super() - @provider = provider - end end end end diff --git a/modules/auth_saml/app/forms/saml/providers/metadata_url_form.rb b/modules/auth_saml/app/forms/saml/providers/metadata_url_form.rb index 649c0cb0a066..9dbdbbc0beb9 100644 --- a/modules/auth_saml/app/forms/saml/providers/metadata_url_form.rb +++ b/modules/auth_saml/app/forms/saml/providers/metadata_url_form.rb @@ -28,7 +28,7 @@ module Saml module Providers - class MetadataUrlForm < ApplicationForm + class MetadataUrlForm < BaseForm form do |f| f.text_field( name: :metadata_url, diff --git a/modules/auth_saml/app/forms/saml/providers/metadata_xml_form.rb b/modules/auth_saml/app/forms/saml/providers/metadata_xml_form.rb index 86a2718d51d4..670a4d5f0889 100644 --- a/modules/auth_saml/app/forms/saml/providers/metadata_xml_form.rb +++ b/modules/auth_saml/app/forms/saml/providers/metadata_xml_form.rb @@ -28,7 +28,7 @@ module Saml module Providers - class MetadataXmlForm < ApplicationForm + class MetadataXmlForm < BaseForm form do |f| f.text_area( name: :metadata_xml, diff --git a/modules/auth_saml/app/forms/saml/providers/name_input_form.rb b/modules/auth_saml/app/forms/saml/providers/name_input_form.rb index e4b8c68e42d3..33e01879e723 100644 --- a/modules/auth_saml/app/forms/saml/providers/name_input_form.rb +++ b/modules/auth_saml/app/forms/saml/providers/name_input_form.rb @@ -28,7 +28,7 @@ module Saml module Providers - class NameInputForm < ApplicationForm + class NameInputForm < BaseForm form do |f| f.text_field( name: :display_name, diff --git a/modules/auth_saml/app/forms/saml/providers/request_attributes_form.rb b/modules/auth_saml/app/forms/saml/providers/request_attributes_form.rb index b5c9b97f445b..01882ff4c6cd 100644 --- a/modules/auth_saml/app/forms/saml/providers/request_attributes_form.rb +++ b/modules/auth_saml/app/forms/saml/providers/request_attributes_form.rb @@ -28,7 +28,7 @@ module Saml module Providers - class RequestAttributesForm < ApplicationForm + class RequestAttributesForm < BaseForm include Redmine::I18n form do |f| @@ -65,11 +65,6 @@ class RequestAttributesForm < ApplicationForm f.separator unless attribute == :uid end end - - def initialize(provider:) - super() - @provider = provider - end end end end diff --git a/modules/auth_saml/app/views/saml/providers/edit.html.erb b/modules/auth_saml/app/views/saml/providers/edit.html.erb index 414c1d1d508f..d4aee70f9334 100644 --- a/modules/auth_saml/app/views/saml/providers/edit.html.erb +++ b/modules/auth_saml/app/views/saml/providers/edit.html.erb @@ -20,4 +20,4 @@ <% end %> <% end %> -<%= render(Saml::Providers::ViewComponent.new(@provider, edit_state: @edit_state)) %> +<%= render(Saml::Providers::ViewComponent.new(@provider, view_mode: :edit, edit_state: @edit_state)) %> diff --git a/modules/auth_saml/app/views/saml/providers/show.html.erb b/modules/auth_saml/app/views/saml/providers/show.html.erb index d687831a72d4..42d188b0f6b0 100644 --- a/modules/auth_saml/app/views/saml/providers/show.html.erb +++ b/modules/auth_saml/app/views/saml/providers/show.html.erb @@ -30,7 +30,7 @@ <%= render(Primer::Alpha::Layout.new(stacking_breakpoint: :md)) do |content| content.with_main do - render Saml::Providers::ViewComponent.new(@provider) + render Saml::Providers::ViewComponent.new(@provider, view_mode: :show) end content.with_sidebar(row_placement: :start, col_placement: :end) do From ff57c6add8b34de32530c81e59bc6d4e6c03ec3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 7 Aug 2024 10:59:35 +0200 Subject: [PATCH 27/80] Cert parsing --- .../app/contracts/saml/providers/base_contract.rb | 14 ++++++++++++++ .../app/forms/saml/providers/configuration_form.rb | 2 +- .../app/services/saml/update_metadata_service.rb | 3 +-- modules/auth_saml/config/locales/en.yml | 8 ++++++++ 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/modules/auth_saml/app/contracts/saml/providers/base_contract.rb b/modules/auth_saml/app/contracts/saml/providers/base_contract.rb index 9d097ec4f4bf..2e1f0bc71c9d 100644 --- a/modules/auth_saml/app/contracts/saml/providers/base_contract.rb +++ b/modules/auth_saml/app/contracts/saml/providers/base_contract.rb @@ -51,10 +51,24 @@ def self.model url: { schemes: %w[http https] }, if: -> { model.idp_slo_service_url_changed? } + attribute :idp_cert + validates_presence_of :idp_cert, + if: -> { model.idp_cert_changed? } + validate :idp_cert_is_valid, + if: -> { model.idp_cert_changed? } + %i[mapping_mail mapping_login mapping_firstname mapping_lastname].each do |attr| attribute attr validates_presence_of attr end + + def idp_cert_is_valid + return if model.idp_cert.blank? + + OpenSSL::X509::Certificate.load(model.idp_cert) + rescue OpenSSL::X509::CertificateError => e + errors.add :idp_cert, :invalid_certificate, additional_message: e.message + end end end end diff --git a/modules/auth_saml/app/forms/saml/providers/configuration_form.rb b/modules/auth_saml/app/forms/saml/providers/configuration_form.rb index 448028c4f923..da39f5cb98b0 100644 --- a/modules/auth_saml/app/forms/saml/providers/configuration_form.rb +++ b/modules/auth_saml/app/forms/saml/providers/configuration_form.rb @@ -56,7 +56,7 @@ class ConfigurationForm < BaseForm rows: 10, label: I18n.t("activemodel.attributes.saml/provider.idp_cert"), caption: I18n.t("saml.instructions.idp_cert"), - required: false, + required: true, input_width: :large ) f.select_list( diff --git a/modules/auth_saml/app/services/saml/update_metadata_service.rb b/modules/auth_saml/app/services/saml/update_metadata_service.rb index 8e8779d5ca73..a4416d117911 100644 --- a/modules/auth_saml/app/services/saml/update_metadata_service.rb +++ b/modules/auth_saml/app/services/saml/update_metadata_service.rb @@ -37,7 +37,6 @@ def initialize(user:, provider:) def call updated_metadata - updated_metadata rescue StandardError => e OpenProject.logger.error(e) ServiceResult.failure(message: I18n.t("saml.metadata_parser.error", error: e.class.name)) @@ -51,7 +50,7 @@ def updated_metadata elsif provider.metadata_xml.present? parse_xml else - {} + ServiceResult.success(result: {}) end end diff --git a/modules/auth_saml/config/locales/en.yml b/modules/auth_saml/config/locales/en.yml index c94f7741fb08..a82e01b4b015 100644 --- a/modules/auth_saml/config/locales/en.yml +++ b/modules/auth_saml/config/locales/en.yml @@ -19,6 +19,14 @@ en: sp_certificate: Certificate used by OpenProject for SAML requests sp_private_key: Corresponding private key for OpenProject SAML requests format: "Format" + activerecord: + errors: + models: + saml/provider: + attributes: + idp_cert: + invalid_certificate: "The certificate for the identity provider is invalid: %{additional_message}" + format: "%{message}" saml: menu_title: SAML providers info: From d779692b1269d2d7212f7f175e3a1406d56ea759 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 7 Aug 2024 11:18:58 +0200 Subject: [PATCH 28/80] private key validation --- .../saml/providers/view_component.html.erb | 4 +-- .../contracts/saml/providers/base_contract.rb | 35 +++++++++++++++++++ .../controllers/saml/providers_controller.rb | 2 ++ .../forms/saml/providers/encryption_form.rb | 12 +++---- modules/auth_saml/app/models/saml/provider.rb | 2 ++ modules/auth_saml/config/locales/en.yml | 17 +++++++-- 6 files changed, 61 insertions(+), 11 deletions(-) diff --git a/modules/auth_saml/app/components/saml/providers/view_component.html.erb b/modules/auth_saml/app/components/saml/providers/view_component.html.erb index 5c7e28f0e057..dfd0cff379f7 100644 --- a/modules/auth_saml/app/components/saml/providers/view_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/view_component.html.erb @@ -78,7 +78,7 @@ else render(Saml::Providers::Sections::ShowComponent.new( provider, - target_state: :configuration, + target_state: :encryption, view_mode:, heading: t("saml.providers.label_configuration_encryption"), description: t("saml.providers.section_texts.encryption") @@ -119,7 +119,7 @@ else render(Saml::Providers::Sections::ShowComponent.new( provider, - target_state: :configuration, + target_state: :requested_attributes, view_mode:, heading: t("saml.providers.requested_attributes"), description: t("saml.providers.section_texts.requested_attributes") diff --git a/modules/auth_saml/app/contracts/saml/providers/base_contract.rb b/modules/auth_saml/app/contracts/saml/providers/base_contract.rb index 2e1f0bc71c9d..b3c6edd41807 100644 --- a/modules/auth_saml/app/contracts/saml/providers/base_contract.rb +++ b/modules/auth_saml/app/contracts/saml/providers/base_contract.rb @@ -57,6 +57,9 @@ def self.model validate :idp_cert_is_valid, if: -> { model.idp_cert_changed? } + attribute :authn_requests_signed + validate :authn_requests_signed_requires_cert + %i[mapping_mail mapping_login mapping_firstname mapping_lastname].each do |attr| attribute attr validates_presence_of attr @@ -69,6 +72,38 @@ def idp_cert_is_valid rescue OpenSSL::X509::CertificateError => e errors.add :idp_cert, :invalid_certificate, additional_message: e.message end + + def valid_certificate + if model.certificate.blank? + errors.add :certificate, :blank + else + OpenSSL::X509::Certificate.new(model.certificate) + end + rescue OpenSSL::X509::CertificateError => e + errors.add :certificate, :invalid_certificate, additional_message: e.message + end + + def valid_sp_key + if model.private_key.blank? + errors.add :private_key, :blank + else + OpenSSL::PKey::RSA.new(model.private_key) + end + rescue OpenSSL::X509::CertificateError => e + errors.add :private_key, :invalid_private_key, additional_message: e.message + end + + def authn_requests_signed_requires_cert + return unless model.authn_requests_signed + return unless model.authn_requests_signed_changed? || model.certificate_changed? || model.private_key_changed? + + cert = valid_certificate + key = valid_sp_key + + unless cert.public_key.public_to_pem == key.public_key.public_to_pem + errors.add :private_key, :unmatched_private_key + end + end end end end diff --git a/modules/auth_saml/app/controllers/saml/providers_controller.rb b/modules/auth_saml/app/controllers/saml/providers_controller.rb index 5041fc0c8710..66d2388ae7ed 100644 --- a/modules/auth_saml/app/controllers/saml/providers_controller.rb +++ b/modules/auth_saml/app/controllers/saml/providers_controller.rb @@ -156,6 +156,8 @@ def update_params .require(:saml_provider) .permit(:display_name, :sp_entity_id, :idp_sso_service_url, :idp_slo_service_url, :idp_cert, :name_identifier_format, :limit_self_registration, + :certificate, :private_key, :authn_requests_signed, + :want_assertions_signed, :want_assertions_encrypted, :mapping_login, :mapping_mail, :mapping_firstname, :mapping_lastname, :mapping_uid, :requested_login_attribute, :requested_mail_attribute, :requested_firstname_attribute, :requested_lastname_attribute, :requested_uid_attribute, diff --git a/modules/auth_saml/app/forms/saml/providers/encryption_form.rb b/modules/auth_saml/app/forms/saml/providers/encryption_form.rb index 2e99edb9b1da..88c0a2a5adfd 100644 --- a/modules/auth_saml/app/forms/saml/providers/encryption_form.rb +++ b/modules/auth_saml/app/forms/saml/providers/encryption_form.rb @@ -49,18 +49,18 @@ class EncryptionForm < BaseForm required: true ) f.text_area( - name: :sp_certificate, + name: :certificate, rows: 10, - label: I18n.t("activemodel.attributes.saml/provider.sp_certificate"), - caption: I18n.t("saml.instructions.sp_certificate"), + label: I18n.t("activemodel.attributes.saml/provider.certificate"), + caption: I18n.t("saml.instructions.certificate"), required: false, input_width: :large ) f.text_area( - name: :sp_private_key, + name: :private_key, rows: 10, - label: I18n.t("activemodel.attributes.saml/provider.sp_private_key"), - caption: I18n.t("saml.instructions.sp_private_key"), + label: I18n.t("activemodel.attributes.saml/provider.private_key"), + caption: I18n.t("saml.instructions.private_key"), required: false, input_width: :large ) diff --git a/modules/auth_saml/app/models/saml/provider.rb b/modules/auth_saml/app/models/saml/provider.rb index e2011c17f9e8..0a2f74f0c892 100644 --- a/modules/auth_saml/app/models/saml/provider.rb +++ b/modules/auth_saml/app/models/saml/provider.rb @@ -15,6 +15,8 @@ class Provider < ApplicationRecord store_attribute :options, :idp_cert, :string store_attribute :options, :idp_cert_fingerprint, :string + store_attribute :options, :certificate, :string + store_attribute :options, :private_key, :string store_attribute :options, :authn_requests_signed, :boolean store_attribute :options, :want_assertions_signed, :boolean store_attribute :options, :want_assertions_encrypted, :boolean diff --git a/modules/auth_saml/config/locales/en.yml b/modules/auth_saml/config/locales/en.yml index a82e01b4b015..72816050ff13 100644 --- a/modules/auth_saml/config/locales/en.yml +++ b/modules/auth_saml/config/locales/en.yml @@ -16,8 +16,8 @@ en: authn_requests_signed: Sign SAML AuthnRequests want_assertions_signed: Require signed responses want_assertions_encrypted: Require encrypted responses - sp_certificate: Certificate used by OpenProject for SAML requests - sp_private_key: Corresponding private key for OpenProject SAML requests + certificate: Certificate used by OpenProject for SAML requests + private_key: Corresponding private key for OpenProject SAML requests format: "Format" activerecord: errors: @@ -25,7 +25,14 @@ en: saml/provider: attributes: idp_cert: - invalid_certificate: "The certificate for the identity provider is invalid: %{additional_message}" + invalid_certificate: "The certificate provided for the identity provider is invalid: %{additional_message}" + format: "%{message}" + certificate: + invalid_certificate: "The certificate is invalid: %{additional_message}" + format: "%{message}" + private_key: + invalid_private_key: "The provided private key is invalid: %{additional_message}" + unmatched_private_key: "The provided private key does not belong to the given certificate" format: "%{message}" saml: menu_title: SAML providers @@ -135,3 +142,7 @@ en: OpenProject will verify the signature against the certificate from the basic configuration section. want_assertions_encrypted: > If enabled, require the identity provider to encrypt the assertion response using the certificate pair that you provide. + certificate: > + Enter the X509 PEM-formatted certificate used by OpenProject for signing SAML requests. + private_key: > + Enter the X509 PEM-formatted private key for the above certificate. This needs to be an RSA private key. From da7096a96055c06119b073cf5bdf70c946f1bff3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 7 Aug 2024 14:57:50 +0200 Subject: [PATCH 29/80] Signatures --- .../auth_saml/app/constants/saml/defaults.rb | 14 +++++++++++++ .../contracts/saml/providers/base_contract.rb | 2 ++ .../forms/saml/providers/encryption_form.rb | 20 +++++++++++++++++++ modules/auth_saml/app/models/saml/provider.rb | 2 +- modules/auth_saml/config/locales/en.yml | 20 +++++++++---------- 5 files changed, 46 insertions(+), 12 deletions(-) diff --git a/modules/auth_saml/app/constants/saml/defaults.rb b/modules/auth_saml/app/constants/saml/defaults.rb index f66dcadc7934..493129d56527 100644 --- a/modules/auth_saml/app/constants/saml/defaults.rb +++ b/modules/auth_saml/app/constants/saml/defaults.rb @@ -32,6 +32,20 @@ module Saml module Defaults NAME_IDENTIFIER_FORMAT = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" + SIGNATURE_METHODS = { + "RSA SHA-1" => XMLSecurity::Document::RSA_SHA1, + "RSA SHA-256" => XMLSecurity::Document::RSA_SHA256, + "RSA SHA-384" => XMLSecurity::Document::RSA_SHA384, + "RSA SHA-512" => XMLSecurity::Document::RSA_SHA512 + }.freeze + + DIGEST_METHODS = { + "SHA-1" => XMLSecurity::Document::SHA1, + "SHA-256" => XMLSecurity::Document::SHA256, + "SHA-384" => XMLSecurity::Document::SHA384, + "SHA-512" => XMLSecurity::Document::SHA512 + }.freeze + NAME_IDENTIFIER_FORMATS = %w[ urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified diff --git a/modules/auth_saml/app/contracts/saml/providers/base_contract.rb b/modules/auth_saml/app/contracts/saml/providers/base_contract.rb index b3c6edd41807..5ac5ab61f7b0 100644 --- a/modules/auth_saml/app/contracts/saml/providers/base_contract.rb +++ b/modules/auth_saml/app/contracts/saml/providers/base_contract.rb @@ -100,6 +100,8 @@ def authn_requests_signed_requires_cert cert = valid_certificate key = valid_sp_key + return unless errors.empty? + unless cert.public_key.public_to_pem == key.public_key.public_to_pem errors.add :private_key, :unmatched_private_key end diff --git a/modules/auth_saml/app/forms/saml/providers/encryption_form.rb b/modules/auth_saml/app/forms/saml/providers/encryption_form.rb index 88c0a2a5adfd..d86698612c17 100644 --- a/modules/auth_saml/app/forms/saml/providers/encryption_form.rb +++ b/modules/auth_saml/app/forms/saml/providers/encryption_form.rb @@ -64,6 +64,26 @@ class EncryptionForm < BaseForm required: false, input_width: :large ) + f.select_list( + name: :digest_method, + label: I18n.t("activemodel.attributes.saml/provider.digest_method"), + input_width: :large, + caption: I18n.t("saml.instructions.digest_method", default_option: "SHA-1") + ) do |list| + Saml::Defaults::DIGEST_METHODS.each do |label, value| + list.option(label:, value:) + end + end + f.select_list( + name: :signature_method, + label: I18n.t("activemodel.attributes.saml/provider.signature_method"), + input_width: :large, + caption: I18n.t("saml.instructions.signature_method", default_option: "RSA SHA-1") + ) do |list| + Saml::Defaults::SIGNATURE_METHODS.each do |label, value| + list.option(label:, value:) + end + end end end end diff --git a/modules/auth_saml/app/models/saml/provider.rb b/modules/auth_saml/app/models/saml/provider.rb index 0a2f74f0c892..2d64c587c7a3 100644 --- a/modules/auth_saml/app/models/saml/provider.rb +++ b/modules/auth_saml/app/models/saml/provider.rb @@ -77,7 +77,7 @@ def to_h assertion_consumer_service_url:, check_idp_cert_expiration: true, check_sp_cert_expiration: true, - metadata_signed: true + metadata_signed: certificate.present? && private_key.present? ) .symbolize_keys end diff --git a/modules/auth_saml/config/locales/en.yml b/modules/auth_saml/config/locales/en.yml index 72816050ff13..88742931aa31 100644 --- a/modules/auth_saml/config/locales/en.yml +++ b/modules/auth_saml/config/locales/en.yml @@ -18,22 +18,16 @@ en: want_assertions_encrypted: Require encrypted responses certificate: Certificate used by OpenProject for SAML requests private_key: Corresponding private key for OpenProject SAML requests + signature_method: Signature algorithm + digest_method: Digest algorithm format: "Format" activerecord: errors: models: saml/provider: - attributes: - idp_cert: - invalid_certificate: "The certificate provided for the identity provider is invalid: %{additional_message}" - format: "%{message}" - certificate: - invalid_certificate: "The certificate is invalid: %{additional_message}" - format: "%{message}" - private_key: - invalid_private_key: "The provided private key is invalid: %{additional_message}" - unmatched_private_key: "The provided private key does not belong to the given certificate" - format: "%{message}" + invalid_certificate: "is not a valid PEM-formatted certificate: %{additional_message}" + invalid_private_key: "is not a valid PEM-formatted private key: %{additional_message}" + unmatched_private_key: "does not belong to the given certificate" saml: menu_title: SAML providers info: @@ -146,3 +140,7 @@ en: Enter the X509 PEM-formatted certificate used by OpenProject for signing SAML requests. private_key: > Enter the X509 PEM-formatted private key for the above certificate. This needs to be an RSA private key. + signature_method: > + Select the signature algorithm to use for the SAML request signature performed by OpenProject (Default: %{default_option}). + digest_method: > + Select the digest algorithm to use for the SAML request signature performed by OpenProject (Default: %{default_option}). From 195db69d4bf838240ac452a955d32426bec6a303 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 7 Aug 2024 15:13:43 +0200 Subject: [PATCH 30/80] Accept non-formatted cert --- .../contracts/saml/providers/base_contract.rb | 44 +++++++++++-------- modules/auth_saml/app/models/saml/provider.rb | 29 ++++++++++++ .../saml/providers/set_attributes_service.rb | 20 +++++++++ 3 files changed, 74 insertions(+), 19 deletions(-) diff --git a/modules/auth_saml/app/contracts/saml/providers/base_contract.rb b/modules/auth_saml/app/contracts/saml/providers/base_contract.rb index 5ac5ab61f7b0..738949677b78 100644 --- a/modules/auth_saml/app/contracts/saml/providers/base_contract.rb +++ b/modules/auth_saml/app/contracts/saml/providers/base_contract.rb @@ -62,50 +62,56 @@ def self.model %i[mapping_mail mapping_login mapping_firstname mapping_lastname].each do |attr| attribute attr - validates_presence_of attr + validates_presence_of attr, if: -> { model.public_send(:"#{attr}_changed?") } end def idp_cert_is_valid - return if model.idp_cert.blank? - - OpenSSL::X509::Certificate.load(model.idp_cert) + model.loaded_idp_certificates rescue OpenSSL::X509::CertificateError => e errors.add :idp_cert, :invalid_certificate, additional_message: e.message end def valid_certificate - if model.certificate.blank? + if model.loaded_certificate.blank? errors.add :certificate, :blank - else - OpenSSL::X509::Certificate.new(model.certificate) end - rescue OpenSSL::X509::CertificateError => e + rescue OpenSSL::OpenSSLError => e errors.add :certificate, :invalid_certificate, additional_message: e.message end def valid_sp_key - if model.private_key.blank? + if model.loaded_private_key.blank? errors.add :private_key, :blank - else - OpenSSL::PKey::RSA.new(model.private_key) end - rescue OpenSSL::X509::CertificateError => e + rescue OpenSSL::OpenSSLError => e errors.add :private_key, :invalid_private_key, additional_message: e.message end def authn_requests_signed_requires_cert - return unless model.authn_requests_signed - return unless model.authn_requests_signed_changed? || model.certificate_changed? || model.private_key_changed? + return unless should_test_certificate? + return if certificate_invalid? - cert = valid_certificate - key = valid_sp_key + cert = model.loaded_certificate + key = model.loaded_private_key - return unless errors.empty? - - unless cert.public_key.public_to_pem == key.public_key.public_to_pem + if cert && key && cert.public_key.public_to_pem != key.public_key.public_to_pem errors.add :private_key, :unmatched_private_key end end + + def certificate_invalid? + valid_certificate + valid_sp_key + + errors.any? + end + + def should_test_certificate? + return false unless model.authn_requests_signed + return false unless model.authn_requests_signed_changed? || model.certificate_changed? || model.private_key_changed? + + model.certificate.present? && model.private_key.present? + end end end end diff --git a/modules/auth_saml/app/models/saml/provider.rb b/modules/auth_saml/app/models/saml/provider.rb index 2d64c587c7a3..8b65ce198b27 100644 --- a/modules/auth_saml/app/models/saml/provider.rb +++ b/modules/auth_saml/app/models/saml/provider.rb @@ -60,10 +60,39 @@ def configured? sp_entity_id.present? && idp_sso_service_url.present? && certificate_configured? end + def loaded_certificate + return nil if certificate.blank? + + OpenSSL::X509::Certificate.new(certificate) + end + + def loaded_private_key + return nil if private_key.blank? + + OpenSSL::PKey::RSA.new(private_key) + end + + def loaded_idp_certificates + return nil if idp_cert.blank? + + OpenSSL::X509::Certificate.load(idp_cert) + end + def certificate_configured? idp_cert.present? end + def idp_cert=(cert) + formatted = + if cert.include?("BEGIN CERTIFICATE") + cert + else + OneLogin::RubySaml::Utils.format_cert(cert) + end + + super(formatted) + end + def assertion_consumer_service_url root_url = OpenProject::StaticRouting::StaticUrlHelpers.new.root_url URI.join(root_url, "/auth/#{slug}/callback").to_s diff --git a/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb b/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb index 9e08db9f2b8a..9218a761676e 100644 --- a/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb +++ b/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb @@ -33,10 +33,21 @@ class SetAttributesService < BaseServices::SetAttributes def set_attributes(params) update_mapping(params) + update_options(params.delete(:options)) if params.key?(:options) super end + def update_options(options) + update_idp_cert(options.delete(:idp_cert)) if options.key?(:idp_cert) + + options + .select { |key, _| Saml::Provider.stored_attributes[:options].include?(key.to_s) } + .each do |key, value| + model.public_send(:"#{key}=", value) + end + end + def set_default_attributes(*) model.change_by_system do set_default_creator @@ -55,6 +66,15 @@ def set_default_creator model.creator = user end + def update_idp_cert(cert) + model.idp_cert = + if cert.include?("BEGIN CERTIFICATE") + cert + else + OneLogin::RubySaml::Utils.format_cert(cert) + end + end + ## # Clean up provided mapping, reducing whitespace def update_mapping(params) From 0fdc9ab3d85d001179aa421f9ae09959cb567816 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 7 Aug 2024 15:35:47 +0200 Subject: [PATCH 31/80] available state --- .../app/components/saml/providers/row_component.rb | 2 +- .../components/saml/providers/view_component.html.erb | 4 +++- .../app/controllers/saml/providers_controller.rb | 3 ++- modules/auth_saml/app/models/saml/provider.rb | 11 ++++++++++- .../services/saml/providers/set_attributes_service.rb | 8 ++++++++ 5 files changed, 24 insertions(+), 4 deletions(-) diff --git a/modules/auth_saml/app/components/saml/providers/row_component.rb b/modules/auth_saml/app/components/saml/providers/row_component.rb index debdc4ec7682..50005ef98586 100644 --- a/modules/auth_saml/app/components/saml/providers/row_component.rb +++ b/modules/auth_saml/app/components/saml/providers/row_component.rb @@ -19,7 +19,7 @@ def name href: url_for(action: :show, id: provider.id) )) { provider.display_name || provider.name } - unless provider.configured? + unless provider.available? concat render(Primer::Beta::Label.new(ml: 2, scheme: :attention, size: :medium)) { t(:label_incomplete) } end diff --git a/modules/auth_saml/app/components/saml/providers/view_component.html.erb b/modules/auth_saml/app/components/saml/providers/view_component.html.erb index dfd0cff379f7..69078ba90f0f 100644 --- a/modules/auth_saml/app/components/saml/providers/view_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/view_component.html.erb @@ -104,7 +104,9 @@ target_state: :mapping, view_mode:, heading: t("saml.providers.label_mapping"), - description: t("saml.providers.section_texts.mapping") + description: t("saml.providers.section_texts.mapping"), + label: provider.mapping_configured? ? t(:label_completed) : t(:label_incomplete), + label_scheme: provider.mapping_configured? ? :success : :attention )) end end diff --git a/modules/auth_saml/app/controllers/saml/providers_controller.rb b/modules/auth_saml/app/controllers/saml/providers_controller.rb index 66d2388ae7ed..69b0000c466c 100644 --- a/modules/auth_saml/app/controllers/saml/providers_controller.rb +++ b/modules/auth_saml/app/controllers/saml/providers_controller.rb @@ -8,7 +8,7 @@ class ProvidersController < ::ApplicationController before_action :find_provider, only: %i[show edit import_metadata update destroy] def index - @providers = Saml::Provider.all + @providers = Saml::Provider.all.order(display_name: "ASC") end def edit @@ -38,6 +38,7 @@ def create flash[:notice] = I18n.t(:notice_successful_create) redirect_to edit_saml_provider_path(call.result, edit_state: :metadata) else + flash[:error] = call.message @provider = call.result render action: :new end diff --git a/modules/auth_saml/app/models/saml/provider.rb b/modules/auth_saml/app/models/saml/provider.rb index 8b65ce198b27..b908f89dbb0c 100644 --- a/modules/auth_saml/app/models/saml/provider.rb +++ b/modules/auth_saml/app/models/saml/provider.rb @@ -57,7 +57,16 @@ def has_metadata? end def configured? - sp_entity_id.present? && idp_sso_service_url.present? && certificate_configured? + sp_entity_id.present? && + idp_sso_service_url.present? && + certificate_configured? + end + + def mapping_configured? + mapping_login.present? && + mapping_mail.present? && + mapping_firstname.present? && + mapping_lastname.present? end def loaded_certificate diff --git a/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb b/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb index 9218a761676e..0386a8b5b056 100644 --- a/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb +++ b/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb @@ -36,6 +36,14 @@ def set_attributes(params) update_options(params.delete(:options)) if params.key?(:options) super + + update_available_state + end + + def update_available_state + model.change_by_system do + model.available = model.configured? && model.mapping_configured? + end end def update_options(options) From d9a33c8bdc97676175132ad3a63c6cc26779cb88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 7 Aug 2024 16:11:31 +0200 Subject: [PATCH 32/80] Fix hash building --- .../contracts/saml/providers/base_contract.rb | 11 ++- .../controllers/saml/providers_controller.rb | 12 +--- modules/auth_saml/app/models/saml/provider.rb | 25 +++---- .../app/models/saml/provider/hash_builder.rb | 72 +++++++++++++++++++ .../saml/providers/set_attributes_service.rb | 6 ++ modules/auth_saml/config/locales/en.yml | 1 + 6 files changed, 100 insertions(+), 27 deletions(-) create mode 100644 modules/auth_saml/app/models/saml/provider/hash_builder.rb diff --git a/modules/auth_saml/app/contracts/saml/providers/base_contract.rb b/modules/auth_saml/app/contracts/saml/providers/base_contract.rb index 738949677b78..6ac62cb22713 100644 --- a/modules/auth_saml/app/contracts/saml/providers/base_contract.rb +++ b/modules/auth_saml/app/contracts/saml/providers/base_contract.rb @@ -66,7 +66,12 @@ def self.model end def idp_cert_is_valid - model.loaded_idp_certificates + model.loaded_idp_certificates.each do |cert| + if OneLogin::RubySaml::Utils.is_cert_expired(cert) + errors.add :certificate, :certificate_expired + return + end + end rescue OpenSSL::X509::CertificateError => e errors.add :idp_cert, :invalid_certificate, additional_message: e.message end @@ -75,6 +80,10 @@ def valid_certificate if model.loaded_certificate.blank? errors.add :certificate, :blank end + + if OneLogin::RubySaml::Utils.is_cert_expired(model.loaded_certificate) + errors.add :certificate, :certificate_expired + end rescue OpenSSL::OpenSSLError => e errors.add :certificate, :invalid_certificate, additional_message: e.message end diff --git a/modules/auth_saml/app/controllers/saml/providers_controller.rb b/modules/auth_saml/app/controllers/saml/providers_controller.rb index 69b0000c466c..5206ed737f35 100644 --- a/modules/auth_saml/app/controllers/saml/providers_controller.rb +++ b/modules/auth_saml/app/controllers/saml/providers_controller.rb @@ -8,7 +8,7 @@ class ProvidersController < ::ApplicationController before_action :find_provider, only: %i[show edit import_metadata update destroy] def index - @providers = Saml::Provider.all.order(display_name: "ASC") + @providers = Saml::Provider.order(display_name: :asc) end def edit @@ -155,15 +155,7 @@ def create_params def update_params params .require(:saml_provider) - .permit(:display_name, :sp_entity_id, :idp_sso_service_url, :idp_slo_service_url, :idp_cert, - :name_identifier_format, :limit_self_registration, - :certificate, :private_key, :authn_requests_signed, - :want_assertions_signed, :want_assertions_encrypted, - :mapping_login, :mapping_mail, :mapping_firstname, :mapping_lastname, :mapping_uid, - :requested_login_attribute, :requested_mail_attribute, :requested_firstname_attribute, - :requested_lastname_attribute, :requested_uid_attribute, - :requested_login_format, :requested_mail_format, :requested_firstname_format, - :requested_lastname_format, :requested_uid_format) + .permit(:display_name, *Saml::Provider.stored_attributes[:options]) end def find_provider diff --git a/modules/auth_saml/app/models/saml/provider.rb b/modules/auth_saml/app/models/saml/provider.rb index b908f89dbb0c..1a59208a1a5f 100644 --- a/modules/auth_saml/app/models/saml/provider.rb +++ b/modules/auth_saml/app/models/saml/provider.rb @@ -1,5 +1,7 @@ module Saml class Provider < ApplicationRecord + include HashBuilder + self.table_name = "saml_providers" belongs_to :creator, class_name: "User" @@ -13,6 +15,8 @@ class Provider < ApplicationRecord store_attribute :options, :idp_slo_service_url, :string store_attribute :options, :idp_cert, :string + # Allow fallbcak to fingerprint from previous versions, + # but we do not offer this in the UI store_attribute :options, :idp_cert_fingerprint, :string store_attribute :options, :certificate, :string @@ -20,6 +24,8 @@ class Provider < ApplicationRecord store_attribute :options, :authn_requests_signed, :boolean store_attribute :options, :want_assertions_signed, :boolean store_attribute :options, :want_assertions_encrypted, :boolean + store_attribute :options, :digest_method, :string + store_attribute :options, :signature_method, :string store_attribute :options, :mapping_login, :string store_attribute :options, :mapping_mail, :string @@ -72,19 +78,19 @@ def mapping_configured? def loaded_certificate return nil if certificate.blank? - OpenSSL::X509::Certificate.new(certificate) + @loaded_certificate ||= OpenSSL::X509::Certificate.new(certificate) end def loaded_private_key return nil if private_key.blank? - OpenSSL::PKey::RSA.new(private_key) + @loaded_private_key ||= OpenSSL::PKey::RSA.new(private_key) end def loaded_idp_certificates return nil if idp_cert.blank? - OpenSSL::X509::Certificate.load(idp_cert) + @loaded_idp_certificates ||= OpenSSL::X509::Certificate.load(idp_cert) end def certificate_configured? @@ -106,18 +112,5 @@ def assertion_consumer_service_url root_url = OpenProject::StaticRouting::StaticUrlHelpers.new.root_url URI.join(root_url, "/auth/#{slug}/callback").to_s end - - def to_h - options - .merge( - name: slug, - display_name:, - assertion_consumer_service_url:, - check_idp_cert_expiration: true, - check_sp_cert_expiration: true, - metadata_signed: certificate.present? && private_key.present? - ) - .symbolize_keys - end end end diff --git a/modules/auth_saml/app/models/saml/provider/hash_builder.rb b/modules/auth_saml/app/models/saml/provider/hash_builder.rb new file mode 100644 index 000000000000..c208b94e53f0 --- /dev/null +++ b/modules/auth_saml/app/models/saml/provider/hash_builder.rb @@ -0,0 +1,72 @@ +module Saml + module Provider::HashBuilder + def formatted_attribute_statements + { + email: mapping_mail&.split("[\r\n]+"), + login: mapping_login&.split("[\r\n]+"), + first_name: mapping_firstname&.split("[\r\n]+"), + last_name: mapping_lastname&.split("[\r\n]+") + }.compact + end + + def formatted_request_attributes + [ + { name: requested_login_attribute, format: requested_login_format, friendly_name: "Login" }, + { name: requested_mail_attribute, format: requested_mail_format, friendly_name: "Email" }, + { name: requested_firstname_attribute, format: requested_firstname_format, friendly_name: "First Name" }, + { name: requested_lastname_attribute, format: requested_lastname_format, friendly_name: "Last Name" } + ] + end + + def idp_cert_options_hash + if idp_cert_fingerprint.present? + return { idp_cert_fingerprint: } + end + + certificates = loaded_idp_certificates.map(&:to_pem) + if certificates.count > 1 + { + idp_cert_multi: { + signing: certificates, + encryption: certificates + } + } + else + { idp_cert: certificates.first } + end + end + + def security_options_hash + { + check_idp_cert_expiration: false, # done in contract + check_sp_cert_expiration: false, # done in contract + metadata_signed: certificate.present? && private_key.present?, + authn_requests_signed:, + want_assertions_signed:, + want_assertions_encrypted:, + digest_method:, + signature_method: + }.compact + end + + def to_h + { + name: slug, + display_name:, + assertion_consumer_service_url:, + sp_entity_id:, + idp_sso_service_url:, + idp_slo_service_url:, + name_identifier_format:, + certificate:, + private_key:, + attribute_statements: formatted_attribute_statements, + request_attributes: formatted_request_attributes, + uid_attribute: mapping_uid + } + .merge(idp_cert_options_hash) + .merge(security: security_options_hash) + .compact + end + end +end diff --git a/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb b/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb index 0386a8b5b056..d4e12a009277 100644 --- a/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb +++ b/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb @@ -63,9 +63,15 @@ def set_default_attributes(*) set_default_requested_attributes set_issuer set_name_identifier_format + set_default_digest end end + def set_default_digest + model.signature_method ||= Saml::Defaults::SIGNATURE_METHODS["RSA SHA-1"] + model.digest_method ||= Saml::Defaults::DIGEST_METHODS["SHA-1"] + end + def set_name_identifier_format model.name_identifier_format ||= Saml::Defaults::NAME_IDENTIFIER_FORMAT end diff --git a/modules/auth_saml/config/locales/en.yml b/modules/auth_saml/config/locales/en.yml index 88742931aa31..ed7b17260da8 100644 --- a/modules/auth_saml/config/locales/en.yml +++ b/modules/auth_saml/config/locales/en.yml @@ -27,6 +27,7 @@ en: saml/provider: invalid_certificate: "is not a valid PEM-formatted certificate: %{additional_message}" invalid_private_key: "is not a valid PEM-formatted private key: %{additional_message}" + certificate_expired: "is expired and can no longer be used." unmatched_private_key: "does not belong to the given certificate" saml: menu_title: SAML providers From 9b663a8cf37c881c6b83f1bace7b98081a8a9cf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 7 Aug 2024 16:31:48 +0200 Subject: [PATCH 33/80] Metadata form --- .../show-when-value-selected.controller.ts | 22 ++++++++++ frontend/src/stimulus/setup.ts | 2 + .../sections/metadata_form_component.html.erb | 16 ++++++-- .../controllers/saml/providers_controller.rb | 2 +- ...ckbox_form.rb => metadata_options_form.rb} | 41 ++++++++++++++----- modules/auth_saml/config/locales/en.yml | 8 ++-- 6 files changed, 72 insertions(+), 19 deletions(-) create mode 100644 frontend/src/stimulus/controllers/show-when-value-selected.controller.ts rename modules/auth_saml/app/forms/saml/providers/{metadata_checkbox_form.rb => metadata_options_form.rb} (52%) diff --git a/frontend/src/stimulus/controllers/show-when-value-selected.controller.ts b/frontend/src/stimulus/controllers/show-when-value-selected.controller.ts new file mode 100644 index 000000000000..6c28e08c22c4 --- /dev/null +++ b/frontend/src/stimulus/controllers/show-when-value-selected.controller.ts @@ -0,0 +1,22 @@ +import { ApplicationController } from 'stimulus-use'; + +export default class OpShowWhenValueSelectedController extends ApplicationController { + static targets = ['cause', 'effect']; + + declare readonly effectTargets:HTMLInputElement[]; + + causeTargetConnected(target:HTMLElement) { + target.addEventListener('change', this.toggleDisabled.bind(this)); + } + + causeTargetDisconnected(target:HTMLElement) { + target.removeEventListener('change', this.toggleDisabled.bind(this)); + } + + private toggleDisabled(evt:InputEvent):void { + const value = (evt.target as HTMLInputElement).value; + this.effectTargets.forEach((el) => { + el.hidden = !(el.dataset.value === value); + }); + } +} diff --git a/frontend/src/stimulus/setup.ts b/frontend/src/stimulus/setup.ts index 9d46a67f5ecc..eb160bfe6943 100644 --- a/frontend/src/stimulus/setup.ts +++ b/frontend/src/stimulus/setup.ts @@ -9,6 +9,7 @@ import AsyncDialogController from './controllers/async-dialog.controller'; import PollForChangesController from './controllers/poll-for-changes.controller'; import TableHighlightingController from './controllers/table-highlighting.controller'; import OpShowWhenCheckedController from "./controllers/show-when-checked.controller"; +import OpShowWhenValueSelectedController from "./controllers/show-when-value-selected.controller"; declare global { interface Window { @@ -29,6 +30,7 @@ instance.register('menus--main', MainMenuController); instance.register('show-when-checked', OpShowWhenCheckedController); instance.register('disable-when-checked', OpDisableWhenCheckedController); +instance.register('show-when-value-selected', OpShowWhenValueSelectedController); instance.register('print', PrintController); instance.register('refresh-on-form-changes', RefreshOnFormChangesController); instance.register('async-dialog', AsyncDialogController); diff --git a/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.html.erb index d51dbe4ef8b1..86a2ed284709 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.html.erb @@ -3,7 +3,7 @@ model: provider, url: import_metadata_saml_provider_path(provider), data: { - controller: "show-when-checked" + controller: "show-when-value-selected" }, method: :post, ) do |form| @@ -15,14 +15,22 @@ end flex.with_row do - render(Saml::Providers::MetadataCheckboxForm.new(form, provider:)) + render(Saml::Providers::MetadataOptionsForm.new(form, provider:)) end - flex.with_row(mt: 2, hidden: !provider.has_metadata?, data: { 'show-when-checked-target': "effect" }) do + flex.with_row( + mt: 2, + hidden: provider.metadata_url.blank?, + data: { value: :url, 'show-when-value-selected-target': "effect" } + ) do render(Saml::Providers::MetadataUrlForm.new(form, provider:)) end - flex.with_row(mt: 2, hidden: !provider.has_metadata?, data: { 'show-when-checked-target': "effect" }) do + flex.with_row( + mt: 2, + hidden: provider.metadata_xml.blank?, + data: { value: :xml, 'show-when-value-selected-target': "effect" } + ) do render(Saml::Providers::MetadataXmlForm.new(form, provider:)) end diff --git a/modules/auth_saml/app/controllers/saml/providers_controller.rb b/modules/auth_saml/app/controllers/saml/providers_controller.rb index 5206ed737f35..7189d988b026 100644 --- a/modules/auth_saml/app/controllers/saml/providers_controller.rb +++ b/modules/auth_saml/app/controllers/saml/providers_controller.rb @@ -22,7 +22,7 @@ def new end def import_metadata - if import_params.present? + if params[:saml_provider][:metadata] != "none" update_provider_metadata end diff --git a/modules/auth_saml/app/forms/saml/providers/metadata_checkbox_form.rb b/modules/auth_saml/app/forms/saml/providers/metadata_options_form.rb similarity index 52% rename from modules/auth_saml/app/forms/saml/providers/metadata_checkbox_form.rb rename to modules/auth_saml/app/forms/saml/providers/metadata_options_form.rb index 7dc4448092f6..c27bb36ef2bb 100644 --- a/modules/auth_saml/app/forms/saml/providers/metadata_checkbox_form.rb +++ b/modules/auth_saml/app/forms/saml/providers/metadata_options_form.rb @@ -28,18 +28,37 @@ module Saml module Providers - class MetadataCheckboxForm < BaseForm + class MetadataOptionsForm < BaseForm form do |f| - f.check_box( - name: :metadata_url, - checked: @provider.has_metadata?, - label: I18n.t("saml.settings.metadata_checkbox"), - required: false, - input_width: :medium, - data: { - "show-when-checked-target": "cause" - } - ) + f.radio_button_group( + name: "metadata", + scope_name_to_model: false, + label: I18n.t("saml.providers.label_metadata") + ) do |radio_group| + radio_group.radio_button( + value: "none", + checked: !@provider.has_metadata?, + label: I18n.t("saml.settings.metadata_none"), + caption: I18n.t("saml.instructions.metadata_none"), + data: { "show-when-value-selected-target": "cause" } + ) + + radio_group.radio_button( + value: "url", + checked: @provider.metadata_url.present?, + label: I18n.t("saml.settings.metadata_url"), + caption: I18n.t("saml.instructions.metadata_url"), + data: { "show-when-value-selected-target": "cause" } + ) + + radio_group.radio_button( + value: "xml", + checked: @provider.metadata_xml.present?, + label: I18n.t("saml.settings.metadata_xml"), + caption: I18n.t("saml.instructions.metadata_xml"), + data: { "show-when-value-selected-target": "cause" } + ) + end end end end diff --git a/modules/auth_saml/config/locales/en.yml b/modules/auth_saml/config/locales/en.yml index ed7b17260da8..4db7d5217588 100644 --- a/modules/auth_saml/config/locales/en.yml +++ b/modules/auth_saml/config/locales/en.yml @@ -81,7 +81,7 @@ en: mapping: "Manually adjust the mapping between the SAML response and user attributes in OpenProject." requested_attributes: "Define the set of attributes to be requested in the SAML request sent to your identity provider." settings: - metadata_checkbox: "I have metadata (optional)" + metadata_none: "I don't have metadata" metadata_url: "Metadata URL" metadata_xml: "Metadata XML" instructions: @@ -89,10 +89,12 @@ en: Please refer to our [documentation on configuring SAML providers](docs_url) for more information on these configuration options. display_name: > The name of the provider. This will be displayed as the login button and in the list of providers. + metadata_none: > + Your identity provider does not have a metadata endpoint or XML download option. You can the configuration manually. metadata_url: > - Enter the URL of the identity provider XML metadata endpoint. + Your identity provider provides a metadata URL. metadata_xml: > - Alternatively, enter the SAML metadata XML from your identity provider. + Your identity provider provides a metadata XML download. limit_self_registration: > If enabled users can only register using this provider if the self registration setting allows for it. sp_entity_id: > From c11484cffbd7e76d41f4e9aef0af71193040bf85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 7 Aug 2024 19:30:36 +0200 Subject: [PATCH 34/80] Larger form --- modules/auth_saml/app/forms/saml/providers/metadata_url_form.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/auth_saml/app/forms/saml/providers/metadata_url_form.rb b/modules/auth_saml/app/forms/saml/providers/metadata_url_form.rb index 9dbdbbc0beb9..4853f7f264d9 100644 --- a/modules/auth_saml/app/forms/saml/providers/metadata_url_form.rb +++ b/modules/auth_saml/app/forms/saml/providers/metadata_url_form.rb @@ -35,7 +35,7 @@ class MetadataUrlForm < BaseForm label: I18n.t("saml.settings.metadata_url"), required: false, caption: I18n.t("saml.instructions.metadata_url"), - input_width: :medium + input_width: :xlarge ) end end From 344ce2730cec54a0fbdd112d7421a04e3bb65ab2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 7 Aug 2024 19:45:14 +0200 Subject: [PATCH 35/80] Move auth provider to base table --- .../contracts/saml/providers/create_contract.rb | 1 + modules/auth_saml/app/models/saml/provider.rb | 16 ++-------------- .../migrate/20240801105918_add_saml_provider.rb | 13 ------------- 3 files changed, 3 insertions(+), 27 deletions(-) delete mode 100644 modules/auth_saml/db/migrate/20240801105918_add_saml_provider.rb diff --git a/modules/auth_saml/app/contracts/saml/providers/create_contract.rb b/modules/auth_saml/app/contracts/saml/providers/create_contract.rb index 555aeac3604f..2115bc482b3c 100644 --- a/modules/auth_saml/app/contracts/saml/providers/create_contract.rb +++ b/modules/auth_saml/app/contracts/saml/providers/create_contract.rb @@ -28,6 +28,7 @@ module Saml module Providers class CreateContract < BaseContract + attribute :type end end end diff --git a/modules/auth_saml/app/models/saml/provider.rb b/modules/auth_saml/app/models/saml/provider.rb index 1a59208a1a5f..ec2604d1b385 100644 --- a/modules/auth_saml/app/models/saml/provider.rb +++ b/modules/auth_saml/app/models/saml/provider.rb @@ -1,11 +1,7 @@ module Saml - class Provider < ApplicationRecord + class Provider < AuthProvider include HashBuilder - self.table_name = "saml_providers" - - belongs_to :creator, class_name: "User" - store_attribute :options, :sp_entity_id, :string store_attribute :options, :name_identifier_format, :string store_attribute :options, :metadata_url, :string @@ -47,17 +43,10 @@ class Provider < ApplicationRecord attr_accessor :readonly - validates_presence_of :display_name - validates_uniqueness_of :display_name - def slug options.fetch(:name) { "saml-#{id}" } end - def limit_self_registration? - limit_self_registration - end - def has_metadata? metadata_xml.present? || metadata_url.present? end @@ -109,8 +98,7 @@ def idp_cert=(cert) end def assertion_consumer_service_url - root_url = OpenProject::StaticRouting::StaticUrlHelpers.new.root_url - URI.join(root_url, "/auth/#{slug}/callback").to_s + callback_url end end end diff --git a/modules/auth_saml/db/migrate/20240801105918_add_saml_provider.rb b/modules/auth_saml/db/migrate/20240801105918_add_saml_provider.rb deleted file mode 100644 index 2eac33eae9f6..000000000000 --- a/modules/auth_saml/db/migrate/20240801105918_add_saml_provider.rb +++ /dev/null @@ -1,13 +0,0 @@ -class AddSamlProvider < ActiveRecord::Migration[7.1] - def change - create_table :saml_providers do |t| - t.string :display_name, null: false, index: { unique: true } - t.boolean :available, null: false, default: true - t.boolean :limit_self_registration, null: false, default: false - t.jsonb :options, default: {}, null: false - t.references :creator, null: false, index: true, foreign_key: { to_table: :users } - - t.timestamps - end - end -end From a4bcc206bce9840f71872ab4a1af23d26790c157 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 7 Aug 2024 19:50:07 +0200 Subject: [PATCH 36/80] Validate cert only if present --- .../contracts/saml/providers/base_contract.rb | 4 ++-- .../app/models/saml/provider/hash_builder.rb | 20 +++++++++++-------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/modules/auth_saml/app/contracts/saml/providers/base_contract.rb b/modules/auth_saml/app/contracts/saml/providers/base_contract.rb index 6ac62cb22713..2ddd4762aa73 100644 --- a/modules/auth_saml/app/contracts/saml/providers/base_contract.rb +++ b/modules/auth_saml/app/contracts/saml/providers/base_contract.rb @@ -55,7 +55,7 @@ def self.model validates_presence_of :idp_cert, if: -> { model.idp_cert_changed? } validate :idp_cert_is_valid, - if: -> { model.idp_cert_changed? } + if: -> { model.idp_cert_changed? && model.idp_cert.present? } attribute :authn_requests_signed validate :authn_requests_signed_requires_cert @@ -69,7 +69,7 @@ def idp_cert_is_valid model.loaded_idp_certificates.each do |cert| if OneLogin::RubySaml::Utils.is_cert_expired(cert) errors.add :certificate, :certificate_expired - return + break end end rescue OpenSSL::X509::CertificateError => e diff --git a/modules/auth_saml/app/models/saml/provider/hash_builder.rb b/modules/auth_saml/app/models/saml/provider/hash_builder.rb index c208b94e53f0..68954119e0bf 100644 --- a/modules/auth_saml/app/models/saml/provider/hash_builder.rb +++ b/modules/auth_saml/app/models/saml/provider/hash_builder.rb @@ -23,16 +23,20 @@ def idp_cert_options_hash return { idp_cert_fingerprint: } end - certificates = loaded_idp_certificates.map(&:to_pem) - if certificates.count > 1 - { - idp_cert_multi: { - signing: certificates, - encryption: certificates + if idp_cert.present? + certificates = loaded_idp_certificates.map(&:to_pem) + if certificates.count > 1 + { + idp_cert_multi: { + signing: certificates, + encryption: certificates + } } - } + else + { idp_cert: certificates.first } + end else - { idp_cert: certificates.first } + {} end end From d0fa5cd2434754701190246f4a4202240ceb0f57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 8 Aug 2024 09:29:31 +0200 Subject: [PATCH 37/80] Store slug --- modules/auth_saml/app/models/saml/provider.rb | 4 +--- .../app/services/saml/providers/set_attributes_service.rb | 5 +++++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/modules/auth_saml/app/models/saml/provider.rb b/modules/auth_saml/app/models/saml/provider.rb index ec2604d1b385..f8df2e39becf 100644 --- a/modules/auth_saml/app/models/saml/provider.rb +++ b/modules/auth_saml/app/models/saml/provider.rb @@ -43,9 +43,7 @@ class Provider < AuthProvider attr_accessor :readonly - def slug - options.fetch(:name) { "saml-#{id}" } - end + def self.slug_fragment = "saml" def has_metadata? metadata_xml.present? || metadata_url.present? diff --git a/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb b/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb index d4e12a009277..ca4246af7bf9 100644 --- a/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb +++ b/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb @@ -58,6 +58,7 @@ def update_options(options) def set_default_attributes(*) model.change_by_system do + set_slug set_default_creator set_default_mapping set_default_requested_attributes @@ -67,6 +68,10 @@ def set_default_attributes(*) end end + def set_slug + model.slug ||= "#{model.class.slug_fragment}-#{model.display_name.to_url}" + end + def set_default_digest model.signature_method ||= Saml::Defaults::SIGNATURE_METHODS["RSA SHA-1"] model.digest_method ||= Saml::Defaults::DIGEST_METHODS["SHA-1"] From ebab8e40f9c9fdf0f15ab0504a8ee5868e49d8c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 8 Aug 2024 11:15:39 +0200 Subject: [PATCH 38/80] custom icon --- .../app/forms/saml/providers/configuration_form.rb | 7 +++++++ modules/auth_saml/app/models/saml/provider.rb | 1 + modules/auth_saml/app/models/saml/provider/hash_builder.rb | 1 + .../app/services/saml/providers/set_attributes_service.rb | 2 +- modules/auth_saml/config/locales/en.yml | 3 +++ 5 files changed, 13 insertions(+), 1 deletion(-) diff --git a/modules/auth_saml/app/forms/saml/providers/configuration_form.rb b/modules/auth_saml/app/forms/saml/providers/configuration_form.rb index da39f5cb98b0..3c09a7763349 100644 --- a/modules/auth_saml/app/forms/saml/providers/configuration_form.rb +++ b/modules/auth_saml/app/forms/saml/providers/configuration_form.rb @@ -76,6 +76,13 @@ class ConfigurationForm < BaseForm required: false, input_width: :large ) + f.text_field( + name: :icon, + label: I18n.t("activemodel.attributes.saml/provider.icon"), + caption: I18n.t("saml.instructions.icon"), + required: false, + input_width: :large + ) end end end diff --git a/modules/auth_saml/app/models/saml/provider.rb b/modules/auth_saml/app/models/saml/provider.rb index f8df2e39becf..e3840842ce92 100644 --- a/modules/auth_saml/app/models/saml/provider.rb +++ b/modules/auth_saml/app/models/saml/provider.rb @@ -2,6 +2,7 @@ module Saml class Provider < AuthProvider include HashBuilder + store_attribute :options, :icon, :string store_attribute :options, :sp_entity_id, :string store_attribute :options, :name_identifier_format, :string store_attribute :options, :metadata_url, :string diff --git a/modules/auth_saml/app/models/saml/provider/hash_builder.rb b/modules/auth_saml/app/models/saml/provider/hash_builder.rb index 68954119e0bf..33fb17e7ef37 100644 --- a/modules/auth_saml/app/models/saml/provider/hash_builder.rb +++ b/modules/auth_saml/app/models/saml/provider/hash_builder.rb @@ -57,6 +57,7 @@ def to_h { name: slug, display_name:, + icon:, assertion_consumer_service_url:, sp_entity_id:, idp_sso_service_url:, diff --git a/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb b/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb index ca4246af7bf9..0da8fa8b962d 100644 --- a/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb +++ b/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb @@ -82,7 +82,7 @@ def set_name_identifier_format end def set_default_creator - model.creator = user + model.creator ||= user end def update_idp_cert(cert) diff --git a/modules/auth_saml/config/locales/en.yml b/modules/auth_saml/config/locales/en.yml index 4db7d5217588..55a8eaa71534 100644 --- a/modules/auth_saml/config/locales/en.yml +++ b/modules/auth_saml/config/locales/en.yml @@ -21,6 +21,7 @@ en: signature_method: Signature algorithm digest_method: Digest algorithm format: "Format" + icon: "Custom icon" activerecord: errors: models: @@ -147,3 +148,5 @@ en: Select the signature algorithm to use for the SAML request signature performed by OpenProject (Default: %{default_option}). digest_method: > Select the digest algorithm to use for the SAML request signature performed by OpenProject (Default: %{default_option}). + icon: > + Optionally provide a public URL to an icon graphic that will be displayed next to the provider name. From cecdd6ce2de113df8d683875fe6e1d65378c8415 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 8 Aug 2024 11:16:03 +0200 Subject: [PATCH 39/80] seed providers from env --- .../sections/show_component.html.erb | 2 +- .../saml/providers/sections/show_component.rb | 6 + .../saml/providers/view_component.html.erb | 8 ++ .../contracts/saml/providers/base_contract.rb | 1 + .../controllers/saml/providers_controller.rb | 8 ++ modules/auth_saml/app/models/saml/provider.rb | 5 + .../seeders/env_data/saml/provider_seeder.rb | 125 ++++++++++++++++++ .../saml/providers/set_attributes_service.rb | 10 ++ .../app/views/saml/providers/show.html.erb | 28 ++-- modules/auth_saml/config/locales/en.yml | 1 + .../lib/open_project/auth_saml/engine.rb | 12 +- 11 files changed, 188 insertions(+), 18 deletions(-) create mode 100644 modules/auth_saml/app/seeders/env_data/saml/provider_seeder.rb diff --git a/modules/auth_saml/app/components/saml/providers/sections/show_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/show_component.html.erb index 40ad76cc1e1a..e3d6adb05cc4 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/show_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/sections/show_component.html.erb @@ -13,7 +13,7 @@ end end - if provider.persisted? && @view_mode == :show + if show_edit? grid.with_area(:action) do flex_layout(justify_content: :flex_end) do |icons_container| icons_container.with_column do diff --git a/modules/auth_saml/app/components/saml/providers/sections/show_component.rb b/modules/auth_saml/app/components/saml/providers/sections/show_component.rb index a630a78d5355..434ebd198206 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/show_component.rb +++ b/modules/auth_saml/app/components/saml/providers/sections/show_component.rb @@ -41,5 +41,11 @@ def initialize(provider, view_mode:, target_state:, @label = label @label_scheme = label_scheme end + + def show_edit? + return false if provider.seeded_from_env? + + provider.persisted? && @view_mode == :show + end end end diff --git a/modules/auth_saml/app/components/saml/providers/view_component.html.erb b/modules/auth_saml/app/components/saml/providers/view_component.html.erb index 69078ba90f0f..0c2ac92d5019 100644 --- a/modules/auth_saml/app/components/saml/providers/view_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/view_component.html.erb @@ -1,3 +1,11 @@ +<% if provider.seeded_from_env? %> + <%= + render(Primer::Alpha::Banner.new(mb: 2, scheme: :default, icon: :bell, spacious: true)) do + I18n.t("saml.providers.seeded_from_env") + end + %> +<% end %> + <%= render(border_box_container) do |component| component.with_header(color: :muted) do diff --git a/modules/auth_saml/app/contracts/saml/providers/base_contract.rb b/modules/auth_saml/app/contracts/saml/providers/base_contract.rb index 2ddd4762aa73..f0169df9e8f1 100644 --- a/modules/auth_saml/app/contracts/saml/providers/base_contract.rb +++ b/modules/auth_saml/app/contracts/saml/providers/base_contract.rb @@ -35,6 +35,7 @@ def self.model end attribute :display_name + attribute :slug attribute :options attribute :metadata_url validates :metadata_url, diff --git a/modules/auth_saml/app/controllers/saml/providers_controller.rb b/modules/auth_saml/app/controllers/saml/providers_controller.rb index 7189d988b026..c3c9f6d87a47 100644 --- a/modules/auth_saml/app/controllers/saml/providers_controller.rb +++ b/modules/auth_saml/app/controllers/saml/providers_controller.rb @@ -6,6 +6,7 @@ class ProvidersController < ::ApplicationController before_action :require_admin before_action :check_ee before_action :find_provider, only: %i[show edit import_metadata update destroy] + before_action :check_provider_writable, only: %i[update import_metadata] def index @providers = Saml::Provider.order(display_name: :asc) @@ -163,5 +164,12 @@ def find_provider rescue ActiveRecord::RecordNotFound render_404 end + + def check_provider_writable + if @provider.seeded_from_env? + flash[:error] = I18n.t(:label_seeded_from_env_warning) + redirect_to saml_provider_path(@provider) + end + end end end diff --git a/modules/auth_saml/app/models/saml/provider.rb b/modules/auth_saml/app/models/saml/provider.rb index e3840842ce92..e2544a5028ef 100644 --- a/modules/auth_saml/app/models/saml/provider.rb +++ b/modules/auth_saml/app/models/saml/provider.rb @@ -4,6 +4,7 @@ class Provider < AuthProvider store_attribute :options, :icon, :string store_attribute :options, :sp_entity_id, :string + store_attribute :options, :seeded_from_env, :boolean store_attribute :options, :name_identifier_format, :string store_attribute :options, :metadata_url, :string store_attribute :options, :metadata_xml, :string @@ -46,6 +47,10 @@ class Provider < AuthProvider def self.slug_fragment = "saml" + def seeded_from_env? + seeded_from_env == true + end + def has_metadata? metadata_xml.present? || metadata_url.present? end diff --git a/modules/auth_saml/app/seeders/env_data/saml/provider_seeder.rb b/modules/auth_saml/app/seeders/env_data/saml/provider_seeder.rb new file mode 100644 index 000000000000..e64dbbef1109 --- /dev/null +++ b/modules/auth_saml/app/seeders/env_data/saml/provider_seeder.rb @@ -0,0 +1,125 @@ +#-- 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 EnvData + module Saml + class ProviderSeeder < Seeder + def seed_data! + Setting.seed_saml_provider.each do |name, options| + print_status " ↳ Creating or Updating SAML provider #{name}" do + provider = ::Saml::Provider.find_by(slug: "saml-env-#{name}") + options = mapped_options(options) + params = { + slug: name, + display_name: options.delete("display_name") || "SAML", + available: true, + options: + } + + if provider + print_status " - Updating existing SAML provider '#{name}' from ENV" + update(name, provider, params) + else + print_status " - Creating new SAML provider '#{name}' from ENV" + create(name, params) + end + end + end + end + + def applicable? + Setting.seed_saml_provider.present? + end + + private + + def create(name, params) + ::Saml::Providers::CreateService + .new(user: User.system) + .call(params) + .on_success { print_status " - Successfully saved SAML provider #{name}." } + .on_failure { |call| raise "Failed to create SAML provider: #{call.message}" } + end + + def update(name, provider, params) + ::Saml::Providers::UpdateService + .new(model: provider, user: User.system) + .call(params) + .on_success { print_status " - Successfully updated SAML provider #{name}." } + .on_failure { |call| raise "Failed to update SAML provider: #{call.message}" } + end + + def mapped_options(options) + options["seeded_from_env"] = true + options["idp_sso_service_url"] ||= options.delete("idp_sso_target_url") + options["idp_slo_service_url"] ||= options.delete("idp_slo_target_url") + options["sp_entity_id"] ||= options.delete("issuer") + + build_idp_cert(options) + extract_security_options(options) + extract_mapping(options) + + options.compact + end + + def extract_mapping(options) + nil unless options["attribute_statements"] + + options["mapping_login"] = extract_mapping_attribute(options, "login") + options["mapping_mail"] = extract_mapping_attribute(options, "email") + options["mapping_firstname"] = extract_mapping_attribute(options, "first_name") + options["mapping_lastname"] = extract_mapping_attribute(options, "last_name") + options["mapping_uid"] = extract_mapping_attribute(options, "uid") + end + + def extract_mapping_attribute(options, key) + value = options["attribute_statements"][key] + + if value.present? + Array(value).join("\n") + end + end + + def build_idp_cert(options) + if options["idp_cert"] + options["idp_cert"] = OneLogin::RubySaml::Utils.format_cert(options["idp_cert"]) + elsif options["idp_cert_multi"] + options["idp_cert"] = options["idp_cert_multi"]["signing"] + .map { |cert| OneLogin::RubySaml::Utils.format_cert(cert) } + .join("\n") + end + end + + def extract_security_options(options) + return unless options["security"] + + options.merge! options["security"].slice("authn_requests_signed", "want_assertions_signed", + "want_assertions_encrypted", "digest_method", "signature_method") + end + end + end +end diff --git a/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb b/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb index 0da8fa8b962d..e01adf1291d7 100644 --- a/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb +++ b/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb @@ -48,6 +48,8 @@ def update_available_state def update_options(options) update_idp_cert(options.delete(:idp_cert)) if options.key?(:idp_cert) + update_certificate(options.delete(:certificate)) if options.key?(:certificate) + update_private_key(options.delete(:private_key)) if options.key?(:private_key) options .select { |key, _| Saml::Provider.stored_attributes[:options].include?(key.to_s) } @@ -94,6 +96,14 @@ def update_idp_cert(cert) end end + def update_certificate(cert) + model.certificate = OneLogin::RubySaml::Utils.format_cert(cert) + end + + def update_private_key(private_key) + model.private_key = OneLogin::RubySaml::Utils.format_private_key(private_key) + end + ## # Clean up provided mapping, reducing whitespace def update_mapping(params) diff --git a/modules/auth_saml/app/views/saml/providers/show.html.erb b/modules/auth_saml/app/views/saml/providers/show.html.erb index 42d188b0f6b0..64d09eaad670 100644 --- a/modules/auth_saml/app/views/saml/providers/show.html.erb +++ b/modules/auth_saml/app/views/saml/providers/show.html.erb @@ -3,23 +3,25 @@ <%= render(Primer::OpenProject::PageHeader.new) do |header| %> <% header.with_title { @provider.display_name } %> - <% header.with_breadcrumbs([{ href: admin_index_path, text: t("label_administration")}, + <% header.with_breadcrumbs([{ href: admin_index_path, text: t("label_administration") }, { href: saml_providers_path, text: t('saml.providers.plural') }, @provider.display_name]) %> <% - header.with_action_button(tag: :a, - scheme: :danger, - mobile_icon: :trash, - mobile_label: t(:button_delete), - size: :medium, - href: saml_provider_path(@provider), - aria: { label: I18n.t(:button_delete) }, - data: { - confirm: t(:text_are_you_sure), - method: :delete, - }, - title: I18n.t(:button_delete)) do |button| + header.with_action_button( + tag: :a, + scheme: :danger, + mobile_icon: :trash, + mobile_label: t(:button_delete), + size: :medium, + href: saml_provider_path(@provider), + aria: { label: I18n.t(:button_delete) }, + data: { + confirm: t(:text_are_you_sure), + method: :delete, + }, + title: I18n.t(:button_delete) + ) do |button| button.with_leading_visual_icon(icon: :trash) t(:button_delete) end diff --git a/modules/auth_saml/config/locales/en.yml b/modules/auth_saml/config/locales/en.yml index 55a8eaa71534..6665fed787f2 100644 --- a/modules/auth_saml/config/locales/en.yml +++ b/modules/auth_saml/config/locales/en.yml @@ -81,6 +81,7 @@ en: encryption_form: "You may optionally want to encrypt the assertion response, or have requests from OpenProject signed." mapping: "Manually adjust the mapping between the SAML response and user attributes in OpenProject." requested_attributes: "Define the set of attributes to be requested in the SAML request sent to your identity provider." + seeded_from_env: "This provider was seeded from the environment configuration. It cannot be edited." settings: metadata_none: "I don't have metadata" metadata_url: "Metadata URL" diff --git a/modules/auth_saml/lib/open_project/auth_saml/engine.rb b/modules/auth_saml/lib/open_project/auth_saml/engine.rb index 92e445ce8649..766279aea31a 100644 --- a/modules/auth_saml/lib/open_project/auth_saml/engine.rb +++ b/modules/auth_saml/lib/open_project/auth_saml/engine.rb @@ -21,7 +21,9 @@ def self.global_configuration end def self.settings_from_providers - Saml::Provider.all.each_with_object({}) do |provider, hash| + Saml::Provider + .where(available: true) + .each_with_object({}) do |provider, hash| hash[provider.slug] = provider.to_h end end @@ -96,10 +98,12 @@ class Engine < ::Rails::Engine end initializer "auth_saml.configuration" do - ::Settings::Definition.add "saml", + ::Settings::Definition.add :seed_saml_provider, + description: "Provide a SAML provider and sync its settings through ENV", + env_alias: "OPENPROJECT_SAML", + writable: false, default: nil, - format: :hash, - writable: false + format: :hash end end end From e7b7b1b764330d70d5cf4d0475abd022cdfe9efe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 8 Aug 2024 13:14:05 +0200 Subject: [PATCH 40/80] Fix mapping to array --- .../app/models/saml/provider/hash_builder.rb | 15 +++++++++++---- .../app/seeders/env_data/saml/provider_seeder.rb | 2 +- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/modules/auth_saml/app/models/saml/provider/hash_builder.rb b/modules/auth_saml/app/models/saml/provider/hash_builder.rb index 33fb17e7ef37..522183b29b83 100644 --- a/modules/auth_saml/app/models/saml/provider/hash_builder.rb +++ b/modules/auth_saml/app/models/saml/provider/hash_builder.rb @@ -2,13 +2,20 @@ module Saml module Provider::HashBuilder def formatted_attribute_statements { - email: mapping_mail&.split("[\r\n]+"), - login: mapping_login&.split("[\r\n]+"), - first_name: mapping_firstname&.split("[\r\n]+"), - last_name: mapping_lastname&.split("[\r\n]+") + email: split_attribute_mapping(mapping_mail), + login: split_attribute_mapping(mapping_login), + first_name: split_attribute_mapping(mapping_firstname), + last_name: split_attribute_mapping(mapping_lastname), + uid: split_attribute_mapping(mapping_uid) }.compact end + def split_attribute_mapping(mapping) + return if mapping.blank? + + mapping.split(/\s*\R+\s*/) + end + def formatted_request_attributes [ { name: requested_login_attribute, format: requested_login_format, friendly_name: "Login" }, diff --git a/modules/auth_saml/app/seeders/env_data/saml/provider_seeder.rb b/modules/auth_saml/app/seeders/env_data/saml/provider_seeder.rb index e64dbbef1109..08e0a1167406 100644 --- a/modules/auth_saml/app/seeders/env_data/saml/provider_seeder.rb +++ b/modules/auth_saml/app/seeders/env_data/saml/provider_seeder.rb @@ -31,7 +31,7 @@ class ProviderSeeder < Seeder def seed_data! Setting.seed_saml_provider.each do |name, options| print_status " ↳ Creating or Updating SAML provider #{name}" do - provider = ::Saml::Provider.find_by(slug: "saml-env-#{name}") + provider = ::Saml::Provider.find_by(slug: name) options = mapped_options(options) params = { slug: name, From 5136cc4a3b7eefcbcc05a31ac998847d296f8500 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 8 Aug 2024 15:58:07 +0200 Subject: [PATCH 41/80] Allow turbo_stream with scrolling --- .../op_turbo/stream_component.html.erb | 5 +- app/components/op_turbo/stream_component.rb | 3 +- .../concerns/op_turbo/component_stream.rb | 7 + .../sections/form_component.html.erb | 1 + .../sections/metadata_form_component.html.erb | 1 + .../sections/show_component.html.erb | 1 + .../saml/providers/view_component.html.erb | 247 +++++++++--------- .../saml/providers/view_component.rb | 1 + .../controllers/saml/providers_controller.rb | 14 + 9 files changed, 153 insertions(+), 127 deletions(-) diff --git a/app/components/op_turbo/stream_component.html.erb b/app/components/op_turbo/stream_component.html.erb index bac890e181e1..8a0a9e20775e 100644 --- a/app/components/op_turbo/stream_component.html.erb +++ b/app/components/op_turbo/stream_component.html.erb @@ -26,11 +26,10 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. See COPYRIGHT and LICENSE files for more details. ++#%> - +<%= content_tag("turbo-stream", action: @action, target: @target, **@turbo_stream_args) do %> <% if @template %> <% end %> - - +<% end %> diff --git a/app/components/op_turbo/stream_component.rb b/app/components/op_turbo/stream_component.rb index 2fb2ecef9826..43fa04eaab70 100644 --- a/app/components/op_turbo/stream_component.rb +++ b/app/components/op_turbo/stream_component.rb @@ -28,9 +28,10 @@ module OpTurbo class StreamComponent < ApplicationComponent - def initialize(template:, action:, target:) + def initialize(action:, target:, template: nil, **turbo_stream_args) super() + @turbo_stream_args = turbo_stream_args @template = template @action = action @target = target diff --git a/app/controllers/concerns/op_turbo/component_stream.rb b/app/controllers/concerns/op_turbo/component_stream.rb index 125a26975e87..6d3b3fa27162 100644 --- a/app/controllers/concerns/op_turbo/component_stream.rb +++ b/app/controllers/concerns/op_turbo/component_stream.rb @@ -39,6 +39,7 @@ def respond_to_with_turbo_streams(status: turbo_status, &format_block) yield(format) if format_block end end + alias_method :respond_with_turbo_streams, :respond_to_with_turbo_streams def update_via_turbo_stream(component:, status: :ok) @@ -82,6 +83,12 @@ def update_flash_message_via_turbo_stream(message:, component: OpPrimer::FlashCo turbo_streams << instance.render_as_turbo_stream(view_context:, action: :flash) end + def scroll_into_view_via_turbo_stream(target, behavior: :auto, block: :start) + turbo_streams << OpTurbo::StreamComponent + .new(action: :scroll_into_view, target:, behavior:, block:) + .render_in(view_context) + end + def turbo_streams @turbo_streams ||= [] end diff --git a/modules/auth_saml/app/components/saml/providers/sections/form_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/form_component.html.erb index 5a7c0dce3f01..64490434c0fe 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/form_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/sections/form_component.html.erb @@ -1,5 +1,6 @@ <%= primer_form_with( + id: "saml-providers-edit-form", model: provider, url:, method: form_method, diff --git a/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.html.erb index 86a2ed284709..26ee6189d80c 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.html.erb @@ -1,6 +1,7 @@ <%= primer_form_with( model: provider, + id: "saml-providers-edit-form", url: import_metadata_saml_provider_path(provider), data: { controller: "show-when-value-selected" diff --git a/modules/auth_saml/app/components/saml/providers/sections/show_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/show_component.html.erb index e3d6adb05cc4..0b381375eb95 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/show_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/sections/show_component.html.erb @@ -23,6 +23,7 @@ tag: :a, scheme: :invisible, href: edit_saml_provider_path(provider, edit_state: @target_state), + data: { turbo: true, turbo_stream: true }, aria: { label: I18n.t(:label_edit) } ) ) diff --git a/modules/auth_saml/app/components/saml/providers/view_component.html.erb b/modules/auth_saml/app/components/saml/providers/view_component.html.erb index 0c2ac92d5019..7a5f055091bd 100644 --- a/modules/auth_saml/app/components/saml/providers/view_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/view_component.html.erb @@ -1,140 +1,141 @@ -<% if provider.seeded_from_env? %> - <%= - render(Primer::Alpha::Banner.new(mb: 2, scheme: :default, icon: :bell, spacious: true)) do - I18n.t("saml.providers.seeded_from_env") - end - %> -<% end %> +<%= component_wrapper do %> + <% if provider.seeded_from_env? %> + <%= + render(Primer::Alpha::Banner.new(mb: 2, scheme: :default, icon: :bell, spacious: true)) do + I18n.t("saml.providers.seeded_from_env") + end + %> + <% end %> -<%= - render(border_box_container) do |component| - component.with_header(color: :muted) do - render(Primer::Beta::Text.new(font_weight: :semibold)) { I18n.t("activemodel.attributes.saml/provider.display_name") } - end +<%= render(border_box_container) do |component| + component.with_header(color: :muted) do + render(Primer::Beta::Text.new(font_weight: :semibold)) { I18n.t("activemodel.attributes.saml/provider.display_name") } + end - component.with_row(scheme: :default) do - if edit_state == :name - render(Saml::Providers::Sections::FormComponent.new( - provider, - form_class: Saml::Providers::NameInputForm, - edit_state:, - heading: nil - )) - else - render(Saml::Providers::Sections::ShowComponent.new( - provider, - view_mode:, - target_state: :name, - heading: t("saml.providers.singular"), - description: t("saml.providers.section_texts.display_name") - )) - end + component.with_row(scheme: :default) do + if edit_state == :name + render(Saml::Providers::Sections::FormComponent.new( + provider, + form_class: Saml::Providers::NameInputForm, + edit_state:, + heading: nil + )) + else + render(Saml::Providers::Sections::ShowComponent.new( + provider, + view_mode:, + target_state: :name, + heading: t("saml.providers.singular"), + description: t("saml.providers.section_texts.display_name") + )) end + end - component.with_row(scheme: :neutral, color: :muted) do - render(Primer::Beta::Text.new(font_weight: :semibold)) { I18n.t('saml.providers.label_automatic_configuration') } - end + component.with_row(scheme: :neutral, color: :muted) do + render(Primer::Beta::Text.new(font_weight: :semibold)) { I18n.t('saml.providers.label_automatic_configuration') } + end - component.with_row(scheme: :default) do - if edit_state == :metadata - render(Saml::Providers::Sections::MetadataFormComponent.new(provider)) - else - render(Saml::Providers::Sections::ShowComponent.new( - provider, - target_state: :metadata, - view_mode:, - heading: t("saml.providers.label_metadata"), - description: t("saml.providers.section_texts.metadata"), - label: provider.has_metadata? ? t(:label_completed) : t(:label_not_configured), - label_scheme: provider.has_metadata? ? :success : :secondary - )) - end + component.with_row(scheme: :default) do + if edit_state == :metadata + render(Saml::Providers::Sections::MetadataFormComponent.new(provider)) + else + render(Saml::Providers::Sections::ShowComponent.new( + provider, + target_state: :metadata, + view_mode:, + heading: t("saml.providers.label_metadata"), + description: t("saml.providers.section_texts.metadata"), + label: provider.has_metadata? ? t(:label_completed) : t(:label_not_configured), + label_scheme: provider.has_metadata? ? :success : :secondary + )) end + end - component.with_row(scheme: :neutral, color: :muted) do - render(Primer::Beta::Text.new(font_weight: :semibold)) { I18n.t('saml.providers.section_headers.configuration') } - end + component.with_row(scheme: :neutral, color: :muted) do + render(Primer::Beta::Text.new(font_weight: :semibold)) { I18n.t('saml.providers.section_headers.configuration') } + end - component.with_row(scheme: :default) do - if edit_state == :configuration - render(Saml::Providers::Sections::FormComponent.new( - provider, - form_class: Saml::Providers::ConfigurationForm, - edit_state:, - heading: t("saml.providers.section_texts.configuration_form") - )) - else - render(Saml::Providers::Sections::ShowComponent.new( - provider, - target_state: :configuration, - view_mode:, - heading: t("saml.providers.label_configuration_details"), - description: t("saml.providers.section_texts.configuration"), - label: (provider.persisted? && !provider.configured?) ? t(:label_incomplete) : nil, - )) - end + component.with_row(scheme: :default) do + if edit_state == :configuration + render(Saml::Providers::Sections::FormComponent.new( + provider, + form_class: Saml::Providers::ConfigurationForm, + edit_state:, + heading: t("saml.providers.section_texts.configuration_form") + )) + else + render(Saml::Providers::Sections::ShowComponent.new( + provider, + target_state: :configuration, + view_mode:, + heading: t("saml.providers.label_configuration_details"), + description: t("saml.providers.section_texts.configuration"), + label: (provider.persisted? && !provider.configured?) ? t(:label_incomplete) : nil, + )) end + end - component.with_row(scheme: :default) do - if edit_state == :encryption - render(Saml::Providers::Sections::FormComponent.new( - provider, - form_class: Saml::Providers::EncryptionForm, - edit_state:, - heading: t("saml.providers.section_texts.encryption_form") - )) - else - render(Saml::Providers::Sections::ShowComponent.new( - provider, - target_state: :encryption, - view_mode:, - heading: t("saml.providers.label_configuration_encryption"), - description: t("saml.providers.section_texts.encryption") - )) - end + component.with_row(scheme: :default) do + if edit_state == :encryption + render(Saml::Providers::Sections::FormComponent.new( + provider, + form_class: Saml::Providers::EncryptionForm, + edit_state:, + heading: t("saml.providers.section_texts.encryption_form") + )) + else + render(Saml::Providers::Sections::ShowComponent.new( + provider, + target_state: :encryption, + view_mode:, + heading: t("saml.providers.label_configuration_encryption"), + description: t("saml.providers.section_texts.encryption") + )) end + end - component.with_row(scheme: :neutral, color: :muted) do - render(Primer::Beta::Text.new(font_weight: :semibold)) { I18n.t('saml.providers.section_headers.attributes') } - end + component.with_row(scheme: :neutral, color: :muted) do + render(Primer::Beta::Text.new(font_weight: :semibold)) { I18n.t('saml.providers.section_headers.attributes') } + end - component.with_row(scheme: :default) do - if edit_state == :mapping - render(Saml::Providers::Sections::FormComponent.new( - provider, - form_class: Saml::Providers::MappingForm, - edit_state:, - heading: t("saml.instructions.mapping") - )) - else - render(Saml::Providers::Sections::ShowComponent.new( - provider, - target_state: :mapping, - view_mode:, - heading: t("saml.providers.label_mapping"), - description: t("saml.providers.section_texts.mapping"), - label: provider.mapping_configured? ? t(:label_completed) : t(:label_incomplete), - label_scheme: provider.mapping_configured? ? :success : :attention - )) - end + component.with_row(scheme: :default) do + if edit_state == :mapping + render(Saml::Providers::Sections::FormComponent.new( + provider, + form_class: Saml::Providers::MappingForm, + edit_state:, + heading: t("saml.instructions.mapping") + )) + else + render(Saml::Providers::Sections::ShowComponent.new( + provider, + target_state: :mapping, + view_mode:, + heading: t("saml.providers.label_mapping"), + description: t("saml.providers.section_texts.mapping"), + label: provider.mapping_configured? ? t(:label_completed) : t(:label_incomplete), + label_scheme: provider.mapping_configured? ? :success : :attention + )) end - component.with_row(scheme: :default) do - if edit_state == :requested_attributes - render(Saml::Providers::Sections::FormComponent.new( - provider, - form_class: Saml::Providers::RequestAttributesForm, - edit_state:, - heading: t("saml.instructions.requested_attributes") - )) - else - render(Saml::Providers::Sections::ShowComponent.new( - provider, - target_state: :requested_attributes, - view_mode:, - heading: t("saml.providers.requested_attributes"), - description: t("saml.providers.section_texts.requested_attributes") - )) - end + end + component.with_row(scheme: :default) do + if edit_state == :requested_attributes + render(Saml::Providers::Sections::FormComponent.new( + provider, + form_class: Saml::Providers::RequestAttributesForm, + edit_state:, + heading: t("saml.instructions.requested_attributes") + )) + else + render(Saml::Providers::Sections::ShowComponent.new( + provider, + target_state: :requested_attributes, + view_mode:, + heading: t("saml.providers.requested_attributes"), + description: t("saml.providers.section_texts.requested_attributes") + )) end end +end %> +<% end %> diff --git a/modules/auth_saml/app/components/saml/providers/view_component.rb b/modules/auth_saml/app/components/saml/providers/view_component.rb index 75ddf562dfd9..75cc6121ee8f 100644 --- a/modules/auth_saml/app/components/saml/providers/view_component.rb +++ b/modules/auth_saml/app/components/saml/providers/view_component.rb @@ -30,6 +30,7 @@ # module Saml::Providers class ViewComponent < ApplicationComponent + include OpTurbo::Streamable include OpPrimer::ComponentHelpers options :view_mode, :edit_state diff --git a/modules/auth_saml/app/controllers/saml/providers_controller.rb b/modules/auth_saml/app/controllers/saml/providers_controller.rb index c3c9f6d87a47..ecb05e69c1c8 100644 --- a/modules/auth_saml/app/controllers/saml/providers_controller.rb +++ b/modules/auth_saml/app/controllers/saml/providers_controller.rb @@ -1,5 +1,7 @@ module Saml class ProvidersController < ::ApplicationController + include OpTurbo::ComponentStream + layout "admin" menu_item :plugin_saml @@ -14,6 +16,18 @@ def index def edit @edit_state = params[:edit_state].to_sym if params.key?(:edit_state) + + respond_to do |format| + format.turbo_stream do + component = Saml::Providers::ViewComponent.new(@provider, + view_mode: :edit, + edit_state: @edit_state) + update_via_turbo_stream(component:) + scroll_into_view_via_turbo_stream("saml-providers-edit-form", behavior: :instant) + render turbo_stream: turbo_streams, status: + end + format.html + end end def show; end From 666f922e5c77cee5fc7f64eb2bf806bc98e45bf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 8 Aug 2024 16:21:23 +0200 Subject: [PATCH 42/80] Cancel with turbo stream --- .../app/controllers/saml/providers_controller.rb | 14 ++++++++++++-- .../forms/saml/providers/submit_or_cancel_form.rb | 1 + 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/modules/auth_saml/app/controllers/saml/providers_controller.rb b/modules/auth_saml/app/controllers/saml/providers_controller.rb index ecb05e69c1c8..e9c225f09f56 100644 --- a/modules/auth_saml/app/controllers/saml/providers_controller.rb +++ b/modules/auth_saml/app/controllers/saml/providers_controller.rb @@ -24,13 +24,23 @@ def edit edit_state: @edit_state) update_via_turbo_stream(component:) scroll_into_view_via_turbo_stream("saml-providers-edit-form", behavior: :instant) - render turbo_stream: turbo_streams, status: + render turbo_stream: turbo_streams end format.html end end - def show; end + def show + respond_to do |format| + format.turbo_stream do + component = Saml::Providers::ViewComponent.new(@provider, + view_mode: :show) + update_via_turbo_stream(component:) + render turbo_stream: turbo_streams + end + format.html + end + end def new @provider = ::Saml::Provider.new diff --git a/modules/auth_saml/app/forms/saml/providers/submit_or_cancel_form.rb b/modules/auth_saml/app/forms/saml/providers/submit_or_cancel_form.rb index 0dc350c91932..d09139de7777 100644 --- a/modules/auth_saml/app/forms/saml/providers/submit_or_cancel_form.rb +++ b/modules/auth_saml/app/forms/saml/providers/submit_or_cancel_form.rb @@ -69,6 +69,7 @@ def default_cancel_button_options scheme: :default, tag: :a, href: back_link, + data: { turbo: true, turbo_stream: !@provider.new_record? }, label: I18n.t("button_cancel") } end From b3cd44c4b94c57a99799978e5b91adc6abedfe12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 8 Aug 2024 20:03:24 +0200 Subject: [PATCH 43/80] Allow disabling when seeded --- .../saml/providers/sections/show_component.html.erb | 5 +++-- .../components/saml/providers/sections/show_component.rb | 2 -- modules/auth_saml/app/forms/saml/providers/base_form.rb | 2 ++ .../app/forms/saml/providers/configuration_form.rb | 7 +++++++ .../auth_saml/app/forms/saml/providers/encryption_form.rb | 7 +++++++ modules/auth_saml/app/forms/saml/providers/mapping_form.rb | 5 +++++ .../app/forms/saml/providers/metadata_options_form.rb | 4 ++++ .../app/forms/saml/providers/metadata_url_form.rb | 1 + .../app/forms/saml/providers/metadata_xml_form.rb | 1 + .../auth_saml/app/forms/saml/providers/name_input_form.rb | 1 + .../app/forms/saml/providers/request_attributes_form.rb | 2 ++ .../app/forms/saml/providers/submit_or_cancel_form.rb | 2 +- modules/costs/config/locales/en.yml | 1 + 13 files changed, 35 insertions(+), 5 deletions(-) diff --git a/modules/auth_saml/app/components/saml/providers/sections/show_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/show_component.html.erb index 0b381375eb95..a7f95321cb5d 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/show_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/sections/show_component.html.erb @@ -13,18 +13,19 @@ end end + disabled = provider.seeded_from_env? if show_edit? grid.with_area(:action) do flex_layout(justify_content: :flex_end) do |icons_container| icons_container.with_column do render( Primer::Beta::IconButton.new( - icon: :pencil, + icon: disabled ? :eye : :pencil, tag: :a, scheme: :invisible, href: edit_saml_provider_path(provider, edit_state: @target_state), data: { turbo: true, turbo_stream: true }, - aria: { label: I18n.t(:label_edit) } + aria: { label: I18n.t(disabled ? :label_show : :label_edit) } ) ) end diff --git a/modules/auth_saml/app/components/saml/providers/sections/show_component.rb b/modules/auth_saml/app/components/saml/providers/sections/show_component.rb index 434ebd198206..267a23282c8a 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/show_component.rb +++ b/modules/auth_saml/app/components/saml/providers/sections/show_component.rb @@ -43,8 +43,6 @@ def initialize(provider, view_mode:, target_state:, end def show_edit? - return false if provider.seeded_from_env? - provider.persisted? && @view_mode == :show end end diff --git a/modules/auth_saml/app/forms/saml/providers/base_form.rb b/modules/auth_saml/app/forms/saml/providers/base_form.rb index bea23db3abee..bcc7f7d52264 100644 --- a/modules/auth_saml/app/forms/saml/providers/base_form.rb +++ b/modules/auth_saml/app/forms/saml/providers/base_form.rb @@ -29,6 +29,8 @@ module Saml module Providers class BaseForm < ApplicationForm + attr_reader :provider + def initialize(provider:) super() @provider = provider diff --git a/modules/auth_saml/app/forms/saml/providers/configuration_form.rb b/modules/auth_saml/app/forms/saml/providers/configuration_form.rb index 3c09a7763349..c917d3ec1c7b 100644 --- a/modules/auth_saml/app/forms/saml/providers/configuration_form.rb +++ b/modules/auth_saml/app/forms/saml/providers/configuration_form.rb @@ -34,6 +34,7 @@ class ConfigurationForm < BaseForm name: :sp_entity_id, label: I18n.t("activemodel.attributes.saml/provider.sp_entity_id"), caption: I18n.t("saml.instructions.sp_entity_id"), + disabled: provider.seeded_from_env?, required: true, input_width: :large ) @@ -41,6 +42,7 @@ class ConfigurationForm < BaseForm name: :idp_sso_service_url, label: I18n.t("activemodel.attributes.saml/provider.idp_sso_service_url"), caption: I18n.t("saml.instructions.idp_sso_service_url"), + disabled: provider.seeded_from_env?, required: true, input_width: :large ) @@ -48,6 +50,7 @@ class ConfigurationForm < BaseForm name: :idp_slo_service_url, label: I18n.t("activemodel.attributes.saml/provider.idp_slo_service_url"), caption: I18n.t("saml.instructions.idp_slo_service_url"), + disabled: provider.seeded_from_env?, required: false, input_width: :large ) @@ -56,6 +59,7 @@ class ConfigurationForm < BaseForm rows: 10, label: I18n.t("activemodel.attributes.saml/provider.idp_cert"), caption: I18n.t("saml.instructions.idp_cert"), + disabled: provider.seeded_from_env?, required: true, input_width: :large ) @@ -63,6 +67,7 @@ class ConfigurationForm < BaseForm name: "name_identifier_format", label: I18n.t("activemodel.attributes.saml/provider.name_identifier_format"), input_width: :large, + disabled: provider.seeded_from_env?, caption: I18n.t("saml.instructions.name_identifier_format") ) do |list| Saml::Defaults::NAME_IDENTIFIER_FORMATS.each do |format| @@ -73,6 +78,7 @@ class ConfigurationForm < BaseForm name: :limit_self_registration, label: I18n.t("activemodel.attributes.saml/provider.limit_self_registration"), caption: I18n.t("saml.instructions.limit_self_registration"), + disabled: provider.seeded_from_env?, required: false, input_width: :large ) @@ -80,6 +86,7 @@ class ConfigurationForm < BaseForm name: :icon, label: I18n.t("activemodel.attributes.saml/provider.icon"), caption: I18n.t("saml.instructions.icon"), + disabled: provider.seeded_from_env?, required: false, input_width: :large ) diff --git a/modules/auth_saml/app/forms/saml/providers/encryption_form.rb b/modules/auth_saml/app/forms/saml/providers/encryption_form.rb index d86698612c17..6ca178317713 100644 --- a/modules/auth_saml/app/forms/saml/providers/encryption_form.rb +++ b/modules/auth_saml/app/forms/saml/providers/encryption_form.rb @@ -34,18 +34,21 @@ class EncryptionForm < BaseForm name: :authn_requests_signed, label: I18n.t("activemodel.attributes.saml/provider.authn_requests_signed"), caption: I18n.t("saml.instructions.authn_requests_signed"), + disabled: provider.seeded_from_env?, required: true ) f.check_box( name: :want_assertions_signed, label: I18n.t("activemodel.attributes.saml/provider.want_assertions_signed"), caption: I18n.t("saml.instructions.want_assertions_signed"), + disabled: provider.seeded_from_env?, required: true ) f.check_box( name: :want_assertions_encrypted, label: I18n.t("activemodel.attributes.saml/provider.want_assertions_encrypted"), caption: I18n.t("saml.instructions.want_assertions_encrypted"), + disabled: provider.seeded_from_env?, required: true ) f.text_area( @@ -54,6 +57,7 @@ class EncryptionForm < BaseForm label: I18n.t("activemodel.attributes.saml/provider.certificate"), caption: I18n.t("saml.instructions.certificate"), required: false, + disabled: provider.seeded_from_env?, input_width: :large ) f.text_area( @@ -62,12 +66,14 @@ class EncryptionForm < BaseForm label: I18n.t("activemodel.attributes.saml/provider.private_key"), caption: I18n.t("saml.instructions.private_key"), required: false, + disabled: provider.seeded_from_env?, input_width: :large ) f.select_list( name: :digest_method, label: I18n.t("activemodel.attributes.saml/provider.digest_method"), input_width: :large, + disabled: provider.seeded_from_env?, caption: I18n.t("saml.instructions.digest_method", default_option: "SHA-1") ) do |list| Saml::Defaults::DIGEST_METHODS.each do |label, value| @@ -78,6 +84,7 @@ class EncryptionForm < BaseForm name: :signature_method, label: I18n.t("activemodel.attributes.saml/provider.signature_method"), input_width: :large, + disabled: provider.seeded_from_env?, caption: I18n.t("saml.instructions.signature_method", default_option: "RSA SHA-1") ) do |list| Saml::Defaults::SIGNATURE_METHODS.each do |label, value| diff --git a/modules/auth_saml/app/forms/saml/providers/mapping_form.rb b/modules/auth_saml/app/forms/saml/providers/mapping_form.rb index 999a6a164bf4..fa1be96a4795 100644 --- a/modules/auth_saml/app/forms/saml/providers/mapping_form.rb +++ b/modules/auth_saml/app/forms/saml/providers/mapping_form.rb @@ -35,6 +35,7 @@ class MappingForm < BaseForm label: I18n.t("saml.providers.label_mapping_for", attribute: User.human_attribute_name(:login)), caption: I18n.t("saml.instructions.mapping_login"), required: true, + disabled: provider.seeded_from_env?, rows: 8, input_width: :large ) @@ -43,6 +44,7 @@ class MappingForm < BaseForm label: I18n.t("saml.providers.label_mapping_for", attribute: User.human_attribute_name(:mail)), caption: I18n.t("saml.instructions.mapping_mail"), required: true, + disabled: provider.seeded_from_env?, rows: 8, input_width: :large ) @@ -51,6 +53,7 @@ class MappingForm < BaseForm label: I18n.t("saml.providers.label_mapping_for", attribute: User.human_attribute_name(:first_name)), caption: I18n.t("saml.instructions.mapping_firstname"), required: true, + disabled: provider.seeded_from_env?, rows: 8, input_width: :large ) @@ -59,6 +62,7 @@ class MappingForm < BaseForm label: I18n.t("saml.providers.label_mapping_for", attribute: User.human_attribute_name(:last_name)), caption: I18n.t("saml.instructions.mapping_lastname"), required: true, + disabled: provider.seeded_from_env?, rows: 8, input_width: :large ) @@ -66,6 +70,7 @@ class MappingForm < BaseForm name: :mapping_uid, label: I18n.t("saml.providers.label_mapping_for", attribute: I18n.t("saml.providers.label_uid")), caption: I18n.t("saml.instructions.mapping_uid"), + disabled: provider.seeded_from_env?, rows: 8, required: false, input_width: :large diff --git a/modules/auth_saml/app/forms/saml/providers/metadata_options_form.rb b/modules/auth_saml/app/forms/saml/providers/metadata_options_form.rb index c27bb36ef2bb..1b2110105287 100644 --- a/modules/auth_saml/app/forms/saml/providers/metadata_options_form.rb +++ b/modules/auth_saml/app/forms/saml/providers/metadata_options_form.rb @@ -33,6 +33,7 @@ class MetadataOptionsForm < BaseForm f.radio_button_group( name: "metadata", scope_name_to_model: false, + disabled: provider.seeded_from_env?, label: I18n.t("saml.providers.label_metadata") ) do |radio_group| radio_group.radio_button( @@ -40,6 +41,7 @@ class MetadataOptionsForm < BaseForm checked: !@provider.has_metadata?, label: I18n.t("saml.settings.metadata_none"), caption: I18n.t("saml.instructions.metadata_none"), + disabled: provider.seeded_from_env?, data: { "show-when-value-selected-target": "cause" } ) @@ -48,6 +50,7 @@ class MetadataOptionsForm < BaseForm checked: @provider.metadata_url.present?, label: I18n.t("saml.settings.metadata_url"), caption: I18n.t("saml.instructions.metadata_url"), + disabled: provider.seeded_from_env?, data: { "show-when-value-selected-target": "cause" } ) @@ -56,6 +59,7 @@ class MetadataOptionsForm < BaseForm checked: @provider.metadata_xml.present?, label: I18n.t("saml.settings.metadata_xml"), caption: I18n.t("saml.instructions.metadata_xml"), + disabled: provider.seeded_from_env?, data: { "show-when-value-selected-target": "cause" } ) end diff --git a/modules/auth_saml/app/forms/saml/providers/metadata_url_form.rb b/modules/auth_saml/app/forms/saml/providers/metadata_url_form.rb index 4853f7f264d9..bfa8bfd57bfc 100644 --- a/modules/auth_saml/app/forms/saml/providers/metadata_url_form.rb +++ b/modules/auth_saml/app/forms/saml/providers/metadata_url_form.rb @@ -34,6 +34,7 @@ class MetadataUrlForm < BaseForm name: :metadata_url, label: I18n.t("saml.settings.metadata_url"), required: false, + disabled: provider.seeded_from_env?, caption: I18n.t("saml.instructions.metadata_url"), input_width: :xlarge ) diff --git a/modules/auth_saml/app/forms/saml/providers/metadata_xml_form.rb b/modules/auth_saml/app/forms/saml/providers/metadata_xml_form.rb index 670a4d5f0889..75039598aff7 100644 --- a/modules/auth_saml/app/forms/saml/providers/metadata_xml_form.rb +++ b/modules/auth_saml/app/forms/saml/providers/metadata_xml_form.rb @@ -35,6 +35,7 @@ class MetadataXmlForm < BaseForm label: I18n.t("saml.settings.metadata_xml"), caption: I18n.t("saml.instructions.metadata_xml"), required: false, + disabled: provider.seeded_from_env?, full_width: false, rows: 10, input_width: :medium diff --git a/modules/auth_saml/app/forms/saml/providers/name_input_form.rb b/modules/auth_saml/app/forms/saml/providers/name_input_form.rb index 33e01879e723..4583f710e2cd 100644 --- a/modules/auth_saml/app/forms/saml/providers/name_input_form.rb +++ b/modules/auth_saml/app/forms/saml/providers/name_input_form.rb @@ -34,6 +34,7 @@ class NameInputForm < BaseForm name: :display_name, label: I18n.t("activemodel.attributes.saml/provider.display_name"), required: true, + disabled: provider.seeded_from_env?, caption: I18n.t("saml.instructions.display_name"), input_width: :medium ) diff --git a/modules/auth_saml/app/forms/saml/providers/request_attributes_form.rb b/modules/auth_saml/app/forms/saml/providers/request_attributes_form.rb index 01882ff4c6cd..e0b4cadcb9ed 100644 --- a/modules/auth_saml/app/forms/saml/providers/request_attributes_form.rb +++ b/modules/auth_saml/app/forms/saml/providers/request_attributes_form.rb @@ -40,6 +40,7 @@ class RequestAttributesForm < BaseForm name: :"requested_#{attribute}_attribute", label: I18n.t("saml.providers.label_requested_attribute_for", attribute: label), required: !uid, + disabled: provider.seeded_from_env?, caption: uid ? I18n.t("saml.instructions.request_uid") : nil, input_width: :large ) @@ -48,6 +49,7 @@ class RequestAttributesForm < BaseForm name: :"requested_#{attribute}_format", label: I18n.t("activemodel.attributes.saml/provider.format"), input_width: :large, + disabled: provider.seeded_from_env?, caption: link_translate( "saml.instructions.documentation_link", links: { diff --git a/modules/auth_saml/app/forms/saml/providers/submit_or_cancel_form.rb b/modules/auth_saml/app/forms/saml/providers/submit_or_cancel_form.rb index d09139de7777..ce4610581c6c 100644 --- a/modules/auth_saml/app/forms/saml/providers/submit_or_cancel_form.rb +++ b/modules/auth_saml/app/forms/saml/providers/submit_or_cancel_form.rb @@ -39,7 +39,7 @@ class SubmitOrCancelForm < ApplicationForm end f.group(layout: :horizontal) do |button_group| - button_group.submit(**@submit_button_options) + button_group.submit(**@submit_button_options) unless @provider.seeded_from_env? button_group.button(**@cancel_button_options) end end diff --git a/modules/costs/config/locales/en.yml b/modules/costs/config/locales/en.yml index f8a7e05c2ad9..a143a2933008 100644 --- a/modules/costs/config/locales/en.yml +++ b/modules/costs/config/locales/en.yml @@ -125,6 +125,7 @@ en: label_rate: "Rate" label_rate_plural: "Rates" label_status_finished: "Finished" + label_show: "Show" label_units: "Cost units" label_user: "User" label_until: "until" From bcb4253edfc70ed467ab74d604c2539e664781b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 8 Aug 2024 20:04:06 +0200 Subject: [PATCH 44/80] Allow edit button always --- .../app/components/saml/providers/sections/show_component.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/auth_saml/app/components/saml/providers/sections/show_component.rb b/modules/auth_saml/app/components/saml/providers/sections/show_component.rb index 267a23282c8a..e87e322cd3d3 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/show_component.rb +++ b/modules/auth_saml/app/components/saml/providers/sections/show_component.rb @@ -43,7 +43,7 @@ def initialize(provider, view_mode:, target_state:, end def show_edit? - provider.persisted? && @view_mode == :show + provider.persisted? end end end From 9bebddadeb351467ae0b89ef9c268c95e6b9a737 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 8 Aug 2024 20:20:15 +0200 Subject: [PATCH 45/80] Differentiate between form edit mode and single edits --- config/locales/en.yml | 1 + .../sections/form_component.html.erb | 8 ++- .../saml/providers/sections/form_component.rb | 18 +++++-- .../sections/metadata_form_component.html.erb | 10 ++-- .../sections/metadata_form_component.rb | 5 +- .../request_attributes_form_component.rb | 49 +++++++++++++++++ .../saml/providers/view_component.html.erb | 19 +++++-- .../saml/providers/view_component.rb | 2 +- .../controllers/saml/providers_controller.rb | 53 +++++++++++++------ .../app/views/saml/providers/edit.html.erb | 5 +- .../app/views/saml/providers/new.html.erb | 2 +- 11 files changed, 137 insertions(+), 35 deletions(-) create mode 100644 modules/auth_saml/app/components/saml/providers/sections/request_attributes_form_component.rb diff --git a/config/locales/en.yml b/config/locales/en.yml index 075ba237f51e..b2503096c448 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1450,6 +1450,7 @@ en: button_expand_all: "Expand all" button_favorite: "Add to favorites" button_filter: "Filter" + button_finish_setup: "Finish setup" button_generate: "Generate" button_list: "List" button_lock: "Lock" diff --git a/modules/auth_saml/app/components/saml/providers/sections/form_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/form_component.html.erb index 64490434c0fe..dfab890d718e 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/form_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/sections/form_component.html.erb @@ -4,6 +4,7 @@ model: provider, url:, method: form_method, + data: { turbo: true, turbo_stream: true } ) do |form| flex_layout do |flex| if @heading @@ -19,8 +20,11 @@ end flex.with_row(mt: 4) do - render(Saml::Providers::SubmitOrCancelForm.new(form, - provider:)) + render(Saml::Providers::SubmitOrCancelForm.new( + form, + provider:, + submit_button_options: { label: button_label } + )) end end end diff --git a/modules/auth_saml/app/components/saml/providers/sections/form_component.rb b/modules/auth_saml/app/components/saml/providers/sections/form_component.rb index bcf8541645a1..98e854d4ccf4 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/form_component.rb +++ b/modules/auth_saml/app/components/saml/providers/sections/form_component.rb @@ -30,19 +30,23 @@ # module Saml::Providers::Sections class FormComponent < SectionComponent - def initialize(provider, edit_state:, form_class:, heading:) + attr_reader :edit_state, :next_edit_state, :edit_mode + + def initialize(provider, edit_state:, form_class:, heading:, next_edit_state: nil, edit_mode: nil) super(provider) @edit_state = edit_state + @next_edit_state = next_edit_state + @edit_mode = edit_mode @form_class = form_class @heading = heading end def url if provider.new_record? - saml_providers_path(state: @edit_state) + saml_providers_path(edit_state:, edit_mode:, next_edit_state:) else - saml_provider_path(provider, state: @edit_state) + saml_provider_path(provider, edit_state:, edit_mode:, next_edit_state:) end end @@ -53,5 +57,13 @@ def form_method :put end end + + def button_label + if edit_mode + I18n.t(:button_continue) + else + I18n.t(:button_save) + end + end end end diff --git a/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.html.erb index 26ee6189d80c..d648e641bf3c 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.html.erb @@ -2,7 +2,7 @@ primer_form_with( model: provider, id: "saml-providers-edit-form", - url: import_metadata_saml_provider_path(provider), + url: import_metadata_saml_provider_path(provider, edit_mode:), data: { controller: "show-when-value-selected" }, @@ -36,9 +36,11 @@ end flex.with_row(mt: 4) do - render(Saml::Providers::SubmitOrCancelForm.new(form, - provider:, - state: :metadata)) + render(Saml::Providers::SubmitOrCancelForm.new( + form, + provider:, + submit_button_options: { label: button_label }, + state: :metadata)) end end end diff --git a/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.rb b/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.rb index 516a559e0b4e..11d114d6390e 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.rb +++ b/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.rb @@ -29,6 +29,9 @@ #++ # module Saml::Providers::Sections - class MetadataFormComponent < SectionComponent + class MetadataFormComponent < FormComponent + def initialize(provider, edit_mode: nil) + super(provider, edit_state: :metadata, edit_mode:, form_class: nil, heading: nil) + end end end diff --git a/modules/auth_saml/app/components/saml/providers/sections/request_attributes_form_component.rb b/modules/auth_saml/app/components/saml/providers/sections/request_attributes_form_component.rb new file mode 100644 index 000000000000..e0a736b2cb5b --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/sections/request_attributes_form_component.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml::Providers::Sections + class RequestAttributesFormComponent < FormComponent + def initialize(provider, edit_mode: nil) + super(provider, + edit_state: :requested_attributes, + edit_mode:, + form_class: Saml::Providers::RequestAttributesForm, + heading: I18n.t("saml.instructions.requested_attributes")) + end + + def button_label + if edit_mode + I18n.t(:button_finish_setup) + else + I18n.t(:button_save) + end + end + end +end diff --git a/modules/auth_saml/app/components/saml/providers/view_component.html.erb b/modules/auth_saml/app/components/saml/providers/view_component.html.erb index 7a5f055091bd..c8eaf5dc8aba 100644 --- a/modules/auth_saml/app/components/saml/providers/view_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/view_component.html.erb @@ -18,6 +18,8 @@ provider, form_class: Saml::Providers::NameInputForm, edit_state:, + next_edit_state: :metadata, + edit_mode:, heading: nil )) else @@ -37,7 +39,10 @@ component.with_row(scheme: :default) do if edit_state == :metadata - render(Saml::Providers::Sections::MetadataFormComponent.new(provider)) + render(Saml::Providers::Sections::MetadataFormComponent.new( + provider, + edit_mode:, + )) else render(Saml::Providers::Sections::ShowComponent.new( provider, @@ -61,6 +66,8 @@ provider, form_class: Saml::Providers::ConfigurationForm, edit_state:, + next_edit_state: :encryption, + edit_mode:, heading: t("saml.providers.section_texts.configuration_form") )) else @@ -81,6 +88,8 @@ provider, form_class: Saml::Providers::EncryptionForm, edit_state:, + next_edit_state: :mapping, + edit_mode:, heading: t("saml.providers.section_texts.encryption_form") )) else @@ -104,6 +113,8 @@ provider, form_class: Saml::Providers::MappingForm, edit_state:, + next_edit_state: :requested_attributes, + edit_mode:, heading: t("saml.instructions.mapping") )) else @@ -120,11 +131,9 @@ end component.with_row(scheme: :default) do if edit_state == :requested_attributes - render(Saml::Providers::Sections::FormComponent.new( + render(Saml::Providers::Sections::RequestAttributesFormComponent.new( provider, - form_class: Saml::Providers::RequestAttributesForm, - edit_state:, - heading: t("saml.instructions.requested_attributes") + edit_mode: )) else render(Saml::Providers::Sections::ShowComponent.new( diff --git a/modules/auth_saml/app/components/saml/providers/view_component.rb b/modules/auth_saml/app/components/saml/providers/view_component.rb index 75cc6121ee8f..a909c7f651fc 100644 --- a/modules/auth_saml/app/components/saml/providers/view_component.rb +++ b/modules/auth_saml/app/components/saml/providers/view_component.rb @@ -33,7 +33,7 @@ class ViewComponent < ApplicationComponent include OpTurbo::Streamable include OpPrimer::ComponentHelpers - options :view_mode, :edit_state + options :view_mode, :edit_state, :edit_mode alias_method :provider, :model end diff --git a/modules/auth_saml/app/controllers/saml/providers_controller.rb b/modules/auth_saml/app/controllers/saml/providers_controller.rb index e9c225f09f56..9d71804f14a1 100644 --- a/modules/auth_saml/app/controllers/saml/providers_controller.rb +++ b/modules/auth_saml/app/controllers/saml/providers_controller.rb @@ -9,18 +9,18 @@ class ProvidersController < ::ApplicationController before_action :check_ee before_action :find_provider, only: %i[show edit import_metadata update destroy] before_action :check_provider_writable, only: %i[update import_metadata] + before_action :set_edit_state, only: %i[create edit update import_metadata] def index @providers = Saml::Provider.order(display_name: :asc) end def edit - @edit_state = params[:edit_state].to_sym if params.key?(:edit_state) - respond_to do |format| format.turbo_stream do component = Saml::Providers::ViewComponent.new(@provider, view_mode: :edit, + edit_mode: @edit_mode, edit_state: @edit_state) update_via_turbo_stream(component:) scroll_into_view_via_turbo_stream("saml-providers-edit-form", behavior: :instant) @@ -49,9 +49,14 @@ def new def import_metadata if params[:saml_provider][:metadata] != "none" update_provider_metadata + return if performed? end - redirect_to edit_saml_provider_path(@provider, edit_state: :configuration) unless performed? + if @edit_mode + redirect_to edit_saml_provider_path(@provider, edit_mode: @edit_mode, edit_state: :configuration) + else + redirect_to saml_provider_path(@provider) + end end def create @@ -59,12 +64,13 @@ def create .new(user: User.current) .call(**create_params) + @provider = call.result + binding.pry + if call.success? - flash[:notice] = I18n.t(:notice_successful_create) - redirect_to edit_saml_provider_path(call.result, edit_state: :metadata) + successful_save_response else flash[:error] = call.message - @provider = call.result render action: :new end end @@ -76,10 +82,9 @@ def update if call.success? flash[:notice] = I18n.t(:notice_successful_update) - redirect_to saml_provider_path(call.result) + successful_save_response else @provider = call.result - @edit_state = params[:state].to_sym render action: :edit end end @@ -100,18 +105,26 @@ def destroy private - def success_redirect - if params[:edit_state].present? - redirect_to edit_saml_provider_path(@provider, edit_state: params[:edit_state]) - else - redirect_to saml_provider_path(@provider) + def successful_save_response + respond_to do |format| + format.turbo_stream do + component = Saml::Providers::ViewComponent.new(@provider, + edit_mode: @edit_mode, + edit_state: @next_edit_state, + view_mode: :show) + update_via_turbo_stream(component:) + render turbo_stream: turbo_streams + end + format.html do + if @edit_mode && @next_edit_state + redirect_to edit_saml_provider_path(@provider, edit_mode: true, edit_state: @next_edit_state) + else + redirect_to saml_provider_path(@provider) + end + end end end - def defaults - {} - end - def check_ee unless EnterpriseToken.allows_to?(:openid_providers) render template: "/saml/providers/upsale" @@ -195,5 +208,11 @@ def check_provider_writable redirect_to saml_provider_path(@provider) end end + + def set_edit_state + @edit_state = params[:edit_state].to_sym if params.key?(:edit_state) + @edit_mode = ActiveRecord::Type::Boolean.new.cast(params[:edit_mode]) + @next_edit_state = params[:next_edit_state].to_sym if params.key?(:next_edit_state) + end end end diff --git a/modules/auth_saml/app/views/saml/providers/edit.html.erb b/modules/auth_saml/app/views/saml/providers/edit.html.erb index d4aee70f9334..09f47ebefb2d 100644 --- a/modules/auth_saml/app/views/saml/providers/edit.html.erb +++ b/modules/auth_saml/app/views/saml/providers/edit.html.erb @@ -20,4 +20,7 @@ <% end %> <% end %> -<%= render(Saml::Providers::ViewComponent.new(@provider, view_mode: :edit, edit_state: @edit_state)) %> +<%= render(Saml::Providers::ViewComponent.new(@provider, + view_mode: :edit, + edit_mode: @edit_mode, + edit_state: @edit_state)) %> diff --git a/modules/auth_saml/app/views/saml/providers/new.html.erb b/modules/auth_saml/app/views/saml/providers/new.html.erb index 9826181dbdf5..8c66e670f24e 100644 --- a/modules/auth_saml/app/views/saml/providers/new.html.erb +++ b/modules/auth_saml/app/views/saml/providers/new.html.erb @@ -18,4 +18,4 @@ <% end %> <% end %> -<%= render(Saml::Providers::ViewComponent.new(@provider, edit_state: :name)) %> +<%= render(Saml::Providers::ViewComponent.new(@provider, edit_mode: true, edit_state: :name)) %> From 842ae27f9e860bd1dd4ebacb9bb680b208351479 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 8 Aug 2024 21:16:16 +0200 Subject: [PATCH 46/80] Disable turbo stream for form Otherwise, we will run into the url push_state issue again --- .../sections/form_component.html.erb | 3 +-- .../controllers/saml/providers_controller.rb | 20 ++++++++++--------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/modules/auth_saml/app/components/saml/providers/sections/form_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/form_component.html.erb index dfab890d718e..4bbd73674b07 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/form_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/sections/form_component.html.erb @@ -3,8 +3,7 @@ id: "saml-providers-edit-form", model: provider, url:, - method: form_method, - data: { turbo: true, turbo_stream: true } + method: form_method ) do |form| flex_layout do |flex| if @heading diff --git a/modules/auth_saml/app/controllers/saml/providers_controller.rb b/modules/auth_saml/app/controllers/saml/providers_controller.rb index 9d71804f14a1..de8af751e85f 100644 --- a/modules/auth_saml/app/controllers/saml/providers_controller.rb +++ b/modules/auth_saml/app/controllers/saml/providers_controller.rb @@ -65,7 +65,6 @@ def create .call(**create_params) @provider = call.result - binding.pry if call.success? successful_save_response @@ -81,7 +80,7 @@ def update .call(update_params) if call.success? - flash[:notice] = I18n.t(:notice_successful_update) + flash[:notice] = I18n.t(:notice_successful_update) unless @edit_mode successful_save_response else @provider = call.result @@ -108,11 +107,14 @@ def destroy def successful_save_response respond_to do |format| format.turbo_stream do - component = Saml::Providers::ViewComponent.new(@provider, - edit_mode: @edit_mode, - edit_state: @next_edit_state, - view_mode: :show) - update_via_turbo_stream(component:) + update_via_turbo_stream( + component: Saml::Providers::ViewComponent.new( + @provider, + edit_mode: @edit_mode, + edit_state: @next_edit_state, + view_mode: :show + ) + ) render turbo_stream: turbo_streams end format.html do @@ -192,8 +194,8 @@ def create_params def update_params params - .require(:saml_provider) - .permit(:display_name, *Saml::Provider.stored_attributes[:options]) + .require(:saml_provider) + .permit(:display_name, *Saml::Provider.stored_attributes[:options]) end def find_provider From 57a3a75e82d2039dac5ef1ce4b5f61b9d064a933 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 9 Aug 2024 08:04:32 +0200 Subject: [PATCH 47/80] Scroll to form even with redirect --- .../app/controllers/saml/providers_controller.rb | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/modules/auth_saml/app/controllers/saml/providers_controller.rb b/modules/auth_saml/app/controllers/saml/providers_controller.rb index de8af751e85f..efb09d7aa47a 100644 --- a/modules/auth_saml/app/controllers/saml/providers_controller.rb +++ b/modules/auth_saml/app/controllers/saml/providers_controller.rb @@ -53,7 +53,10 @@ def import_metadata end if @edit_mode - redirect_to edit_saml_provider_path(@provider, edit_mode: @edit_mode, edit_state: :configuration) + redirect_to edit_saml_provider_path(@provider, + anchor: "saml-providers-edit-form", + edit_mode: @edit_mode, + edit_state: :configuration) else redirect_to saml_provider_path(@provider) end @@ -119,7 +122,10 @@ def successful_save_response end format.html do if @edit_mode && @next_edit_state - redirect_to edit_saml_provider_path(@provider, edit_mode: true, edit_state: @next_edit_state) + redirect_to edit_saml_provider_path(@provider, + anchor: "saml-providers-edit-form", + edit_mode: true, + edit_state: @next_edit_state) else redirect_to saml_provider_path(@provider) end From 28c379530dc9982422f6852bb090782f3d2c74ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 9 Aug 2024 09:12:20 +0200 Subject: [PATCH 48/80] Extract mapper, spec --- .../seeders/env_data/saml/provider_seeder.rb | 57 +--- .../app/services/saml/configuration_mapper.rb | 95 +++++++ .../spec/fixtures/idp_cert_plain.txt | 1 + .../env_data/saml/provider_seeder_spec.rb | 149 ++++++++++ .../saml/configuration_mapper_spec.rb | 261 ++++++++++++++++++ spec/support/shared/with_settings.rb | 5 +- 6 files changed, 511 insertions(+), 57 deletions(-) create mode 100644 modules/auth_saml/app/services/saml/configuration_mapper.rb create mode 100644 modules/auth_saml/spec/fixtures/idp_cert_plain.txt create mode 100644 modules/auth_saml/spec/seeders/env_data/saml/provider_seeder_spec.rb create mode 100644 modules/auth_saml/spec/services/saml/configuration_mapper_spec.rb diff --git a/modules/auth_saml/app/seeders/env_data/saml/provider_seeder.rb b/modules/auth_saml/app/seeders/env_data/saml/provider_seeder.rb index 08e0a1167406..937a37477575 100644 --- a/modules/auth_saml/app/seeders/env_data/saml/provider_seeder.rb +++ b/modules/auth_saml/app/seeders/env_data/saml/provider_seeder.rb @@ -32,13 +32,8 @@ def seed_data! Setting.seed_saml_provider.each do |name, options| print_status " ↳ Creating or Updating SAML provider #{name}" do provider = ::Saml::Provider.find_by(slug: name) - options = mapped_options(options) - params = { - slug: name, - display_name: options.delete("display_name") || "SAML", - available: true, - options: - } + params = ::Saml::ConfigurationMapper.new(options).call! + params["options"]["seeded_from_env"] = true if provider print_status " - Updating existing SAML provider '#{name}' from ENV" @@ -72,54 +67,6 @@ def update(name, provider, params) .on_success { print_status " - Successfully updated SAML provider #{name}." } .on_failure { |call| raise "Failed to update SAML provider: #{call.message}" } end - - def mapped_options(options) - options["seeded_from_env"] = true - options["idp_sso_service_url"] ||= options.delete("idp_sso_target_url") - options["idp_slo_service_url"] ||= options.delete("idp_slo_target_url") - options["sp_entity_id"] ||= options.delete("issuer") - - build_idp_cert(options) - extract_security_options(options) - extract_mapping(options) - - options.compact - end - - def extract_mapping(options) - nil unless options["attribute_statements"] - - options["mapping_login"] = extract_mapping_attribute(options, "login") - options["mapping_mail"] = extract_mapping_attribute(options, "email") - options["mapping_firstname"] = extract_mapping_attribute(options, "first_name") - options["mapping_lastname"] = extract_mapping_attribute(options, "last_name") - options["mapping_uid"] = extract_mapping_attribute(options, "uid") - end - - def extract_mapping_attribute(options, key) - value = options["attribute_statements"][key] - - if value.present? - Array(value).join("\n") - end - end - - def build_idp_cert(options) - if options["idp_cert"] - options["idp_cert"] = OneLogin::RubySaml::Utils.format_cert(options["idp_cert"]) - elsif options["idp_cert_multi"] - options["idp_cert"] = options["idp_cert_multi"]["signing"] - .map { |cert| OneLogin::RubySaml::Utils.format_cert(cert) } - .join("\n") - end - end - - def extract_security_options(options) - return unless options["security"] - - options.merge! options["security"].slice("authn_requests_signed", "want_assertions_signed", - "want_assertions_encrypted", "digest_method", "signature_method") - end end end end diff --git a/modules/auth_saml/app/services/saml/configuration_mapper.rb b/modules/auth_saml/app/services/saml/configuration_mapper.rb new file mode 100644 index 000000000000..748745546989 --- /dev/null +++ b/modules/auth_saml/app/services/saml/configuration_mapper.rb @@ -0,0 +1,95 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml + class ConfigurationMapper + attr_reader :configuration + + def initialize(configuration) + @configuration = configuration + end + + def call! + options = mapped_options(configuration.deep_stringify_keys) + { + "options" => options, + "slug" => options.delete("name"), + "display_name" => options.delete("display_name") || "SAML" + } + end + + private + + def mapped_options(options) + options["idp_sso_service_url"] ||= options.delete("idp_sso_target_url") + options["idp_slo_service_url"] ||= options.delete("idp_slo_target_url") + options["sp_entity_id"] ||= options.delete("issuer") + + build_idp_cert(options) + extract_security_options(options) + extract_mapping(options) + + options.compact + end + + def extract_mapping(options) + return unless options["attribute_statements"] + + options["mapping_login"] = extract_mapping_attribute(options, "login") + options["mapping_mail"] = extract_mapping_attribute(options, "email") + options["mapping_firstname"] = extract_mapping_attribute(options, "first_name") + options["mapping_lastname"] = extract_mapping_attribute(options, "last_name") + options["mapping_uid"] = extract_mapping_attribute(options, "uid") + end + + def extract_mapping_attribute(options, key) + value = options["attribute_statements"][key] + + if value.present? + Array(value).join("\n") + end + end + + def build_idp_cert(options) + if options["idp_cert"] + options["idp_cert"] = OneLogin::RubySaml::Utils.format_cert(options["idp_cert"]) + elsif options["idp_cert_multi"] + options["idp_cert"] = options["idp_cert_multi"]["signing"] + .map { |cert| OneLogin::RubySaml::Utils.format_cert(cert) } + .join("\n") + end + end + + def extract_security_options(options) + return unless options["security"] + + options.merge! options["security"].slice("authn_requests_signed", "want_assertions_signed", + "want_assertions_encrypted", "digest_method", "signature_method") + end + end +end diff --git a/modules/auth_saml/spec/fixtures/idp_cert_plain.txt b/modules/auth_saml/spec/fixtures/idp_cert_plain.txt new file mode 100644 index 000000000000..bb5ce9674d63 --- /dev/null +++ b/modules/auth_saml/spec/fixtures/idp_cert_plain.txt @@ -0,0 +1 @@ +MIIDHTCCAgWgAwIBAgIJaPVOFuER4IyxMA0GCSqGSIb3DQEBCwUAMCwxKjAoBgNVBAMTIWRldi10eXQzaDZkNTZjeTB4aXA3LnVzLmF1dGgwLmNvbTAeFw0yMzA0MjUwODU0MThaFw0zNzAxMDEwODU0MThaMCwxKjAoBgNVBAMTIWRldi10eXQzaDZkNTZjeTB4aXA3LnVzLmF1dGgwLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAK/2idoD9sJDzYukmOCVC2Qp4nLm2WGkIbdAudSDzb3hEDVTebAUXeDUqi9nQMtXjS5lvXYVQMIHdq1yRLGciSBviO+qT/FGNRm/VncpWYt0mVvf6t/9Y1PJ1krFlo/72FN3VBEClqAccVryPq+pjwswZMWbgBi3BsMdi+HYW2mgPRKeAemaEYcPwPyUpCBiVQo/6cCSlBB+Kigyp33LAJZVakwNA5pxEfnhUwifiyBzO+xH5m4ol8mcDznsmiBYddxqVQ7qaYds4HSpwSzArdxLnlFj1nVwusJyGsjZD4pCMZuoWXLGPs4ldBgzLH5Dq6UtsCKPZGhRzYA4Au7qVGcCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUrh9OF0EmZv3h3BmcuCF05qLDYokwDgYDVR0PAQH/BAQDAgKEMA0GCSqGSIb3DQEBCwUAA4IBAQAoo/z1LTjEmGrm9ilObVDzPSWlvOqDq1lkP8nrROpYkghDPxXQl+8nJ4J0gKwDP6BgTjWzhMk8MyoeCn8wZGI7oBOeBPOeJCbc6WYW2HGvtc86nO3JHHdvDYMO3HbR9LFoxBXhF95nWwgmPd+9uxdHrdfQ1CIXasATWToQgud6rkCgMcNP25LFds36iMCzXDxKiYvn54JaXmGo567U1Rx6O4AwnCG1RHTdtx/CdiIuPAOBl/xUnnsVeCPQgqNl53/6Qtmk9kg15GeeFDsjJoHoZuLZANaGMUBLRMzLU4al3/SRgPJLaEq46BaRq/oX0WvWlGiVegHNWjpOJ5ZSEeS5 diff --git a/modules/auth_saml/spec/seeders/env_data/saml/provider_seeder_spec.rb b/modules/auth_saml/spec/seeders/env_data/saml/provider_seeder_spec.rb new file mode 100644 index 000000000000..42e039df5de0 --- /dev/null +++ b/modules/auth_saml/spec/seeders/env_data/saml/provider_seeder_spec.rb @@ -0,0 +1,149 @@ +# 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. +#++ + +require "spec_helper" + +RSpec.describe EnvData::Saml::ProviderSeeder, :settings_reset do + let(:seed_data) { Source::SeedData.new({}) } + + subject(:seeder) { described_class.new(seed_data) } + + before do + reset(:seed_saml_provider, + description: "Provide a SAML provider and sync its settings through ENV", + env_alias: "OPENPROJECT_SAML", + writable: false, + default: nil, + format: :hash) + end + + context "when not provided" do + it "does nothing" do + expect { seeder.seed! }.not_to change(Saml::Provider, :count) + end + end + + context "when providing seed variables", + with_env: { + OPENPROJECT_SAML_SAML_NAME: "saml", + OPENPROJECT_SAML_SAML_DISPLAY__NAME: "Test SAML", + OPENPROJECT_SAML_SAML_ASSERTION__CONSUMER__SERVICE__URL: "some wrong value", + OPENPROJECT_SAML_SAML_ASSERTION__CONSUMER__SERVICE__BINDING: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", + OPENPROJECT_SAML_SAML_SP__ENTITY__ID: "http://localhost:3000", + OPENPROJECT_SAML_SAML_IDP__CERT: "MIIDHTCCAgWgAwIBAgIJaPVOFuER4IyxMA0GCSqGSIb3DQEBCwUAMCwxKjAoBgNVBAMTIWRldi10eXQzaDZkNTZjeTB4aXA3LnVzLmF1dGgwLmNvbTAeFw0yMzA0MjUwODU0MThaFw0zNzAxMDEwODU0MThaMCwxKjAoBgNVBAMTIWRldi10eXQzaDZkNTZjeTB4aXA3LnVzLmF1dGgwLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAK/2idoD9sJDzYukmOCVC2Qp4nLm2WGkIbdAudSDzb3hEDVTebAUXeDUqi9nQMtXjS5lvXYVQMIHdq1yRLGciSBviO+qT/FGNRm/VncpWYt0mVvf6t/9Y1PJ1krFlo/72FN3VBEClqAccVryPq+pjwswZMWbgBi3BsMdi+HYW2mgPRKeAemaEYcPwPyUpCBiVQo/6cCSlBB+Kigyp33LAJZVakwNA5pxEfnhUwifiyBzO+xH5m4ol8mcDznsmiBYddxqVQ7qaYds4HSpwSzArdxLnlFj1nVwusJyGsjZD4pCMZuoWXLGPs4ldBgzLH5Dq6UtsCKPZGhRzYA4Au7qVGcCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUrh9OF0EmZv3h3BmcuCF05qLDYokwDgYDVR0PAQH/BAQDAgKEMA0GCSqGSIb3DQEBCwUAA4IBAQAoo/z1LTjEmGrm9ilObVDzPSWlvOqDq1lkP8nrROpYkghDPxXQl+8nJ4J0gKwDP6BgTjWzhMk8MyoeCn8wZGI7oBOeBPOeJCbc6WYW2HGvtc86nO3JHHdvDYMO3HbR9LFoxBXhF95nWwgmPd+9uxdHrdfQ1CIXasATWToQgud6rkCgMcNP25LFds36iMCzXDxKiYvn54JaXmGo567U1Rx6O4AwnCG1RHTdtx/CdiIuPAOBl/xUnnsVeCPQgqNl53/6Qtmk9kg15GeeFDsjJoHoZuLZANaGMUBLRMzLU4al3/SRgPJLaEq46BaRq/oX0WvWlGiVegHNWjpOJ5ZSEeS5", + OPENPROJECT_SAML_SAML_IDP__SSO__TARGET__URL: "https://saml.example.com/samlp/tPLrXaiBZhLNtzBMN8oNFQAzhFdGlUPK", + OPENPROJECT_SAML_SAML_IDP__SLO__TARGET__URL: "https://saml.example.com/samlp/tPLrXaiBZhLNtzBMN8oNFQAzhFdGlUPK/logout", + OPENPROJECT_SAML_SAML_ATTRIBUTE__STATEMENTS_EMAIL: "['mail', 'urn:oid:2.5.4.42', 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']", + OPENPROJECT_SAML_SAML_ATTRIBUTE__STATEMENTS_LOGIN: "['mail', 'urn:oid:2.5.4.42', 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']", + OPENPROJECT_SAML_SAML_ATTRIBUTE__STATEMENTS_FIRST__NAME: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname", + OPENPROJECT_SAML_SAML_ATTRIBUTE__STATEMENTS_LAST__NAME: "['urn:oid:2.5.4.4', 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname']" + } do + it "uses those variables" do + expect { seeder.seed! }.to change(Saml::Provider, :count).by(1) + + provider = Saml::Provider.last + expect(provider.slug).to eq "saml" + expect(provider.display_name).to eq "Test SAML" + expect(provider.seeded_from_env).to be true + + expect(provider.sp_entity_id).to eq "http://localhost:3000" + expect(provider.assertion_consumer_service_url).to eq "http://localhost:3000/auth/saml/callback" + expect(provider.idp_cert).to eq OneLogin::RubySaml::Utils.format_cert(ENV.fetch("OPENPROJECT_SAML_SAML_IDP__CERT")) + + expect(provider.mapping_login).to eq "mail\nurn:oid:2.5.4.42\nhttp://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" + expect(provider.mapping_mail).to eq "mail\nurn:oid:2.5.4.42\nhttp://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" + expect(provider.mapping_firstname).to eq "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname" + expect(provider.mapping_lastname).to eq "urn:oid:2.5.4.4\nhttp://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname" + end + + context "when provider already exists with that name" do + it "updates the provider" do + provider = Saml::Provider.create!(display_name: "Something", slug: "saml", mapping_mail: "old", creator: User.system) + expect(provider.seeded_from_env).to be_nil + + expect { seeder.seed! }.not_to change(Saml::Provider, :count) + + provider.reload + + expect(provider.display_name).to eq "Test SAML" + expect(provider.seeded_from_env).to be true + expect(provider.mapping_mail).to eq "mail\nurn:oid:2.5.4.42\nhttp://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" + end + end + end + + context "when providing invalid variables", + with_env: { + OPENPROJECT_SAML_SAML_NAME: "saml", + OPENPROJECT_SAML_SAML_DISPLAY__NAME: "Test SAML", + OPENPROJECT_SAML_SAML_IDP__CERT: "invalid" + } do + it "raises an exception" do + expect { seeder.seed! }.to raise_error(/Idp cert is not a valid PEM-formatted certificate/) + + expect(Saml::Provider.all).to be_empty + end + end + + context "when providing multiple variables", + with_env: { + OPENPROJECT_SAML_SAML_NAME: "saml", + OPENPROJECT_SAML_SAML_DISPLAY__NAME: "Test SAML", + OPENPROJECT_SAML_SAML_ASSERTION__CONSUMER__SERVICE__URL: "some wrong value", + OPENPROJECT_SAML_SAML_ASSERTION__CONSUMER__SERVICE__BINDING: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", + OPENPROJECT_SAML_SAML_SP__ENTITY__ID: "http://localhost:3000", + OPENPROJECT_SAML_SAML_IDP__CERT: "MIIDHTCCAgWgAwIBAgIJaPVOFuER4IyxMA0GCSqGSIb3DQEBCwUAMCwxKjAoBgNVBAMTIWRldi10eXQzaDZkNTZjeTB4aXA3LnVzLmF1dGgwLmNvbTAeFw0yMzA0MjUwODU0MThaFw0zNzAxMDEwODU0MThaMCwxKjAoBgNVBAMTIWRldi10eXQzaDZkNTZjeTB4aXA3LnVzLmF1dGgwLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAK/2idoD9sJDzYukmOCVC2Qp4nLm2WGkIbdAudSDzb3hEDVTebAUXeDUqi9nQMtXjS5lvXYVQMIHdq1yRLGciSBviO+qT/FGNRm/VncpWYt0mVvf6t/9Y1PJ1krFlo/72FN3VBEClqAccVryPq+pjwswZMWbgBi3BsMdi+HYW2mgPRKeAemaEYcPwPyUpCBiVQo/6cCSlBB+Kigyp33LAJZVakwNA5pxEfnhUwifiyBzO+xH5m4ol8mcDznsmiBYddxqVQ7qaYds4HSpwSzArdxLnlFj1nVwusJyGsjZD4pCMZuoWXLGPs4ldBgzLH5Dq6UtsCKPZGhRzYA4Au7qVGcCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUrh9OF0EmZv3h3BmcuCF05qLDYokwDgYDVR0PAQH/BAQDAgKEMA0GCSqGSIb3DQEBCwUAA4IBAQAoo/z1LTjEmGrm9ilObVDzPSWlvOqDq1lkP8nrROpYkghDPxXQl+8nJ4J0gKwDP6BgTjWzhMk8MyoeCn8wZGI7oBOeBPOeJCbc6WYW2HGvtc86nO3JHHdvDYMO3HbR9LFoxBXhF95nWwgmPd+9uxdHrdfQ1CIXasATWToQgud6rkCgMcNP25LFds36iMCzXDxKiYvn54JaXmGo567U1Rx6O4AwnCG1RHTdtx/CdiIuPAOBl/xUnnsVeCPQgqNl53/6Qtmk9kg15GeeFDsjJoHoZuLZANaGMUBLRMzLU4al3/SRgPJLaEq46BaRq/oX0WvWlGiVegHNWjpOJ5ZSEeS5", + OPENPROJECT_SAML_SAML_IDP__SSO__TARGET__URL: "https://saml.example.com/samlp/tPLrXaiBZhLNtzBMN8oNFQAzhFdGlUPK", + OPENPROJECT_SAML_SAML_IDP__SLO__TARGET__URL: "https://saml.example.com/samlp/tPLrXaiBZhLNtzBMN8oNFQAzhFdGlUPK/logout", + OPENPROJECT_SAML_SAML_ATTRIBUTE__STATEMENTS_EMAIL: "['mail', 'urn:oid:2.5.4.42', 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']", + OPENPROJECT_SAML_SAML_ATTRIBUTE__STATEMENTS_LOGIN: "['mail', 'urn:oid:2.5.4.42', 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']", + OPENPROJECT_SAML_SAML_ATTRIBUTE__STATEMENTS_FIRST__NAME: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname", + OPENPROJECT_SAML_SAML_ATTRIBUTE__STATEMENTS_LAST__NAME: "['urn:oid:2.5.4.4', 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname']", + OPENPROJECT_SAML_MYSAML_NAME: "mysaml", + OPENPROJECT_SAML_MYSAML_DISPLAY__NAME: "Another SAML", + OPENPROJECT_SAML_MYSAML_ASSERTION__CONSUMER__SERVICE__URL: "some wrong value", + OPENPROJECT_SAML_MYSAML_ASSERTION__CONSUMER__SERVICE__BINDING: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", + OPENPROJECT_SAML_MYSAML_SP__ENTITY__ID: "http://localhost:3000", + OPENPROJECT_SAML_MYSAML_IDP__CERT: "MIIDHTCCAgWgAwIBAgIJaPVOFuER4IyxMA0GCSqGSIb3DQEBCwUAMCwxKjAoBgNVBAMTIWRldi10eXQzaDZkNTZjeTB4aXA3LnVzLmF1dGgwLmNvbTAeFw0yMzA0MjUwODU0MThaFw0zNzAxMDEwODU0MThaMCwxKjAoBgNVBAMTIWRldi10eXQzaDZkNTZjeTB4aXA3LnVzLmF1dGgwLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAK/2idoD9sJDzYukmOCVC2Qp4nLm2WGkIbdAudSDzb3hEDVTebAUXeDUqi9nQMtXjS5lvXYVQMIHdq1yRLGciSBviO+qT/FGNRm/VncpWYt0mVvf6t/9Y1PJ1krFlo/72FN3VBEClqAccVryPq+pjwswZMWbgBi3BsMdi+HYW2mgPRKeAemaEYcPwPyUpCBiVQo/6cCSlBB+Kigyp33LAJZVakwNA5pxEfnhUwifiyBzO+xH5m4ol8mcDznsmiBYddxqVQ7qaYds4HSpwSzArdxLnlFj1nVwusJyGsjZD4pCMZuoWXLGPs4ldBgzLH5Dq6UtsCKPZGhRzYA4Au7qVGcCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUrh9OF0EmZv3h3BmcuCF05qLDYokwDgYDVR0PAQH/BAQDAgKEMA0GCSqGSIb3DQEBCwUAA4IBAQAoo/z1LTjEmGrm9ilObVDzPSWlvOqDq1lkP8nrROpYkghDPxXQl+8nJ4J0gKwDP6BgTjWzhMk8MyoeCn8wZGI7oBOeBPOeJCbc6WYW2HGvtc86nO3JHHdvDYMO3HbR9LFoxBXhF95nWwgmPd+9uxdHrdfQ1CIXasATWToQgud6rkCgMcNP25LFds36iMCzXDxKiYvn54JaXmGo567U1Rx6O4AwnCG1RHTdtx/CdiIuPAOBl/xUnnsVeCPQgqNl53/6Qtmk9kg15GeeFDsjJoHoZuLZANaGMUBLRMzLU4al3/SRgPJLaEq46BaRq/oX0WvWlGiVegHNWjpOJ5ZSEeS5", + OPENPROJECT_SAML_MYSAML_IDP__SSO__TARGET__URL: "https://saml.example.com/samlp/tPLrXaiBZhLNtzBMN8oNFQAzhFdGlUPK", + OPENPROJECT_SAML_MYSAML_IDP__SLO__TARGET__URL: "https://saml.example.com/samlp/tPLrXaiBZhLNtzBMN8oNFQAzhFdGlUPK/logout", + OPENPROJECT_SAML_MYSAML_ATTRIBUTE__STATEMENTS_EMAIL: "['mail', 'urn:oid:2.5.4.42', 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']", + OPENPROJECT_SAML_MYSAML_ATTRIBUTE__STATEMENTS_LOGIN: "['mail', 'urn:oid:2.5.4.42', 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']", + OPENPROJECT_SAML_MYSAML_ATTRIBUTE__STATEMENTS_FIRST__NAME: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname", + OPENPROJECT_SAML_MYSAML_ATTRIBUTE__STATEMENTS_LAST__NAME: "['urn:oid:2.5.4.4', 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname']" + } do + it "creates both" do + expect { seeder.seed! }.to change(Saml::Provider, :count).by(2) + + providers = Saml::Provider.pluck(:slug) + expect(providers).to contain_exactly("saml", "mysaml") + end + end +end diff --git a/modules/auth_saml/spec/services/saml/configuration_mapper_spec.rb b/modules/auth_saml/spec/services/saml/configuration_mapper_spec.rb new file mode 100644 index 000000000000..6cf93f4af3fb --- /dev/null +++ b/modules/auth_saml/spec/services/saml/configuration_mapper_spec.rb @@ -0,0 +1,261 @@ +# 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. +#++ + +require "spec_helper" + +RSpec.describe Saml::ConfigurationMapper, type: :model do + let(:instance) { described_class.new(configuration) } + let(:result) { instance.call! } + + describe "display_name" do + subject { result["display_name"] } + + context "when provided" do + let(:configuration) { { display_name: "My SAML Provider" } } + + it { is_expected.to eq("My SAML Provider") } + end + + context "when not provided" do + let(:configuration) { {} } + + it { is_expected.to eq("SAML") } + end + end + + describe "slug" do + subject { result["slug"] } + + context "when provided from name" do + let(:configuration) { { name: "samlwat" } } + + it { is_expected.to eq("samlwat") } + end + + context "when not provided" do + let(:configuration) { {} } + + it { is_expected.to be_nil } + end + end + + describe "idp_sso_service_url" do + subject { result["options"] } + + context "when provided" do + let(:configuration) { { idp_sso_service_url: "http://example.com" } } + + it { is_expected.to include("idp_sso_service_url" => "http://example.com") } + end + + context "when provided as legacy" do + let(:configuration) { { idp_sso_target_url: "http://example.com" } } + + it { is_expected.to include("idp_sso_service_url" => "http://example.com") } + end + end + + describe "idp_slo_service_url" do + subject { result["options"] } + + context "when provided" do + let(:configuration) { { idp_slo_service_url: "http://example.com" } } + + it { is_expected.to include("idp_slo_service_url" => "http://example.com") } + end + + context "when provided as legacy" do + let(:configuration) { { idp_slo_target_url: "http://example.com" } } + + it { is_expected.to include("idp_slo_service_url" => "http://example.com") } + end + end + + describe "sp_entity_id" do + subject { result["options"] } + + context "when provided" do + let(:configuration) { { sp_entity_id: "http://example.com" } } + + it { is_expected.to include("sp_entity_id" => "http://example.com") } + end + + context "when provided as legacy" do + let(:configuration) { { issuer: "http://example.com" } } + + it { is_expected.to include("sp_entity_id" => "http://example.com") } + end + end + + describe "idp_cert" do + let(:idp_cert) { File.read(Rails.root.join("modules/auth_saml/spec/fixtures/idp_cert_plain.txt").to_s) } + + subject { result["options"] } + + context "when provided as single" do + let(:configuration) do + { idp_cert: } + end + + it "formats the certificate" do + expect(subject["idp_cert"]).to include("BEGIN CERTIFICATE") + expect(subject["idp_cert"]).to eq(OneLogin::RubySaml::Utils.format_cert(idp_cert)) + end + end + + context "when provided already formatted" do + let(:configuration) do + { idp_cert: OneLogin::RubySaml::Utils.format_cert(idp_cert) } + end + + it "uses the certificate as is" do + expect(subject["idp_cert"]).to include("BEGIN CERTIFICATE") + expect(subject["idp_cert"]).to eq(OneLogin::RubySaml::Utils.format_cert(idp_cert)) + end + end + + context "when provided as multi" do + let(:configuration) do + { + idp_cert_multi: { + signing: [idp_cert, idp_cert] + } + } + end + + it "formats the certificate" do + expect(subject["idp_cert"]).to include("BEGIN CERTIFICATE") + expect(subject["idp_cert"].scan("BEGIN CERTIFICATE").length).to eq(2) + formatted = OneLogin::RubySaml::Utils.format_cert(idp_cert) + expect(subject["idp_cert"]).to eq("#{formatted}\n#{formatted}") + end + end + end + + describe "attribute mapping" do + let(:configuration) { { attribute_statements: } } + + subject { result["options"] } + + context "when provided" do + let(:attribute_statements) do + { + login: "uid", + email: %w[email mail], + first_name: "givenName", + last_name: "sn", + uid: "someInternalValue" + } + end + + it "extracts the mappings" do + expect(subject["mapping_login"]).to eq "uid" + expect(subject["mapping_mail"]).to eq "email\nmail" + expect(subject["mapping_firstname"]).to eq "givenName" + expect(subject["mapping_lastname"]).to eq "sn" + expect(subject["mapping_uid"]).to eq "someInternalValue" + end + end + + context "when partially provided" do + let(:attribute_statements) do + { + login: "uid", + email: "mail" + } + end + + it "extracts the mappings" do + expect(subject["mapping_login"]).to eq "uid" + expect(subject["mapping_mail"]).to eq "mail" + expect(subject["mapping_firstname"]).to be_nil + expect(subject["mapping_lastname"]).to be_nil + expect(subject["mapping_uid"]).to be_nil + end + end + + context "when not provided" do + let(:attribute_statements) { nil } + + it "does not set any security options" do + expect(subject["mapping_login"]).to be_nil + expect(subject["mapping_mail"]).to be_nil + expect(subject["mapping_firstname"]).to be_nil + expect(subject["mapping_lastname"]).to be_nil + expect(subject["mapping_uid"]).to be_nil + end + end + end + + describe "security" do + let(:configuration) { { security: } } + + subject { result["options"] } + + context "when provided" do + let(:security) do + { + authn_requests_signed: true, + want_assertions_signed: true, + want_assertions_encrypted: true, + digest_method: "http://www.w3.org/2001/04/xmlenc#sha256", + signature_method: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + bogus_method: "wat" + } + end + + it "extracts the security options" do + expect(subject).to include(security + .slice(:authn_requests_signed, :want_assertions_signed, + :want_assertions_encrypted, :digest_method, :signature_method) + .stringify_keys) + + expect(subject["authn_requests_signed"]).to be true + expect(subject["want_assertions_signed"]).to be true + expect(subject["want_assertions_encrypted"]).to be true + expect(subject["digest_method"]).to eq("http://www.w3.org/2001/04/xmlenc#sha256") + expect(subject["signature_method"]).to eq("http://www.w3.org/2001/04/xmldsig-more#rsa-sha256") + expect(subject).not_to include("bogus_method") + end + end + + context "when not provided" do + let(:security) { nil } + + it "does not set any security options" do + expect(subject["authn_requests_signed"]).to be_nil + expect(subject["want_assertions_signed"]).to be_nil + expect(subject["want_assertions_encrypted"]).to be_nil + expect(subject["digest_method"]).to be_nil + expect(subject["signature_method"]).to be_nil + end + end + end +end diff --git a/spec/support/shared/with_settings.rb b/spec/support/shared/with_settings.rb index a49c56bbcb2e..6d516dbd799b 100644 --- a/spec/support/shared/with_settings.rb +++ b/spec/support/shared/with_settings.rb @@ -41,9 +41,10 @@ def aggregate_mocked_settings(example, settings) RSpec.shared_context "with settings reset" do shared_let(:definitions_before) { Settings::Definition.all.dup } - def reset(setting) + def reset(setting, **definitions) + definitions ||= Settings::Definition::DEFINITIONS[setting] Settings::Definition.all.delete(setting) - Settings::Definition.add(setting, **Settings::Definition::DEFINITIONS[setting]) + Settings::Definition.add(setting, **definitions) end def stub_configuration_yml From 00e165f9dc8063c004b2215122dcf75bc1ac1543 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 20 Aug 2024 10:13:17 +0200 Subject: [PATCH 49/80] Know when metadata was requested last --- .../saml/providers/sections/form_component.html.erb | 9 +++++++++ .../components/saml/providers/sections/form_component.rb | 6 +++++- .../app/controllers/saml/providers_controller.rb | 9 +++++---- modules/auth_saml/app/models/saml/provider.rb | 1 + 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/modules/auth_saml/app/components/saml/providers/sections/form_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/form_component.html.erb index 4bbd73674b07..5115da161606 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/form_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/sections/form_component.html.erb @@ -14,6 +14,15 @@ end end + if @banner + flex.with_row do + icon = @banner_scheme == :warning ? :alert : :info + render(Primer::Alpha::Banner.new(scheme: @banner_scheme, icon:)) do + @banner + end + end + end + flex.with_row do render(@form_class.new(form, provider:)) end diff --git a/modules/auth_saml/app/components/saml/providers/sections/form_component.rb b/modules/auth_saml/app/components/saml/providers/sections/form_component.rb index 98e854d4ccf4..07cabe0ad1f0 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/form_component.rb +++ b/modules/auth_saml/app/components/saml/providers/sections/form_component.rb @@ -32,7 +32,9 @@ module Saml::Providers::Sections class FormComponent < SectionComponent attr_reader :edit_state, :next_edit_state, :edit_mode - def initialize(provider, edit_state:, form_class:, heading:, next_edit_state: nil, edit_mode: nil) + def initialize(provider, edit_state:, form_class:, + heading:, banner: nil, banner_scheme: :info, + next_edit_state: nil, edit_mode: nil) super(provider) @edit_state = edit_state @@ -40,6 +42,8 @@ def initialize(provider, edit_state:, form_class:, heading:, next_edit_state: ni @edit_mode = edit_mode @form_class = form_class @heading = heading + @banner = banner + @banner_scheme = banner_scheme end def url diff --git a/modules/auth_saml/app/controllers/saml/providers_controller.rb b/modules/auth_saml/app/controllers/saml/providers_controller.rb index efb09d7aa47a..ef4cfce3057a 100644 --- a/modules/auth_saml/app/controllers/saml/providers_controller.rb +++ b/modules/auth_saml/app/controllers/saml/providers_controller.rb @@ -162,7 +162,7 @@ def load_and_apply_metadata .call if call.success? - apply_metadata(call.result) + apply_metadata(call.result.compact_blank) else @edit_state = :metadata @@ -171,11 +171,12 @@ def load_and_apply_metadata end end - def apply_metadata(params) - new_options = @provider.options.merge(params.compact_blank) + def apply_metadata(params) # rubocop:disable Metrics/AbcSize + new_options = @provider.options.merge(params) + last_metadata_update = params.blank? ? nil : Time.current call = Saml::Providers::UpdateService .new(model: @provider, user: User.current) - .call({ options: new_options }) + .call({ options: new_options, last_metadata_update: }) if call.success? flash[:notice] = I18n.t("saml.metadata_parser.success") diff --git a/modules/auth_saml/app/models/saml/provider.rb b/modules/auth_saml/app/models/saml/provider.rb index e2544a5028ef..d56d36bffddb 100644 --- a/modules/auth_saml/app/models/saml/provider.rb +++ b/modules/auth_saml/app/models/saml/provider.rb @@ -8,6 +8,7 @@ class Provider < AuthProvider store_attribute :options, :name_identifier_format, :string store_attribute :options, :metadata_url, :string store_attribute :options, :metadata_xml, :string + store_attribute :options, :last_metadata_update, :datetime store_attribute :options, :idp_sso_service_url, :string store_attribute :options, :idp_slo_service_url, :string From 8d5754a9c7a3df937bf83dc670d2b58e64cd8d95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 21 Aug 2024 09:05:19 +0200 Subject: [PATCH 50/80] Show banner in metadata --- .../sections/metadata_form_component.html.erb | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.html.erb index d648e641bf3c..0e6ef653deb4 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.html.erb @@ -9,9 +9,17 @@ method: :post, ) do |form| flex_layout do |flex| - flex.with_row do - render(Primer::Beta::Text.new(tag: :p, mb: 4, font_size: :small)) do - t("saml.providers.section_texts.metadata_form") + if edit_mode + flex.with_row do + render(Primer::Beta::Text.new(tag: :p, mb: 4, font_size: :small)) do + t("saml.providers.section_texts.metadata_form") + end + end + else + flex.with_row do + render(Primer::Alpha::Banner.new(mb: 2, scheme: :warning, icon: :alert)) do + t("saml.providers.section_texts.metadata_form_banner") + end end end From 59b3534b4e37d996f2787083291dbe1acd4d0ad4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 21 Aug 2024 09:05:26 +0200 Subject: [PATCH 51/80] Hide cancel button in edit mode --- .../components/saml/providers/sections/form_component.html.erb | 3 ++- .../app/components/saml/providers/sections/form_component.rb | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/auth_saml/app/components/saml/providers/sections/form_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/form_component.html.erb index 5115da161606..952ad7343ccf 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/form_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/sections/form_component.html.erb @@ -31,7 +31,8 @@ render(Saml::Providers::SubmitOrCancelForm.new( form, provider:, - submit_button_options: { label: button_label } + submit_button_options: { label: button_label }, + cancel_button_options: { hidden: edit_mode } )) end end diff --git a/modules/auth_saml/app/components/saml/providers/sections/form_component.rb b/modules/auth_saml/app/components/saml/providers/sections/form_component.rb index 07cabe0ad1f0..b0b97c2ed33b 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/form_component.rb +++ b/modules/auth_saml/app/components/saml/providers/sections/form_component.rb @@ -66,7 +66,7 @@ def button_label if edit_mode I18n.t(:button_continue) else - I18n.t(:button_save) + I18n.t(:button_update) end end end From 1a886a984e9a1fed21121612d27557896aadcf21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 21 Aug 2024 09:05:33 +0200 Subject: [PATCH 52/80] Update metadata correctly --- .../sections/form_component.html.erb | 2 +- .../saml/providers/sections/form_component.rb | 2 +- .../saml/providers/view_component.html.erb | 4 +- .../controllers/saml/providers_controller.rb | 80 ++++++------------- .../saml/providers/submit_or_cancel_form.rb | 2 +- modules/auth_saml/app/models/saml/provider.rb | 4 + .../services/saml/providers/create_service.rb | 1 + .../saml/providers/update_metadata.rb | 47 +++++++++++ .../services/saml/providers/update_service.rb | 1 + .../services/saml/update_metadata_service.rb | 26 +++--- modules/auth_saml/config/locales/en.yml | 4 +- 11 files changed, 102 insertions(+), 71 deletions(-) create mode 100644 modules/auth_saml/app/services/saml/providers/update_metadata.rb diff --git a/modules/auth_saml/app/components/saml/providers/sections/form_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/form_component.html.erb index 952ad7343ccf..ba286d6bd055 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/form_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/sections/form_component.html.erb @@ -15,7 +15,7 @@ end if @banner - flex.with_row do + flex.with_row(mb: 2) do icon = @banner_scheme == :warning ? :alert : :info render(Primer::Alpha::Banner.new(scheme: @banner_scheme, icon:)) do @banner diff --git a/modules/auth_saml/app/components/saml/providers/sections/form_component.rb b/modules/auth_saml/app/components/saml/providers/sections/form_component.rb index b0b97c2ed33b..7935602098d3 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/form_component.rb +++ b/modules/auth_saml/app/components/saml/providers/sections/form_component.rb @@ -33,7 +33,7 @@ class FormComponent < SectionComponent attr_reader :edit_state, :next_edit_state, :edit_mode def initialize(provider, edit_state:, form_class:, - heading:, banner: nil, banner_scheme: :info, + heading:, banner: nil, banner_scheme: :default, next_edit_state: nil, edit_mode: nil) super(provider) diff --git a/modules/auth_saml/app/components/saml/providers/view_component.html.erb b/modules/auth_saml/app/components/saml/providers/view_component.html.erb index c8eaf5dc8aba..931e2dfa3a0b 100644 --- a/modules/auth_saml/app/components/saml/providers/view_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/view_component.html.erb @@ -68,7 +68,9 @@ edit_state:, next_edit_state: :encryption, edit_mode:, - heading: t("saml.providers.section_texts.configuration_form") + banner: provider.last_metadata_update ? t("saml.providers.section_texts.configuration_metadata") : nil, + banner_scheme: :default, + heading: nil )) else render(Saml::Providers::Sections::ShowComponent.new( diff --git a/modules/auth_saml/app/controllers/saml/providers_controller.rb b/modules/auth_saml/app/controllers/saml/providers_controller.rb index ef4cfce3057a..e49985cbceaf 100644 --- a/modules/auth_saml/app/controllers/saml/providers_controller.rb +++ b/modules/auth_saml/app/controllers/saml/providers_controller.rb @@ -47,18 +47,23 @@ def new end def import_metadata - if params[:saml_provider][:metadata] != "none" - update_provider_metadata - return if performed? - end + call = update_provider_metadata_call + @provider = call.result - if @edit_mode - redirect_to edit_saml_provider_path(@provider, - anchor: "saml-providers-edit-form", - edit_mode: @edit_mode, - edit_state: :configuration) + if call.success? + if @edit_mode || @provider.last_metadata_update.present? + redirect_to edit_saml_provider_path(@provider, + anchor: "saml-providers-edit-form", + edit_mode: @edit_mode, + edit_state: :configuration) + else + redirect_to saml_provider_path(@provider) + end else - redirect_to saml_provider_path(@provider) + @edit_state = :metadata + + flash[:error] = call.message + render action: :edit end end @@ -140,61 +145,24 @@ def check_ee end end - def update_provider_metadata - call = Saml::Providers::UpdateService + def update_provider_metadata_call + Saml::Providers::UpdateService .new(model: @provider, user: User.current) .call(import_params) - - if call.success? - load_and_apply_metadata - else - @provider = call.result - @edit_state = :metadata - - flash[:error] = call.message - render action: :edit - end - end - - def load_and_apply_metadata - call = Saml::UpdateMetadataService - .new(provider: @provider, user: User.current) - .call - - if call.success? - apply_metadata(call.result.compact_blank) - else - @edit_state = :metadata - - flash[:error] = call.message - render action: :edit - end end - def apply_metadata(params) # rubocop:disable Metrics/AbcSize - new_options = @provider.options.merge(params) - last_metadata_update = params.blank? ? nil : Time.current - call = Saml::Providers::UpdateService - .new(model: @provider, user: User.current) - .call({ options: new_options, last_metadata_update: }) + def import_params + options = params + .require(:saml_provider) + .permit(:metadata_url, :metadata_xml, :metadata) - if call.success? - flash[:notice] = I18n.t("saml.metadata_parser.success") + if options[:metadata] == "none" + { metadata_url: nil, metadata_xml: nil } else - @provider = call.result - @edit_state = :configuration - - flash[:error] = call.message - render action: :edit + options.slice(:metadata_url, :metadata_xml) end end - def import_params - params - .require(:saml_provider) - .permit(:metadata_url, :metadata_xml) - end - def create_params params.require(:saml_provider).permit(:display_name) end diff --git a/modules/auth_saml/app/forms/saml/providers/submit_or_cancel_form.rb b/modules/auth_saml/app/forms/saml/providers/submit_or_cancel_form.rb index ce4610581c6c..40f55fe34a6c 100644 --- a/modules/auth_saml/app/forms/saml/providers/submit_or_cancel_form.rb +++ b/modules/auth_saml/app/forms/saml/providers/submit_or_cancel_form.rb @@ -40,7 +40,7 @@ class SubmitOrCancelForm < ApplicationForm f.group(layout: :horizontal) do |button_group| button_group.submit(**@submit_button_options) unless @provider.seeded_from_env? - button_group.button(**@cancel_button_options) + button_group.button(**@cancel_button_options) unless @cancel_button_options[:hidden] end end diff --git a/modules/auth_saml/app/models/saml/provider.rb b/modules/auth_saml/app/models/saml/provider.rb index d56d36bffddb..7747e7cee62b 100644 --- a/modules/auth_saml/app/models/saml/provider.rb +++ b/modules/auth_saml/app/models/saml/provider.rb @@ -56,6 +56,10 @@ def has_metadata? metadata_xml.present? || metadata_url.present? end + def metadata_updated? + metadata_xml_changed? || metadata_url_changed? + end + def configured? sp_entity_id.present? && idp_sso_service_url.present? && diff --git a/modules/auth_saml/app/services/saml/providers/create_service.rb b/modules/auth_saml/app/services/saml/providers/create_service.rb index 45edbfdcf926..9f36558b94be 100644 --- a/modules/auth_saml/app/services/saml/providers/create_service.rb +++ b/modules/auth_saml/app/services/saml/providers/create_service.rb @@ -29,6 +29,7 @@ module Saml module Providers class CreateService < BaseServices::Create + include UpdateMetadata end end end diff --git a/modules/auth_saml/app/services/saml/providers/update_metadata.rb b/modules/auth_saml/app/services/saml/providers/update_metadata.rb new file mode 100644 index 000000000000..6139150379a0 --- /dev/null +++ b/modules/auth_saml/app/services/saml/providers/update_metadata.rb @@ -0,0 +1,47 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml + module Providers + module UpdateMetadata + def after_validate(_params, call) + return call unless model.metadata_updated? + + metadata_update_call(call.result) + end + + private + + def metadata_update_call(provider) + Saml::UpdateMetadataService + .new(provider:, user:) + .call + end + end + end +end diff --git a/modules/auth_saml/app/services/saml/providers/update_service.rb b/modules/auth_saml/app/services/saml/providers/update_service.rb index 558bd3cb02a2..97f5fc835646 100644 --- a/modules/auth_saml/app/services/saml/providers/update_service.rb +++ b/modules/auth_saml/app/services/saml/providers/update_service.rb @@ -29,6 +29,7 @@ module Saml module Providers class UpdateService < BaseServices::Update + include UpdateMetadata end end end diff --git a/modules/auth_saml/app/services/saml/update_metadata_service.rb b/modules/auth_saml/app/services/saml/update_metadata_service.rb index a4416d117911..6f0976aef0b5 100644 --- a/modules/auth_saml/app/services/saml/update_metadata_service.rb +++ b/modules/auth_saml/app/services/saml/update_metadata_service.rb @@ -36,34 +36,40 @@ def initialize(user:, provider:) end def call - updated_metadata + apply_metadata(fetch_metadata) rescue StandardError => e OpenProject.logger.error(e) - ServiceResult.failure(message: I18n.t("saml.metadata_parser.error", error: e.class.name)) + ServiceResult.failure(result: provider, + message: I18n.t("saml.metadata_parser.error", error: e.class.name)) end private - def updated_metadata + def apply_metadata(metadata) + new_options = provider.options.merge(metadata) + last_metadata_update = metadata.blank? ? nil : Time.current + + Saml::Providers::SetAttributesService + .new(model: @provider, user: User.current, contract_class: Saml::Providers::UpdateContract) + .call({ options: new_options, last_metadata_update: }) + end + + def fetch_metadata if provider.metadata_url.present? parse_url elsif provider.metadata_xml.present? parse_xml else - ServiceResult.success(result: {}) + {} end end def parse_xml - result = parser_instance.parse_to_hash(provider.metadata_xml) - ServiceResult.success(result:) + parser_instance.parse_to_hash(provider.metadata_xml) end def parse_url - result = parser_instance.parse_remote_to_hash(provider.metadata_url) - ServiceResult.success(result:) - rescue OneLogin::RubySaml::HttpError => e - ServiceResult.failure(message: I18n.t("saml.metadata_parser.error", error: e.message)) + parser_instance.parse_remote_to_hash(provider.metadata_url) end def parser_instance diff --git a/modules/auth_saml/config/locales/en.yml b/modules/auth_saml/config/locales/en.yml index 6665fed787f2..26b56974d905 100644 --- a/modules/auth_saml/config/locales/en.yml +++ b/modules/auth_saml/config/locales/en.yml @@ -37,7 +37,7 @@ en: description: > Use these parameters to configure your identity provider connection to OpenProject. metadata_parser: - success: "Successfully updated the configuration using the identity provider metadata. Please review and save the configuration." + success: "Successfully updated the configuration using the identity provider metadata." invalid_url: "Provided metadata URL is invalid. Provide a HTTP(s) URL." error: "Failed to retrieve the identity provider metadata: %{error}" providers: @@ -76,7 +76,9 @@ en: display_name: "Configure the display name of the SAML provider." metadata: "Pre-fill configuration using a metadata URL or by pasting metadata XML" metadata_form: "If your identity provider has a metadata endpoint or XML download, add it below to pre-fill configuration." + metadata_form_banner: "Editing the metadata may override existing values in other sections. " configuration: "Configure the endpoint URLs for the identity provider, certificates, and further SAML options." + configuration_metadata: "This information has been pre-filled using the supplied metadata. In most cases, they do not require editing." encryption: "Configure assertion signatures and encryption for SAML requests and responses." encryption_form: "You may optionally want to encrypt the assertion response, or have requests from OpenProject signed." mapping: "Manually adjust the mapping between the SAML response and user attributes in OpenProject." From ad91efa908ed928d5854b021c7f556ad8eab6218 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 21 Aug 2024 10:48:02 +0200 Subject: [PATCH 53/80] Rename openid_providers -> sso_auth_providers --- app/services/authorization/enterprise_service.rb | 2 +- modules/auth_saml/app/controllers/saml/providers_controller.rb | 2 +- modules/auth_saml/lib/open_project/auth_saml/engine.rb | 2 +- modules/auth_saml/spec/requests/saml_provider_callback_spec.rb | 2 +- .../app/controllers/openid_connect/providers_controller.rb | 2 +- .../openid_connect/lib/open_project/openid_connect/engine.rb | 2 +- .../spec/controllers/providers_controller_spec.rb | 2 +- modules/openid_connect/spec/requests/openid_connect_spec.rb | 2 +- spec/requests/openid_google_provider_callback_spec.rb | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/services/authorization/enterprise_service.rb b/app/services/authorization/enterprise_service.rb index f07c857f0dae..b3acea653243 100644 --- a/app/services/authorization/enterprise_service.rb +++ b/app/services/authorization/enterprise_service.rb @@ -41,7 +41,7 @@ class Authorization::EnterpriseService grid_widget_wp_graph ldap_groups one_drive_sharepoint_file_storage - openid_providers + sso_auth_providers placeholder_users project_list_sharing readonly_work_packages diff --git a/modules/auth_saml/app/controllers/saml/providers_controller.rb b/modules/auth_saml/app/controllers/saml/providers_controller.rb index e49985cbceaf..c9caf20cd9ab 100644 --- a/modules/auth_saml/app/controllers/saml/providers_controller.rb +++ b/modules/auth_saml/app/controllers/saml/providers_controller.rb @@ -139,7 +139,7 @@ def successful_save_response end def check_ee - unless EnterpriseToken.allows_to?(:openid_providers) + unless EnterpriseToken.allows_to?(:sso_auth_providers) render template: "/saml/providers/upsale" false end diff --git a/modules/auth_saml/lib/open_project/auth_saml/engine.rb b/modules/auth_saml/lib/open_project/auth_saml/engine.rb index 766279aea31a..0e0786724825 100644 --- a/modules/auth_saml/lib/open_project/auth_saml/engine.rb +++ b/modules/auth_saml/lib/open_project/auth_saml/engine.rb @@ -65,7 +65,7 @@ class Engine < ::Rails::Engine :saml_providers_path, parent: :authentication, caption: ->(*) { I18n.t("saml.menu_title") }, - enterprise_feature: "openid_providers" + enterprise_feature: "sso_auth_providers" end assets %w( diff --git a/modules/auth_saml/spec/requests/saml_provider_callback_spec.rb b/modules/auth_saml/spec/requests/saml_provider_callback_spec.rb index a78a8a3a05c6..fdf296962b5c 100644 --- a/modules/auth_saml/spec/requests/saml_provider_callback_spec.rb +++ b/modules/auth_saml/spec/requests/saml_provider_callback_spec.rb @@ -31,7 +31,7 @@ RSpec.describe "SAML provider callback", type: :rails_request, - with_ee: %i[openid_providers] do + with_ee: %i[sso_auth_providers] do include Rack::Test::Methods include API::V3::Utilities::PathHelper diff --git a/modules/openid_connect/app/controllers/openid_connect/providers_controller.rb b/modules/openid_connect/app/controllers/openid_connect/providers_controller.rb index dbf3136d1173..ac436d6418fe 100644 --- a/modules/openid_connect/app/controllers/openid_connect/providers_controller.rb +++ b/modules/openid_connect/app/controllers/openid_connect/providers_controller.rb @@ -55,7 +55,7 @@ def destroy private def check_ee - unless EnterpriseToken.allows_to?(:openid_providers) + unless EnterpriseToken.allows_to?(:sso_auth_providers) render template: "/openid_connect/providers/upsale" false end diff --git a/modules/openid_connect/lib/open_project/openid_connect/engine.rb b/modules/openid_connect/lib/open_project/openid_connect/engine.rb index 12a2cb99bfb4..345651d2967e 100644 --- a/modules/openid_connect/lib/open_project/openid_connect/engine.rb +++ b/modules/openid_connect/lib/open_project/openid_connect/engine.rb @@ -16,7 +16,7 @@ class Engine < ::Rails::Engine :openid_connect_providers_path, parent: :authentication, caption: ->(*) { I18n.t("openid_connect.menu_title") }, - enterprise_feature: "openid_providers" + enterprise_feature: "sso_auth_providers" end assets %w( diff --git a/modules/openid_connect/spec/controllers/providers_controller_spec.rb b/modules/openid_connect/spec/controllers/providers_controller_spec.rb index 8d1870f35eb4..493b4d50d543 100644 --- a/modules/openid_connect/spec/controllers/providers_controller_spec.rb +++ b/modules/openid_connect/spec/controllers/providers_controller_spec.rb @@ -51,7 +51,7 @@ end end - context "with an EE token", with_ee: %i[openid_providers] do + context "with an EE token", with_ee: %i[sso_auth_providers] do before do login_as user end diff --git a/modules/openid_connect/spec/requests/openid_connect_spec.rb b/modules/openid_connect/spec/requests/openid_connect_spec.rb index c0405e226ac8..808902e6aad0 100644 --- a/modules/openid_connect/spec/requests/openid_connect_spec.rb +++ b/modules/openid_connect/spec/requests/openid_connect_spec.rb @@ -35,7 +35,7 @@ RSpec.describe "OpenID Connect", :skip_2fa_stage, # Prevent redirects to 2FA stage type: :rails_request, - with_ee: %i[openid_providers] do + with_ee: %i[sso_auth_providers] do let(:host) { OmniAuth::OpenIDConnect::Heroku.new("foo", {}).host } let(:user_info) do { diff --git a/spec/requests/openid_google_provider_callback_spec.rb b/spec/requests/openid_google_provider_callback_spec.rb index 9dc3e213ae57..5f86a8ac23dc 100644 --- a/spec/requests/openid_google_provider_callback_spec.rb +++ b/spec/requests/openid_google_provider_callback_spec.rb @@ -29,7 +29,7 @@ require "spec_helper" require "rack/test" -RSpec.describe "OpenID Google provider callback", with_ee: %i[openid_providers] do +RSpec.describe "OpenID Google provider callback", with_ee: %i[sso_auth_providers] do include Rack::Test::Methods include API::V3::Utilities::PathHelper From 9fe5bc908264079cacd92bc0a8cdc825fe3457de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 21 Aug 2024 11:17:09 +0200 Subject: [PATCH 54/80] Load deprecated settings in seeder --- .../seeders/env_data/saml/provider_seeder.rb | 34 +++++++++++++++++-- .../saml/providers/update_metadata.rb | 3 +- .../env_data/saml/provider_seeder_spec.rb | 2 +- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/modules/auth_saml/app/seeders/env_data/saml/provider_seeder.rb b/modules/auth_saml/app/seeders/env_data/saml/provider_seeder.rb index 937a37477575..29f9926c6c95 100644 --- a/modules/auth_saml/app/seeders/env_data/saml/provider_seeder.rb +++ b/modules/auth_saml/app/seeders/env_data/saml/provider_seeder.rb @@ -29,7 +29,7 @@ module EnvData module Saml class ProviderSeeder < Seeder def seed_data! - Setting.seed_saml_provider.each do |name, options| + provider_configuration.each do |name, options| print_status " ↳ Creating or Updating SAML provider #{name}" do provider = ::Saml::Provider.find_by(slug: name) params = ::Saml::ConfigurationMapper.new(options).call! @@ -47,11 +47,41 @@ def seed_data! end def applicable? - Setting.seed_saml_provider.present? + provider_configuration.present? + end + + def provider_configuration + config = Setting.seed_saml_provider + deprecated_config = load_deprecated_configuration.presence || {} + + config.reverse_merge(deprecated_config) end private + def load_deprecated_configuration + deprecated_settings = Rails.root.join("config/plugins/auth_saml/settings.yml") + + if deprecated_settings.exist? + Rails.logger.info do + <<~WARNING + Loading SAML configuration from deprecated location #{deprecated_path}. + Please use ENV variables or UI configuration instead. + + For more information, see our guide on how to configure SAML. + https://www.openproject.org/docs/system-admin-guide/authentication/saml/ + WARNING + end + + begin + YAML::load(File.open(deprecated_settings))&.symbolize_keys + rescue StandardError + Rails.logger.error "Failed to load deprecated SAML configuration from #{deprecated_settings}. Ignoring that file." + nil + end + end + end + def create(name, params) ::Saml::Providers::CreateService .new(user: User.system) diff --git a/modules/auth_saml/app/services/saml/providers/update_metadata.rb b/modules/auth_saml/app/services/saml/providers/update_metadata.rb index 6139150379a0..8b24d003d494 100644 --- a/modules/auth_saml/app/services/saml/providers/update_metadata.rb +++ b/modules/auth_saml/app/services/saml/providers/update_metadata.rb @@ -30,7 +30,8 @@ module Saml module Providers module UpdateMetadata def after_validate(_params, call) - return call unless model.metadata_updated? + model = call.result + return call unless model&.metadata_updated? metadata_update_call(call.result) end diff --git a/modules/auth_saml/spec/seeders/env_data/saml/provider_seeder_spec.rb b/modules/auth_saml/spec/seeders/env_data/saml/provider_seeder_spec.rb index 42e039df5de0..57b10da5fe02 100644 --- a/modules/auth_saml/spec/seeders/env_data/saml/provider_seeder_spec.rb +++ b/modules/auth_saml/spec/seeders/env_data/saml/provider_seeder_spec.rb @@ -40,7 +40,7 @@ description: "Provide a SAML provider and sync its settings through ENV", env_alias: "OPENPROJECT_SAML", writable: false, - default: nil, + default: {}, format: :hash) end From 98b0f4e4b88f42b7bf292a644573b7a5ecc9e39d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 21 Aug 2024 12:11:55 +0200 Subject: [PATCH 55/80] Metadata dialog --- .../metadata_dialog_component.html.erb | 40 +++ .../providers/metadata_dialog_component.rb | 15 + .../sections/show_component.html.erb | 6 + .../saml/providers/sections/show_component.rb | 3 +- .../saml/providers/view_component.html.erb | 267 +++++++++--------- .../controllers/saml/providers_controller.rb | 9 +- modules/auth_saml/app/models/saml/provider.rb | 4 + modules/auth_saml/config/locales/en.yml | 3 + modules/auth_saml/config/routes.rb | 1 + 9 files changed, 219 insertions(+), 129 deletions(-) create mode 100644 modules/auth_saml/app/components/saml/providers/metadata_dialog_component.html.erb create mode 100644 modules/auth_saml/app/components/saml/providers/metadata_dialog_component.rb diff --git a/modules/auth_saml/app/components/saml/providers/metadata_dialog_component.html.erb b/modules/auth_saml/app/components/saml/providers/metadata_dialog_component.html.erb new file mode 100644 index 000000000000..68b1fe14c033 --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/metadata_dialog_component.html.erb @@ -0,0 +1,40 @@ +<%= + render(Primer::Alpha::Dialog.new( + id:, + title: t("saml.providers.label_metadata_endpoint"), + size: :large + )) do |d| + d.with_header + d.with_body do + flex_layout do |flex| + flex.with_row(mb: 4) do + t("saml.providers.metadata.dialog") + end + + flex.with_row do + render(Primer::OpenProject::InputGroup.new) do |input_group| + input_group.with_text_input(name: :metadata_endpoint, + label: t("saml.providers.label_metadata_endpoint"), + visually_hide_label: true, + value: provider.metadata_endpoint) + input_group.with_trailing_action_clipboard_copy_button( + value: provider.metadata_endpoint, + aria: { + label: I18n.t('button_copy_to_clipboard') + }) + end + end + + flex.with_row(justify_items: :flex_end) do + + end + end + end + + d.with_footer do + render(Primer::ButtonComponent.new(data: { 'close-dialog-id': id })) do + I18n.t("button_close") + end + end + end +%> diff --git a/modules/auth_saml/app/components/saml/providers/metadata_dialog_component.rb b/modules/auth_saml/app/components/saml/providers/metadata_dialog_component.rb new file mode 100644 index 000000000000..016e9a5a7558 --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/metadata_dialog_component.rb @@ -0,0 +1,15 @@ +module Saml + module Providers + class MetadataDialogComponent < ApplicationComponent + include ApplicationHelper + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + alias_method :provider, :model + + def id + "saml-metadata-dialog" + end + end + end +end diff --git a/modules/auth_saml/app/components/saml/providers/sections/show_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/show_component.html.erb index a7f95321cb5d..ceb8ae5e22fe 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/show_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/sections/show_component.html.erb @@ -17,6 +17,12 @@ if show_edit? grid.with_area(:action) do flex_layout(justify_content: :flex_end) do |icons_container| + if @action + icons_container.with_column do + render(@action) + end + end + icons_container.with_column do render( Primer::Beta::IconButton.new( diff --git a/modules/auth_saml/app/components/saml/providers/sections/show_component.rb b/modules/auth_saml/app/components/saml/providers/sections/show_component.rb index e87e322cd3d3..c6fdfa055e79 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/show_component.rb +++ b/modules/auth_saml/app/components/saml/providers/sections/show_component.rb @@ -31,7 +31,7 @@ module Saml::Providers::Sections class ShowComponent < SectionComponent def initialize(provider, view_mode:, target_state:, - heading:, description:, label: nil, label_scheme: :attention) + heading:, description:, action: nil, label: nil, label_scheme: :attention) super(provider) @target_state = target_state @@ -40,6 +40,7 @@ def initialize(provider, view_mode:, target_state:, @description = description @label = label @label_scheme = label_scheme + @action = action end def show_edit? diff --git a/modules/auth_saml/app/components/saml/providers/view_component.html.erb b/modules/auth_saml/app/components/saml/providers/view_component.html.erb index 931e2dfa3a0b..0e2c86ad2db3 100644 --- a/modules/auth_saml/app/components/saml/providers/view_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/view_component.html.erb @@ -7,146 +7,159 @@ %> <% end %> -<%= render(border_box_container) do |component| - component.with_header(color: :muted) do - render(Primer::Beta::Text.new(font_weight: :semibold)) { I18n.t("activemodel.attributes.saml/provider.display_name") } - end + <%= render(border_box_container) do |component| + component.with_header(color: :muted) do + render(Primer::Beta::Text.new(font_weight: :semibold)) { I18n.t("activemodel.attributes.saml/provider.display_name") } + end - component.with_row(scheme: :default) do - if edit_state == :name - render(Saml::Providers::Sections::FormComponent.new( - provider, - form_class: Saml::Providers::NameInputForm, - edit_state:, - next_edit_state: :metadata, - edit_mode:, - heading: nil - )) - else - render(Saml::Providers::Sections::ShowComponent.new( - provider, - view_mode:, - target_state: :name, - heading: t("saml.providers.singular"), - description: t("saml.providers.section_texts.display_name") - )) + component.with_row(scheme: :default) do + if edit_state == :name + render(Saml::Providers::Sections::FormComponent.new( + provider, + form_class: Saml::Providers::NameInputForm, + edit_state:, + next_edit_state: :metadata, + edit_mode:, + heading: nil + )) + else + render(Saml::Providers::Sections::ShowComponent.new( + provider, + view_mode:, + target_state: :name, + heading: t("saml.providers.singular"), + description: t("saml.providers.section_texts.display_name") + )) + end end - end - component.with_row(scheme: :neutral, color: :muted) do - render(Primer::Beta::Text.new(font_weight: :semibold)) { I18n.t('saml.providers.label_automatic_configuration') } - end + component.with_row(scheme: :neutral, color: :muted) do + render(Primer::Beta::Text.new(font_weight: :semibold)) { I18n.t('saml.providers.label_automatic_configuration') } + end + + component.with_row(scheme: :default) do + if edit_state == :metadata + render(Saml::Providers::Sections::MetadataFormComponent.new( + provider, + edit_mode:, + )) + else + dialog_button = + if provider.persisted? + Primer::Beta::Button.new( + tag: :a, + href: show_metadata_saml_provider_path(provider), + scheme: :invisible, + color: :subtle, + 'aria-label': t('saml.providers.label_show_metadata'), + data: { turbo: true, 'turbo-stream': true } + ).with_content(t('saml.providers.label_show_metadata')) + end - component.with_row(scheme: :default) do - if edit_state == :metadata - render(Saml::Providers::Sections::MetadataFormComponent.new( - provider, - edit_mode:, - )) - else - render(Saml::Providers::Sections::ShowComponent.new( - provider, - target_state: :metadata, - view_mode:, - heading: t("saml.providers.label_metadata"), - description: t("saml.providers.section_texts.metadata"), - label: provider.has_metadata? ? t(:label_completed) : t(:label_not_configured), - label_scheme: provider.has_metadata? ? :success : :secondary - )) + render(Saml::Providers::Sections::ShowComponent.new( + provider, + target_state: :metadata, + view_mode:, + action: dialog_button, + heading: t("saml.providers.label_metadata"), + description: t("saml.providers.section_texts.metadata"), + label: provider.has_metadata? ? t(:label_completed) : t(:label_not_configured), + label_scheme: provider.has_metadata? ? :success : :secondary + )) + end end - end - component.with_row(scheme: :neutral, color: :muted) do - render(Primer::Beta::Text.new(font_weight: :semibold)) { I18n.t('saml.providers.section_headers.configuration') } - end + component.with_row(scheme: :neutral, color: :muted) do + render(Primer::Beta::Text.new(font_weight: :semibold)) { I18n.t('saml.providers.section_headers.configuration') } + end - component.with_row(scheme: :default) do - if edit_state == :configuration - render(Saml::Providers::Sections::FormComponent.new( - provider, - form_class: Saml::Providers::ConfigurationForm, - edit_state:, - next_edit_state: :encryption, - edit_mode:, - banner: provider.last_metadata_update ? t("saml.providers.section_texts.configuration_metadata") : nil, - banner_scheme: :default, - heading: nil - )) - else - render(Saml::Providers::Sections::ShowComponent.new( - provider, - target_state: :configuration, - view_mode:, - heading: t("saml.providers.label_configuration_details"), - description: t("saml.providers.section_texts.configuration"), - label: (provider.persisted? && !provider.configured?) ? t(:label_incomplete) : nil, - )) + component.with_row(scheme: :default) do + if edit_state == :configuration + render(Saml::Providers::Sections::FormComponent.new( + provider, + form_class: Saml::Providers::ConfigurationForm, + edit_state:, + next_edit_state: :encryption, + edit_mode:, + banner: provider.last_metadata_update ? t("saml.providers.section_texts.configuration_metadata") : nil, + banner_scheme: :default, + heading: nil + )) + else + render(Saml::Providers::Sections::ShowComponent.new( + provider, + target_state: :configuration, + view_mode:, + heading: t("saml.providers.label_configuration_details"), + description: t("saml.providers.section_texts.configuration"), + label: (provider.persisted? && !provider.configured?) ? t(:label_incomplete) : nil, + )) + end end - end - component.with_row(scheme: :default) do - if edit_state == :encryption - render(Saml::Providers::Sections::FormComponent.new( - provider, - form_class: Saml::Providers::EncryptionForm, - edit_state:, - next_edit_state: :mapping, - edit_mode:, - heading: t("saml.providers.section_texts.encryption_form") - )) - else - render(Saml::Providers::Sections::ShowComponent.new( - provider, - target_state: :encryption, - view_mode:, - heading: t("saml.providers.label_configuration_encryption"), - description: t("saml.providers.section_texts.encryption") - )) + component.with_row(scheme: :default) do + if edit_state == :encryption + render(Saml::Providers::Sections::FormComponent.new( + provider, + form_class: Saml::Providers::EncryptionForm, + edit_state:, + next_edit_state: :mapping, + edit_mode:, + heading: t("saml.providers.section_texts.encryption_form") + )) + else + render(Saml::Providers::Sections::ShowComponent.new( + provider, + target_state: :encryption, + view_mode:, + heading: t("saml.providers.label_configuration_encryption"), + description: t("saml.providers.section_texts.encryption") + )) + end end - end - component.with_row(scheme: :neutral, color: :muted) do - render(Primer::Beta::Text.new(font_weight: :semibold)) { I18n.t('saml.providers.section_headers.attributes') } - end + component.with_row(scheme: :neutral, color: :muted) do + render(Primer::Beta::Text.new(font_weight: :semibold)) { I18n.t('saml.providers.section_headers.attributes') } + end - component.with_row(scheme: :default) do - if edit_state == :mapping - render(Saml::Providers::Sections::FormComponent.new( - provider, - form_class: Saml::Providers::MappingForm, - edit_state:, - next_edit_state: :requested_attributes, - edit_mode:, - heading: t("saml.instructions.mapping") - )) - else - render(Saml::Providers::Sections::ShowComponent.new( - provider, - target_state: :mapping, - view_mode:, - heading: t("saml.providers.label_mapping"), - description: t("saml.providers.section_texts.mapping"), - label: provider.mapping_configured? ? t(:label_completed) : t(:label_incomplete), - label_scheme: provider.mapping_configured? ? :success : :attention - )) + component.with_row(scheme: :default) do + if edit_state == :mapping + render(Saml::Providers::Sections::FormComponent.new( + provider, + form_class: Saml::Providers::MappingForm, + edit_state:, + next_edit_state: :requested_attributes, + edit_mode:, + heading: t("saml.instructions.mapping") + )) + else + render(Saml::Providers::Sections::ShowComponent.new( + provider, + target_state: :mapping, + view_mode:, + heading: t("saml.providers.label_mapping"), + description: t("saml.providers.section_texts.mapping"), + label: provider.mapping_configured? ? t(:label_completed) : t(:label_incomplete), + label_scheme: provider.mapping_configured? ? :success : :attention + )) + end end - end - component.with_row(scheme: :default) do - if edit_state == :requested_attributes - render(Saml::Providers::Sections::RequestAttributesFormComponent.new( - provider, - edit_mode: - )) - else - render(Saml::Providers::Sections::ShowComponent.new( - provider, - target_state: :requested_attributes, - view_mode:, - heading: t("saml.providers.requested_attributes"), - description: t("saml.providers.section_texts.requested_attributes") - )) + component.with_row(scheme: :default) do + if edit_state == :requested_attributes + render(Saml::Providers::Sections::RequestAttributesFormComponent.new( + provider, + edit_mode: + )) + else + render(Saml::Providers::Sections::ShowComponent.new( + provider, + target_state: :requested_attributes, + view_mode:, + heading: t("saml.providers.requested_attributes"), + description: t("saml.providers.section_texts.requested_attributes") + )) + end end end -end -%> + %> <% end %> diff --git a/modules/auth_saml/app/controllers/saml/providers_controller.rb b/modules/auth_saml/app/controllers/saml/providers_controller.rb index c9caf20cd9ab..e3762e79791a 100644 --- a/modules/auth_saml/app/controllers/saml/providers_controller.rb +++ b/modules/auth_saml/app/controllers/saml/providers_controller.rb @@ -1,13 +1,14 @@ module Saml class ProvidersController < ::ApplicationController include OpTurbo::ComponentStream + include OpTurbo::DialogStreamHelper layout "admin" menu_item :plugin_saml before_action :require_admin before_action :check_ee - before_action :find_provider, only: %i[show edit import_metadata update destroy] + before_action :find_provider, only: %i[show edit show_metadata import_metadata update destroy] before_action :check_provider_writable, only: %i[update import_metadata] before_action :set_edit_state, only: %i[create edit update import_metadata] @@ -67,6 +68,12 @@ def import_metadata end end + def show_metadata + respond_with_dialog( + Saml::Providers::MetadataDialogComponent.new(@provider) + ) + end + def create call = ::Saml::Providers::CreateService .new(user: User.current) diff --git a/modules/auth_saml/app/models/saml/provider.rb b/modules/auth_saml/app/models/saml/provider.rb index 7747e7cee62b..704fc6935f94 100644 --- a/modules/auth_saml/app/models/saml/provider.rb +++ b/modules/auth_saml/app/models/saml/provider.rb @@ -60,6 +60,10 @@ def metadata_updated? metadata_xml_changed? || metadata_url_changed? end + def metadata_endpoint + URI.join(auth_url, "metadata").to_s + end + def configured? sp_entity_id.present? && idp_sso_service_url.present? && diff --git a/modules/auth_saml/config/locales/en.yml b/modules/auth_saml/config/locales/en.yml index 26b56974d905..7a61e57311a0 100644 --- a/modules/auth_saml/config/locales/en.yml +++ b/modules/auth_saml/config/locales/en.yml @@ -45,6 +45,7 @@ en: label_empty_description: "Add a provider to see them here." label_automatic_configuration: Automatic configuration label_metadata: Metadata + label_show_metadata: Show OpenProject metadata label_metadata_endpoint: Metadata endpoint label_configuration_details: "Identity provider endpoints and certificates" label_configuration_encryption: "Signatures and Encryption" @@ -62,6 +63,8 @@ en: attribute_mapping_text: > The following fields control which attributes provided by the SAML identity provider are used to provide user attributes in OpenProject + metadata: + dialog: "This is the URL where the OpenProject SAML metadata is available. Optionally use it to configure your identity provider:" upsale: description: Connect OpenProject to a SAML identity provider request_attributes: diff --git a/modules/auth_saml/config/routes.rb b/modules/auth_saml/config/routes.rb index 926f2427ae98..6af0d120b4e4 100644 --- a/modules/auth_saml/config/routes.rb +++ b/modules/auth_saml/config/routes.rb @@ -4,6 +4,7 @@ resources :providers do member do post :import_metadata + get :show_metadata end end end From 390c6649e53d9185952d3298d2190d7a5f270319 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 21 Aug 2024 12:13:13 +0200 Subject: [PATCH 56/80] Use data-turbo=false for cancel link This ensures we update the URL in case we're editing --- .../auth_saml/app/forms/saml/providers/submit_or_cancel_form.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/auth_saml/app/forms/saml/providers/submit_or_cancel_form.rb b/modules/auth_saml/app/forms/saml/providers/submit_or_cancel_form.rb index 40f55fe34a6c..b7abab6cd845 100644 --- a/modules/auth_saml/app/forms/saml/providers/submit_or_cancel_form.rb +++ b/modules/auth_saml/app/forms/saml/providers/submit_or_cancel_form.rb @@ -69,7 +69,7 @@ def default_cancel_button_options scheme: :default, tag: :a, href: back_link, - data: { turbo: true, turbo_stream: !@provider.new_record? }, + data: { turbo: false }, label: I18n.t("button_cancel") } end From 93dd5524f816dbce42a87310104f01e7f0988062 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 21 Aug 2024 14:01:46 +0200 Subject: [PATCH 57/80] Remove config option --- .../lib/open_project/auth_saml/engine.rb | 30 +------------------ 1 file changed, 1 insertion(+), 29 deletions(-) diff --git a/modules/auth_saml/lib/open_project/auth_saml/engine.rb b/modules/auth_saml/lib/open_project/auth_saml/engine.rb index 0e0786724825..1c0ed1825393 100644 --- a/modules/auth_saml/lib/open_project/auth_saml/engine.rb +++ b/modules/auth_saml/lib/open_project/auth_saml/engine.rb @@ -3,23 +3,11 @@ module OpenProject module AuthSaml def self.configuration RequestStore.fetch(:openproject_omniauth_saml_provider) do - global_configuration - .deep_merge(settings_from_db) + settings_from_db .deep_merge(settings_from_providers) end end - def self.reload_configuration! - @global_configuration = nil - RequestStore.delete :openproject_omniauth_saml_provider - end - - ## - # Loads the settings once to avoid accessing the file in each request - def self.global_configuration - @global_configuration ||= Hash(settings_from_config || settings_from_yaml).with_indifferent_access - end - def self.settings_from_providers Saml::Provider .where(available: true) @@ -34,22 +22,6 @@ def self.settings_from_db value.is_a?(Hash) ? value : {} end - def self.settings_from_config - if OpenProject::Configuration["saml"].present? - Rails.logger.info("[auth_saml] Registering saml integration from configuration.yml") - - OpenProject::Configuration["saml"] - end - end - - def self.settings_from_yaml - if (settings = Rails.root.join("config/plugins/auth_saml/settings.yml")).exist? - Rails.logger.info("[auth_saml] Registering saml integration from settings file") - - YAML::load(File.open(settings)).symbolize_keys - end - end - class Engine < ::Rails::Engine engine_name :openproject_auth_saml From 4ce3c933b6948c4e23b560542886ff4a2478d980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 21 Aug 2024 14:50:12 +0200 Subject: [PATCH 58/80] Create migration to sync settings to SAML provider --- app/services/service_result.rb | 1 + .../seeders/env_data/saml/provider_seeder.rb | 28 ++------ .../app/services/saml/sync_service.rb | 69 +++++++++++++++++++ ...1856_migrate_saml_settings_to_providers.rb | 43 ++++++++++++ .../lib/open_project/auth_saml/engine.rb | 21 ++---- 5 files changed, 122 insertions(+), 40 deletions(-) create mode 100644 modules/auth_saml/app/services/saml/sync_service.rb create mode 100644 modules/auth_saml/db/migrate/20240821121856_migrate_saml_settings_to_providers.rb diff --git a/app/services/service_result.rb b/app/services/service_result.rb index 36b35cc7943f..950a140f5d49 100644 --- a/app/services/service_result.rb +++ b/app/services/service_result.rb @@ -35,6 +35,7 @@ class ServiceResult attr_accessor :success, :result, :errors, + :message, :dependent_results attr_writer :state diff --git a/modules/auth_saml/app/seeders/env_data/saml/provider_seeder.rb b/modules/auth_saml/app/seeders/env_data/saml/provider_seeder.rb index 29f9926c6c95..4c68fdad45ea 100644 --- a/modules/auth_saml/app/seeders/env_data/saml/provider_seeder.rb +++ b/modules/auth_saml/app/seeders/env_data/saml/provider_seeder.rb @@ -31,16 +31,12 @@ class ProviderSeeder < Seeder def seed_data! provider_configuration.each do |name, options| print_status " ↳ Creating or Updating SAML provider #{name}" do - provider = ::Saml::Provider.find_by(slug: name) - params = ::Saml::ConfigurationMapper.new(options).call! - params["options"]["seeded_from_env"] = true + call = ::Saml::SyncService.new(name, options).call - if provider - print_status " - Updating existing SAML provider '#{name}' from ENV" - update(name, provider, params) + if call.success + print_status " - #{call.message}" else - print_status " - Creating new SAML provider '#{name}' from ENV" - create(name, params) + raise call.message end end end @@ -81,22 +77,6 @@ def load_deprecated_configuration end end end - - def create(name, params) - ::Saml::Providers::CreateService - .new(user: User.system) - .call(params) - .on_success { print_status " - Successfully saved SAML provider #{name}." } - .on_failure { |call| raise "Failed to create SAML provider: #{call.message}" } - end - - def update(name, provider, params) - ::Saml::Providers::UpdateService - .new(model: provider, user: User.system) - .call(params) - .on_success { print_status " - Successfully updated SAML provider #{name}." } - .on_failure { |call| raise "Failed to update SAML provider: #{call.message}" } - end end end end diff --git a/modules/auth_saml/app/services/saml/sync_service.rb b/modules/auth_saml/app/services/saml/sync_service.rb new file mode 100644 index 000000000000..ed7217fb8d6c --- /dev/null +++ b/modules/auth_saml/app/services/saml/sync_service.rb @@ -0,0 +1,69 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml + ## + # Synchronize a configuration from ENV or legacy settings to a SAML provider record + class SyncService + attr_reader :name, :configuration + + def initialize(name, configuration) + @name = name + @configuration = configuration + end + + def call + params = ::Saml::ConfigurationMapper.new(configuration).call! + provider = ::Saml::Provider.find_by(slug: name) + + if provider + update(name, provider, params) + else + create(name, params) + end + end + + private + + def create(name, params) + ::Saml::Providers::CreateService + .new(user: User.system) + .call(params) + .on_success { |call| call.message = "Successfully saved SAML provider #{name}." } + .on_failure { |call| call.message = "Failed to create SAML provider: #{call.message}" } + end + + def update(name, provider, params) + ::Saml::Providers::UpdateService + .new(model: provider, user: User.system) + .call(params) + .on_success { |call| call.message = "Successfully updated SAML provider #{name}." } + .on_failure { |call| call.message = "Failed to update SAML provider: #{call.message}" } + end + end +end diff --git a/modules/auth_saml/db/migrate/20240821121856_migrate_saml_settings_to_providers.rb b/modules/auth_saml/db/migrate/20240821121856_migrate_saml_settings_to_providers.rb new file mode 100644 index 000000000000..ae82aba4de29 --- /dev/null +++ b/modules/auth_saml/db/migrate/20240821121856_migrate_saml_settings_to_providers.rb @@ -0,0 +1,43 @@ +class MigrateSamlSettingsToProviders < ActiveRecord::Migration[7.1] + def up + providers = Hash(Setting.plugin_openproject_auth_saml).with_indifferent_access[:providers] + return if providers.blank? + + providers.each do |name, options| + migrate_provider!(name, options) + end + end + + def down + # This migration does not yet remove Setting.plugin_openproject_auth_saml + # so it can be retried. + end + + private + + def migrate_provider!(name, options) + puts "Trying to migrate SAML provider #{name} from previous settings format..." + call = ::Saml::SyncService.new(name, options).call + + if call.success + puts <<~SUCCESS + Successfully migrated SAML provider #{name} from previous settings format. + You can now manage this provider in the new administrative UI within OpenProject under + the "Administration -> Authentication -> SAML providers" section. + SUCCESS + else + raise <<~ERROR + Failed to create or update SAML provider #{name} from previous settings format. + The error message was: #{call.message} + + Please check the logs for more information and open a bug report in our community: + https://www.openproject.org/docs/development/report-a-bug/ + + If you would like to skip migrating the SAML setting and discard them instead, you can use our documentation + to unset any previous SAML settings: + + https://www.openproject.org/docs/system-admin-guide/authentication/saml/ + ERROR + end + end +end diff --git a/modules/auth_saml/lib/open_project/auth_saml/engine.rb b/modules/auth_saml/lib/open_project/auth_saml/engine.rb index 1c0ed1825393..5477eb21da97 100644 --- a/modules/auth_saml/lib/open_project/auth_saml/engine.rb +++ b/modules/auth_saml/lib/open_project/auth_saml/engine.rb @@ -2,26 +2,15 @@ module OpenProject module AuthSaml def self.configuration - RequestStore.fetch(:openproject_omniauth_saml_provider) do - settings_from_db - .deep_merge(settings_from_providers) - end - end + providers = Saml::Provider.where(available: true) - def self.settings_from_providers - Saml::Provider - .where(available: true) - .each_with_object({}) do |provider, hash| - hash[provider.slug] = provider.to_h + OpenProject::Cache.fetch(providers.cache_key) do + providers.each_with_object({}) do |provider, hash| + hash[provider.slug] = provider.to_h + end end end - def self.settings_from_db - value = Hash(Setting.plugin_openproject_auth_saml).with_indifferent_access[:providers] - - value.is_a?(Hash) ? value : {} - end - class Engine < ::Rails::Engine engine_name :openproject_auth_saml From 7760c4fea55f79087aa25556115d9774ed7b56e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 21 Aug 2024 14:50:23 +0200 Subject: [PATCH 59/80] Fix validity checking of certs with idp_cert_multi --- .../app/contracts/saml/providers/base_contract.rb | 11 ++++------- modules/auth_saml/app/models/saml/provider.rb | 8 ++++++-- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/modules/auth_saml/app/contracts/saml/providers/base_contract.rb b/modules/auth_saml/app/contracts/saml/providers/base_contract.rb index f0169df9e8f1..3450597c6f1c 100644 --- a/modules/auth_saml/app/contracts/saml/providers/base_contract.rb +++ b/modules/auth_saml/app/contracts/saml/providers/base_contract.rb @@ -55,7 +55,7 @@ def self.model attribute :idp_cert validates_presence_of :idp_cert, if: -> { model.idp_cert_changed? } - validate :idp_cert_is_valid, + validate :idp_cert_not_expired, if: -> { model.idp_cert_changed? && model.idp_cert.present? } attribute :authn_requests_signed @@ -66,12 +66,9 @@ def self.model validates_presence_of attr, if: -> { model.public_send(:"#{attr}_changed?") } end - def idp_cert_is_valid - model.loaded_idp_certificates.each do |cert| - if OneLogin::RubySaml::Utils.is_cert_expired(cert) - errors.add :certificate, :certificate_expired - break - end + def idp_cert_not_expired + unless model.idp_certificate_expired? + errors.add :certificate, :certificate_expired end rescue OpenSSL::X509::CertificateError => e errors.add :idp_cert, :invalid_certificate, additional_message: e.message diff --git a/modules/auth_saml/app/models/saml/provider.rb b/modules/auth_saml/app/models/saml/provider.rb index 704fc6935f94..7b156dbbf8da 100644 --- a/modules/auth_saml/app/models/saml/provider.rb +++ b/modules/auth_saml/app/models/saml/provider.rb @@ -67,7 +67,7 @@ def metadata_endpoint def configured? sp_entity_id.present? && idp_sso_service_url.present? && - certificate_configured? + idp_certificate_configured? end def mapping_configured? @@ -95,10 +95,14 @@ def loaded_idp_certificates @loaded_idp_certificates ||= OpenSSL::X509::Certificate.load(idp_cert) end - def certificate_configured? + def idp_certificate_configured? idp_cert.present? end + def idp_certificate_expired? + !loaded_idp_certificates.all? { |cert| OneLogin::RubySaml::Utils.is_cert_expired(cert) } + end + def idp_cert=(cert) formatted = if cert.include?("BEGIN CERTIFICATE") From 61ce42752c2668b694209be7ea0dcd5d0593afab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 21 Aug 2024 15:05:30 +0200 Subject: [PATCH 60/80] Check seed_from_env manually This allows you to unset ENVs after migrating, and updating in UI afterwards --- modules/auth_saml/app/models/saml/provider.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/modules/auth_saml/app/models/saml/provider.rb b/modules/auth_saml/app/models/saml/provider.rb index 7b156dbbf8da..6e081d79e835 100644 --- a/modules/auth_saml/app/models/saml/provider.rb +++ b/modules/auth_saml/app/models/saml/provider.rb @@ -4,7 +4,6 @@ class Provider < AuthProvider store_attribute :options, :icon, :string store_attribute :options, :sp_entity_id, :string - store_attribute :options, :seeded_from_env, :boolean store_attribute :options, :name_identifier_format, :string store_attribute :options, :metadata_url, :string store_attribute :options, :metadata_xml, :string @@ -49,7 +48,7 @@ class Provider < AuthProvider def self.slug_fragment = "saml" def seeded_from_env? - seeded_from_env == true + (Setting.seed_saml_provider || {}).key?(slug) end def has_metadata? From f55f2a004787a94153c8710034f884a4eaf6beac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 21 Aug 2024 15:22:00 +0200 Subject: [PATCH 61/80] Contract specs --- .../saml/providers/create_contract_spec.rb | 49 ++++++++++++++++++ .../saml/providers/delete_contract_spec.rb | 51 +++++++++++++++++++ .../saml/providers/update_contract_spec.rb | 49 ++++++++++++++++++ .../spec/factories/saml_provider_factory.rb | 33 ++++++++++++ 4 files changed, 182 insertions(+) create mode 100644 modules/auth_saml/spec/contracts/saml/providers/create_contract_spec.rb create mode 100644 modules/auth_saml/spec/contracts/saml/providers/delete_contract_spec.rb create mode 100644 modules/auth_saml/spec/contracts/saml/providers/update_contract_spec.rb create mode 100644 modules/auth_saml/spec/factories/saml_provider_factory.rb diff --git a/modules/auth_saml/spec/contracts/saml/providers/create_contract_spec.rb b/modules/auth_saml/spec/contracts/saml/providers/create_contract_spec.rb new file mode 100644 index 000000000000..81f4b0a13294 --- /dev/null +++ b/modules/auth_saml/spec/contracts/saml/providers/create_contract_spec.rb @@ -0,0 +1,49 @@ +#-- 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. +#++ + +require "spec_helper" +require "contracts/shared/model_contract_shared_context" + +RSpec.describe Saml::Providers::CreateContract do + include_context "ModelContract shared context" + + let(:provider) { build(:saml_provider) } + let(:contract) { described_class.new provider, current_user } + + context "when admin" do + let(:current_user) { build_stubbed(:admin) } + + it_behaves_like "contract is valid" + end + + context "when non-admin" do + let(:current_user) { build_stubbed(:user) } + + it_behaves_like "contract is invalid", base: :error_unauthorized + end +end diff --git a/modules/auth_saml/spec/contracts/saml/providers/delete_contract_spec.rb b/modules/auth_saml/spec/contracts/saml/providers/delete_contract_spec.rb new file mode 100644 index 000000000000..2b0d80475ff8 --- /dev/null +++ b/modules/auth_saml/spec/contracts/saml/providers/delete_contract_spec.rb @@ -0,0 +1,51 @@ +# 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. +#++ + +require "spec_helper" +require "contracts/shared/model_contract_shared_context" + +RSpec.describe Saml::Providers::DeleteContract do + include_context "ModelContract shared context" + + let(:provider) { build_stubbed(:saml_provider) } + let(:contract) { described_class.new provider, current_user } + + context "when admin" do + let(:current_user) { build_stubbed(:admin) } + + it_behaves_like "contract is valid" + end + + context "when non-admin" do + let(:current_user) { build_stubbed(:user) } + + it_behaves_like "contract is invalid", base: :error_unauthorized + end +end diff --git a/modules/auth_saml/spec/contracts/saml/providers/update_contract_spec.rb b/modules/auth_saml/spec/contracts/saml/providers/update_contract_spec.rb new file mode 100644 index 000000000000..9954641223d7 --- /dev/null +++ b/modules/auth_saml/spec/contracts/saml/providers/update_contract_spec.rb @@ -0,0 +1,49 @@ +#-- 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. +#++ + +require "spec_helper" +require "contracts/shared/model_contract_shared_context" + +RSpec.describe Saml::Providers::UpdateContract do + let(:provider) { build_stubbed(:saml_provider) } + let(:contract) { described_class.new provider, current_user } + + include_context "ModelContract shared context" + + context "when admin" do + let(:current_user) { build_stubbed(:admin) } + + it_behaves_like "contract is valid" + end + + context "when non-admin" do + let(:current_user) { build_stubbed(:user) } + + it_behaves_like "contract is invalid", base: :error_unauthorized + end +end diff --git a/modules/auth_saml/spec/factories/saml_provider_factory.rb b/modules/auth_saml/spec/factories/saml_provider_factory.rb new file mode 100644 index 000000000000..51ebdd6ac4ff --- /dev/null +++ b/modules/auth_saml/spec/factories/saml_provider_factory.rb @@ -0,0 +1,33 @@ +#-- 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. +#++ + +FactoryBot.define do + factory(:saml_provider, class: "Saml::Provider") do + sequence(:display_name) { |n| "Saml Provider #{n}" } + end +end From 3e5866b0333d8d3c25eff10c0fefc4077bf76827 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 21 Aug 2024 17:16:40 +0200 Subject: [PATCH 62/80] Fix and unify setting of options hash --- modules/auth_saml/app/controllers/saml/providers_controller.rb | 2 +- .../app/services/saml/providers/set_attributes_service.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/auth_saml/app/controllers/saml/providers_controller.rb b/modules/auth_saml/app/controllers/saml/providers_controller.rb index e3762e79791a..0429fe712500 100644 --- a/modules/auth_saml/app/controllers/saml/providers_controller.rb +++ b/modules/auth_saml/app/controllers/saml/providers_controller.rb @@ -92,7 +92,7 @@ def create def update call = Saml::Providers::UpdateService .new(model: @provider, user: User.current) - .call(update_params) + .call(options: update_params) if call.success? flash[:notice] = I18n.t(:notice_successful_update) unless @edit_mode diff --git a/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb b/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb index e01adf1291d7..a70be4d9fc76 100644 --- a/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb +++ b/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb @@ -32,7 +32,6 @@ class SetAttributesService < BaseServices::SetAttributes private def set_attributes(params) - update_mapping(params) update_options(params.delete(:options)) if params.key?(:options) super @@ -50,6 +49,7 @@ def update_options(options) update_idp_cert(options.delete(:idp_cert)) if options.key?(:idp_cert) update_certificate(options.delete(:certificate)) if options.key?(:certificate) update_private_key(options.delete(:private_key)) if options.key?(:private_key) + update_mapping(options) options .select { |key, _| Saml::Provider.stored_attributes[:options].include?(key.to_s) } From dd65ef439ca6bfeb9a01bf598a62446534563931 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 21 Aug 2024 15:41:29 +0200 Subject: [PATCH 63/80] Service specs --- .../contracts/saml/providers/base_contract.rb | 20 +- .../saml/providers/create_contract.rb | 1 - .../app/models/saml/provider/hash_builder.rb | 6 +- .../seeders/env_data/saml/provider_seeder.rb | 2 +- .../saml/providers/set_attributes_service.rb | 21 +- .../lib/open_project/auth_saml/engine.rb | 2 +- .../spec/factories/saml_provider_factory.rb | 13 + .../spec/lib/open_project/auth_saml_spec.rb | 80 ++--- .../env_data/saml/provider_seeder_spec.rb | 6 +- .../saml/providers/create_service_spec.rb | 36 +++ .../providers/set_attributes_service_spec.rb | 276 ++++++++++++++++++ .../saml/providers/update_service_spec.rb | 36 +++ .../spec/support/certificate_helper.rb | 56 ++++ 13 files changed, 476 insertions(+), 79 deletions(-) create mode 100644 modules/auth_saml/spec/services/saml/providers/create_service_spec.rb create mode 100644 modules/auth_saml/spec/services/saml/providers/set_attributes_service_spec.rb create mode 100644 modules/auth_saml/spec/services/saml/providers/update_service_spec.rb create mode 100644 modules/auth_saml/spec/support/certificate_helper.rb diff --git a/modules/auth_saml/app/contracts/saml/providers/base_contract.rb b/modules/auth_saml/app/contracts/saml/providers/base_contract.rb index 3450597c6f1c..c0d018638b9c 100644 --- a/modules/auth_saml/app/contracts/saml/providers/base_contract.rb +++ b/modules/auth_saml/app/contracts/saml/providers/base_contract.rb @@ -34,6 +34,9 @@ def self.model Saml::Provider end + attribute :type + validate :type_is_saml_provider + attribute :display_name attribute :slug attribute :options @@ -59,16 +62,22 @@ def self.model if: -> { model.idp_cert_changed? && model.idp_cert.present? } attribute :authn_requests_signed - validate :authn_requests_signed_requires_cert + validate :valid_certificate_key_pair %i[mapping_mail mapping_login mapping_firstname mapping_lastname].each do |attr| attribute attr validates_presence_of attr, if: -> { model.public_send(:"#{attr}_changed?") } end + def type_is_saml_provider + unless model.type == Saml::Provider.name + errors.add(:type, :inclusion) + end + end + def idp_cert_not_expired unless model.idp_certificate_expired? - errors.add :certificate, :certificate_expired + errors.add :idp_cert, :certificate_expired end rescue OpenSSL::X509::CertificateError => e errors.add :idp_cert, :invalid_certificate, additional_message: e.message @@ -94,7 +103,7 @@ def valid_sp_key errors.add :private_key, :invalid_private_key, additional_message: e.message end - def authn_requests_signed_requires_cert + def valid_certificate_key_pair return unless should_test_certificate? return if certificate_invalid? @@ -114,10 +123,9 @@ def certificate_invalid? end def should_test_certificate? - return false unless model.authn_requests_signed - return false unless model.authn_requests_signed_changed? || model.certificate_changed? || model.private_key_changed? + return false unless model.certificate_changed? || model.private_key_changed? - model.certificate.present? && model.private_key.present? + model.certificate.present? || model.private_key.present? end end end diff --git a/modules/auth_saml/app/contracts/saml/providers/create_contract.rb b/modules/auth_saml/app/contracts/saml/providers/create_contract.rb index 2115bc482b3c..555aeac3604f 100644 --- a/modules/auth_saml/app/contracts/saml/providers/create_contract.rb +++ b/modules/auth_saml/app/contracts/saml/providers/create_contract.rb @@ -28,7 +28,6 @@ module Saml module Providers class CreateContract < BaseContract - attribute :type end end end diff --git a/modules/auth_saml/app/models/saml/provider/hash_builder.rb b/modules/auth_saml/app/models/saml/provider/hash_builder.rb index 522183b29b83..cc02b2c83619 100644 --- a/modules/auth_saml/app/models/saml/provider/hash_builder.rb +++ b/modules/auth_saml/app/models/saml/provider/hash_builder.rb @@ -52,9 +52,9 @@ def security_options_hash check_idp_cert_expiration: false, # done in contract check_sp_cert_expiration: false, # done in contract metadata_signed: certificate.present? && private_key.present?, - authn_requests_signed:, - want_assertions_signed:, - want_assertions_encrypted:, + authn_requests_signed: !!authn_requests_signed, + want_assertions_signed: !!want_assertions_signed, + want_assertions_encrypted: !!want_assertions_encrypted, digest_method:, signature_method: }.compact diff --git a/modules/auth_saml/app/seeders/env_data/saml/provider_seeder.rb b/modules/auth_saml/app/seeders/env_data/saml/provider_seeder.rb index 4c68fdad45ea..134ed8a94ffe 100644 --- a/modules/auth_saml/app/seeders/env_data/saml/provider_seeder.rb +++ b/modules/auth_saml/app/seeders/env_data/saml/provider_seeder.rb @@ -61,7 +61,7 @@ def load_deprecated_configuration if deprecated_settings.exist? Rails.logger.info do <<~WARNING - Loading SAML configuration from deprecated location #{deprecated_path}. + Loading SAML configuration from deprecated location #{deprecated_settings}. Please use ENV variables or UI configuration instead. For more information, see our guide on how to configure SAML. diff --git a/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb b/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb index a70be4d9fc76..322072a72ba4 100644 --- a/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb +++ b/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb @@ -45,7 +45,7 @@ def update_available_state end end - def update_options(options) + def update_options(options) # rubocop:disable Metrics/AbcSize update_idp_cert(options.delete(:idp_cert)) if options.key?(:idp_cert) update_certificate(options.delete(:certificate)) if options.key?(:certificate) update_private_key(options.delete(:private_key)) if options.key?(:private_key) @@ -67,11 +67,18 @@ def set_default_attributes(*) set_issuer set_name_identifier_format set_default_digest + set_default_encryption end end + def set_default_encryption + model.authn_requests_signed = false if model.authn_requests_signed.nil? + model.want_assertions_signed = false if model.want_assertions_signed.nil? + model.want_assertions_encrypted = false if model.want_assertions_encrypted.nil? + end + def set_slug - model.slug ||= "#{model.class.slug_fragment}-#{model.display_name.to_url}" + model.slug ||= "#{model.class.slug_fragment}-#{model.display_name.to_url}" if model.display_name end def set_default_digest @@ -107,14 +114,14 @@ def update_private_key(private_key) ## # Clean up provided mapping, reducing whitespace def update_mapping(params) - %i[mapping_mail mapping_login mapping_firstname mapping_lastname].each do |attr| + %i[mapping_mail mapping_login mapping_firstname mapping_lastname mapping_uid].each do |attr| next unless params.key?(attr) - mapping = params.delete(attr) - mapping.gsub!("\r\n", "\n") - mapping.gsub!(/^\s*(.+?)\s*$/, '\1') + parsed = params.delete(attr) + .gsub("\r\n", "\n") + .gsub!(/^\s*(.+?)\s*$/, '\1') - model.public_send(:"#{attr}=", mapping) + model.public_send(:"#{attr}=", parsed) end end diff --git a/modules/auth_saml/lib/open_project/auth_saml/engine.rb b/modules/auth_saml/lib/open_project/auth_saml/engine.rb index 5477eb21da97..41cf1c3a9ee2 100644 --- a/modules/auth_saml/lib/open_project/auth_saml/engine.rb +++ b/modules/auth_saml/lib/open_project/auth_saml/engine.rb @@ -6,7 +6,7 @@ def self.configuration OpenProject::Cache.fetch(providers.cache_key) do providers.each_with_object({}) do |provider, hash| - hash[provider.slug] = provider.to_h + hash[provider.slug.to_sym] = provider.to_h end end end diff --git a/modules/auth_saml/spec/factories/saml_provider_factory.rb b/modules/auth_saml/spec/factories/saml_provider_factory.rb index 51ebdd6ac4ff..d0daf5f25fb5 100644 --- a/modules/auth_saml/spec/factories/saml_provider_factory.rb +++ b/modules/auth_saml/spec/factories/saml_provider_factory.rb @@ -26,8 +26,21 @@ # See COPYRIGHT and LICENSE files for more details. #++ +require_relative "../support/certificate_helper" + FactoryBot.define do factory(:saml_provider, class: "Saml::Provider") do sequence(:display_name) { |n| "Saml Provider #{n}" } + creator factory: :user + available { true } + idp_cert { CertificateHelper.valid_certificate.to_pem } + + idp_sso_service_url { "https://example.com/sso" } + idp_slo_service_url { "https://example.com/slo" } + + mapping_login { Saml::Defaults::MAIL_MAPPING } + mapping_mail { Saml::Defaults::MAIL_MAPPING } + mapping_firstname { Saml::Defaults::FIRSTNAME_MAPPING } + mapping_lastname { Saml::Defaults::LASTNAME_MAPPING } end end diff --git a/modules/auth_saml/spec/lib/open_project/auth_saml_spec.rb b/modules/auth_saml/spec/lib/open_project/auth_saml_spec.rb index 4aff710610ec..263cd698e03d 100644 --- a/modules/auth_saml/spec/lib/open_project/auth_saml_spec.rb +++ b/modules/auth_saml/spec/lib/open_project/auth_saml_spec.rb @@ -1,66 +1,34 @@ -require File.dirname(__FILE__) + "/../../spec_helper" +require "#{File.dirname(__FILE__)}/../../spec_helper" require "open_project/auth_saml" RSpec.describe OpenProject::AuthSaml do - before do - OpenProject::AuthSaml.reload_configuration! - end - - after do - OpenProject::AuthSaml.reload_configuration! - end - describe ".configuration" do - let(:config) do - # the `configuration` method is cached to avoid - # loading the SAML file more than once - # thus remove any cached value here - OpenProject::AuthSaml.remove_instance_variable(:@saml_settings) - OpenProject::AuthSaml.configuration - end + let!(:provider) { create(:saml_provider, display_name: "My SSO", slug: "my-saml") } + + subject { described_class.configuration[:"my-saml"] } - context( - "with configuration", - with_config: { - saml: { - my_saml: { - name: "saml", - display_name: "My SSO" - } - } - } - ) do - it "contains the configuration from OpenProject::Configuration (or settings.yml) by default" do - expect(config[:my_saml][:name]).to eq "saml" - expect(config[:my_saml][:display_name]).to eq "My SSO" - end + it "contains the configuration from OpenProject::Configuration (or settings.yml) by default", + :aggregate_failures do + expect(subject[:name]).to eq "my-saml" + expect(subject[:display_name]).to eq "My SSO" + expect(subject[:idp_cert].strip).to eq provider.idp_cert.strip + expect(subject[:assertion_consumer_service_url]).to eq "http://#{Setting.host_name}/auth/my-saml/callback" + expect(subject[:idp_sso_service_url]).to eq "https://example.com/sso" + expect(subject[:idp_slo_service_url]).to eq "https://example.com/slo" - context( - "with settings override from database", - with_settings: { - plugin_openproject_auth_saml: { - providers: { - my_saml: { - display_name: "Your SSO" - }, - new_saml: { - name: "new_saml", - display_name: "Another SAML" - } - } - } - } - ) do - it "overrides the existing configuration where defined" do - expect(config[:my_saml][:name]).to eq "saml" - expect(config[:my_saml][:display_name]).to eq "Your SSO" - end + attributes = subject[:attribute_statements] + expect(attributes[:email]).to eq Saml::Defaults::MAIL_MAPPING.split("\n") + expect(attributes[:login]).to eq Saml::Defaults::MAIL_MAPPING.split("\n") + expect(attributes[:first_name]).to eq Saml::Defaults::FIRSTNAME_MAPPING.split("\n") + expect(attributes[:last_name]).to eq Saml::Defaults::LASTNAME_MAPPING.split("\n") - it "defines new providers if given" do - expect(config[:new_saml][:name]).to eq "new_saml" - expect(config[:new_saml][:display_name]).to eq "Another SAML" - end - end + security = subject[:security] + expect(security[:check_idp_cert_expiration]).to be false + expect(security[:check_sp_cert_expiration]).to be false + expect(security[:metadata_signed]).to be false + expect(security[:authn_requests_signed]).to be false + expect(security[:want_assertions_signed]).to be false + expect(security[:want_assertions_encrypted]).to be false end end end diff --git a/modules/auth_saml/spec/seeders/env_data/saml/provider_seeder_spec.rb b/modules/auth_saml/spec/seeders/env_data/saml/provider_seeder_spec.rb index 57b10da5fe02..23f1681f67ff 100644 --- a/modules/auth_saml/spec/seeders/env_data/saml/provider_seeder_spec.rb +++ b/modules/auth_saml/spec/seeders/env_data/saml/provider_seeder_spec.rb @@ -69,9 +69,9 @@ expect { seeder.seed! }.to change(Saml::Provider, :count).by(1) provider = Saml::Provider.last + expect(provider.seeded_from_env?).to be true expect(provider.slug).to eq "saml" expect(provider.display_name).to eq "Test SAML" - expect(provider.seeded_from_env).to be true expect(provider.sp_entity_id).to eq "http://localhost:3000" expect(provider.assertion_consumer_service_url).to eq "http://localhost:3000/auth/saml/callback" @@ -86,14 +86,12 @@ context "when provider already exists with that name" do it "updates the provider" do provider = Saml::Provider.create!(display_name: "Something", slug: "saml", mapping_mail: "old", creator: User.system) - expect(provider.seeded_from_env).to be_nil - expect { seeder.seed! }.not_to change(Saml::Provider, :count) provider.reload expect(provider.display_name).to eq "Test SAML" - expect(provider.seeded_from_env).to be true + expect(provider.seeded_from_env?).to be true expect(provider.mapping_mail).to eq "mail\nurn:oid:2.5.4.42\nhttp://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" end end diff --git a/modules/auth_saml/spec/services/saml/providers/create_service_spec.rb b/modules/auth_saml/spec/services/saml/providers/create_service_spec.rb new file mode 100644 index 000000000000..a148565c4924 --- /dev/null +++ b/modules/auth_saml/spec/services/saml/providers/create_service_spec.rb @@ -0,0 +1,36 @@ +#-- 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. +#++ + +require "spec_helper" +require "services/base_services/behaves_like_create_service" + +RSpec.describe Saml::Providers::CreateService, type: :model do + it_behaves_like "BaseServices create service" do + let(:factory) { :saml_provider } + end +end diff --git a/modules/auth_saml/spec/services/saml/providers/set_attributes_service_spec.rb b/modules/auth_saml/spec/services/saml/providers/set_attributes_service_spec.rb new file mode 100644 index 000000000000..7dc74db109b6 --- /dev/null +++ b/modules/auth_saml/spec/services/saml/providers/set_attributes_service_spec.rb @@ -0,0 +1,276 @@ +# 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. +#++ + +require "spec_helper" +require_relative "../../../support/certificate_helper" +require_module_spec_helper + +RSpec.describe Saml::Providers::SetAttributesService, type: :model do + let(:current_user) { build_stubbed(:admin) } + + let(:instance) do + described_class.new(user: current_user, + model: model_instance, + contract_class:, + contract_options: {}) + end + + let(:params) do + { options: } + end + let(:call) { instance.call(params) } + + subject { call.result } + + describe "new instance" do + let(:model_instance) { Saml::Provider.new(display_name: "foo") } + let(:contract_class) { Saml::Providers::CreateContract } + + describe "default attributes" do + let(:options) { {} } + + it "sets all default attributes", :aggregate_failures do + expect(subject.display_name).to eq "foo" + expect(subject.slug).to eq "saml-foo" + expect(subject.creator).to eq(current_user) + expect(subject.sp_entity_id).to eq(OpenProject::StaticRouting::StaticUrlHelpers.new.root_url) + expect(subject.name_identifier_format).to eq("urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress") + expect(subject.signature_method).to eq(Saml::Defaults::SIGNATURE_METHODS["RSA SHA-1"]) + expect(subject.digest_method).to eq(Saml::Defaults::DIGEST_METHODS["SHA-1"]) + + expect(subject.mapping_mail).to eq Saml::Defaults::MAIL_MAPPING + expect(subject.mapping_firstname).to eq Saml::Defaults::FIRSTNAME_MAPPING + expect(subject.mapping_lastname).to eq Saml::Defaults::LASTNAME_MAPPING + expect(subject.mapping_uid).to be_blank + expect(subject.mapping_login).to eq Saml::Defaults::MAIL_MAPPING + + expect(subject.requested_login_attribute).to eq "mail" + expect(subject.requested_mail_attribute).to eq "mail" + expect(subject.requested_firstname_attribute).to eq "givenName" + expect(subject.requested_lastname_attribute).to eq "sn" + + expect(subject.requested_login_format).to eq "urn:oasis:names:tc:SAML:2.0:attrname-format:basic" + expect(subject.requested_mail_format).to eq "urn:oasis:names:tc:SAML:2.0:attrname-format:basic" + expect(subject.requested_firstname_format).to eq "urn:oasis:names:tc:SAML:2.0:attrname-format:basic" + expect(subject.requested_lastname_format).to eq "urn:oasis:names:tc:SAML:2.0:attrname-format:basic" + end + end + + describe "IDP certificate" do + let(:options) do + { + idp_cert: certificate + } + end + + context "with a valid certificate" do + let(:certificate) { CertificateHelper.valid_certificate.to_pem } + + it "assigns the certificate" do + expect(call).to be_success + expect(call.errors).to be_empty + + expect(subject.idp_cert).to eq CertificateHelper.valid_certificate.to_pem + end + end + + context "with a valid certificate, not in pem format" do + let(:certificate) { CertificateHelper.valid_certificate.to_pem.lines[1...-1].join } + + it "assigns the certificate" do + expect(call).to be_success + expect(call.errors).to be_empty + + expect(subject.idp_cert).to eq CertificateHelper.valid_certificate.to_pem.strip + end + end + + context "with two certificates, one expired" do + let(:certificate) do + "#{CertificateHelper.valid_certificate.to_pem}\n#{CertificateHelper.expired_certificate.to_pem}" + end + + it "assigns the certificate" do + expect(call).to be_success + expect(call.errors).to be_empty + + expect(subject.idp_cert).to eq certificate + end + end + + context "with an invalid certificate" do + let(:certificate) { CertificateHelper.expired_certificate.to_pem } + + it "assigns the certificate" do + expect(call).not_to be_success + expect(call.errors.details[:idp_cert]).to contain_exactly({ error: :certificate_expired }) + end + end + end + + describe "certificate and private key" do + let(:options) do + { + certificate: given_certificate, + private_key: given_private_key + } + end + + context "with a valid certificate pair" do + let(:given_certificate) { CertificateHelper.valid_certificate.to_pem } + let(:given_private_key) { CertificateHelper.private_key.private_to_pem } + + it "assigns the certificate" do + expect(call).to be_success + expect(call.errors).to be_empty + + expect(subject.certificate).to eq given_certificate.strip + expect(subject.private_key).to eq given_private_key.strip + end + end + + context "with an invalid certificate" do + let(:given_certificate) { CertificateHelper.expired_certificate.to_pem } + let(:given_private_key) { nil } + + it "results in an error" do + expect(call).not_to be_success + expect(call.errors.details[:certificate]).to contain_exactly({ error: :certificate_expired }) + expect(call.errors.details[:private_key]).to contain_exactly({ error: :blank }) + end + end + + context "with a mismatched certificate" do + let(:given_certificate) { CertificateHelper.mismatched_certificate.to_pem } + let(:given_private_key) { CertificateHelper.private_key.private_to_pem } + + it "results in an error" do + expect(call).not_to be_success + expect(call.errors.details[:private_key]).to contain_exactly({ error: :unmatched_private_key }) + end + end + end + + describe "mapping" do + let(:options) do + { + mapping_mail: "mail\n whitespace \nfoo", + mapping_firstname: "name\nsn", + mapping_lastname: "hello ", + mapping_uid: "something" + } + end + + it "assigns the given and default values", :aggregate_failures do + expect(call).to be_success + expect(call.errors).to be_empty + + expect(subject.mapping_mail).to eq "mail\nwhitespace\nfoo" + expect(subject.mapping_firstname).to eq "name\nsn" + expect(subject.mapping_lastname).to eq "hello" + expect(subject.mapping_uid).to eq "something" + + expect(subject.mapping_login).to eq Saml::Defaults::MAIL_MAPPING + end + end + + describe "want_assertions_signed" do + context "when provided" do + let(:options) { { want_assertions_signed: true } } + + it "assigns the value" do + expect(call).to be_success + expect(call.errors).to be_empty + + expect(subject.want_assertions_signed).to be true + end + end + + context "when not provided" do + let(:options) { {} } + + it "assigns the default value" do + expect(call).to be_success + expect(call.errors).to be_empty + + expect(subject.want_assertions_signed).to be false + end + end + end + + describe "want_assertions_encrypted" do + context "when provided" do + let(:options) { { want_assertions_encrypted: true } } + + it "assigns the value" do + expect(call).to be_success + expect(call.errors).to be_empty + + expect(subject.want_assertions_encrypted).to be true + end + end + + context "when not provided" do + let(:options) { {} } + + it "assigns the default value" do + expect(call).to be_success + expect(call.errors).to be_empty + + expect(subject.want_assertions_encrypted).to be false + end + end + end + + describe "authn_requests_signed" do + context "when provided" do + let(:options) { { authn_requests_signed: true } } + + it "assigns the value" do + expect(call).to be_success + expect(call.errors).to be_empty + + expect(subject.authn_requests_signed).to be true + end + end + + context "when not provided" do + let(:options) { {} } + + it "assigns the default value" do + expect(call).to be_success + expect(call.errors).to be_empty + + expect(subject.authn_requests_signed).to be false + end + end + end + end +end diff --git a/modules/auth_saml/spec/services/saml/providers/update_service_spec.rb b/modules/auth_saml/spec/services/saml/providers/update_service_spec.rb new file mode 100644 index 000000000000..bd0b8df595d3 --- /dev/null +++ b/modules/auth_saml/spec/services/saml/providers/update_service_spec.rb @@ -0,0 +1,36 @@ +#-- 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. +#++ + +require "spec_helper" +require "services/base_services/behaves_like_update_service" + +RSpec.describe Saml::Providers::UpdateService, type: :model do + it_behaves_like "BaseServices update service" do + let(:factory) { :saml_provider } + end +end diff --git a/modules/auth_saml/spec/support/certificate_helper.rb b/modules/auth_saml/spec/support/certificate_helper.rb new file mode 100644 index 000000000000..1574c8e5cdbb --- /dev/null +++ b/modules/auth_saml/spec/support/certificate_helper.rb @@ -0,0 +1,56 @@ +module CertificateHelper + module_function + + def private_key + @private_key ||= OpenSSL::PKey::RSA.new(1024) + end + + def valid_certificate + @valid_certificate ||= begin + name = OpenSSL::X509::Name.parse "/CN=valid-testing" + cert = OpenSSL::X509::Certificate.new + cert.version = 2 + cert.serial = 1234 + + cert.not_before = Time.now + cert.not_after = Time.now + 606024364.251 + cert.public_key = private_key.public_key + cert.subject = name + cert.issuer = name + cert.sign private_key, OpenSSL::Digest.new("SHA1") + end + end + + def expired_certificate + @expired_certificate ||= begin + name = OpenSSL::X509::Name.parse "/CN=expired-testing" + cert = OpenSSL::X509::Certificate.new + cert.version = 2 + cert.serial = 1234 + + cert.not_before = Time.now - 2.years + cert.not_after = Time.now - 30.days + cert.public_key = private_key.public_key + cert.subject = name + cert.issuer = name + cert.sign private_key, OpenSSL::Digest.new("SHA1") + end + end + + def mismatched_certificate + @mismatched_certificate ||= begin + name = OpenSSL::X509::Name.parse "/CN=mismatched-testing" + cert = OpenSSL::X509::Certificate.new + cert.version = 2 + cert.serial = 1234 + + key = OpenSSL::PKey::RSA.new(1024) + cert.not_before = Time.now + cert.not_after = Time.now + 606024364.251 + cert.public_key = key.public_key + cert.subject = name + cert.issuer = name + cert.sign key, OpenSSL::Digest.new("SHA1") + end + end +end From 6a9fc6e2eec9bfdefa9b1afeae642a0c920cc89a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 22 Aug 2024 10:51:36 +0200 Subject: [PATCH 64/80] Fix remaining specs --- .github/workflows/test-core.yml | 2 +- modules/auth_saml/app/models/saml/provider.rb | 2 +- .../spec/factories/saml_provider_factory.rb | 4 + .../spec/fixtures/idp_cert_plain.txt | 2 +- .../auth_saml/spec/fixtures/saml_response.xml | 2 +- .../requests/saml_provider_callback_spec.rb | 77 ++++++++++--------- 6 files changed, 49 insertions(+), 40 deletions(-) diff --git a/.github/workflows/test-core.yml b/.github/workflows/test-core.yml index 30d3d658d320..da196cdf94bc 100644 --- a/.github/workflows/test-core.yml +++ b/.github/workflows/test-core.yml @@ -24,7 +24,7 @@ jobs: name: Units + Features if: github.repository == 'opf/openproject' runs-on: - labels: + labels - runs-on - runner=32cpu-linux-x64 - family=m7 diff --git a/modules/auth_saml/app/models/saml/provider.rb b/modules/auth_saml/app/models/saml/provider.rb index 6e081d79e835..4191bd0db798 100644 --- a/modules/auth_saml/app/models/saml/provider.rb +++ b/modules/auth_saml/app/models/saml/provider.rb @@ -104,7 +104,7 @@ def idp_certificate_expired? def idp_cert=(cert) formatted = - if cert.include?("BEGIN CERTIFICATE") + if cert.nil? || cert.include?("BEGIN CERTIFICATE") cert else OneLogin::RubySaml::Utils.format_cert(cert) diff --git a/modules/auth_saml/spec/factories/saml_provider_factory.rb b/modules/auth_saml/spec/factories/saml_provider_factory.rb index d0daf5f25fb5..848da24df12c 100644 --- a/modules/auth_saml/spec/factories/saml_provider_factory.rb +++ b/modules/auth_saml/spec/factories/saml_provider_factory.rb @@ -33,7 +33,11 @@ sequence(:display_name) { |n| "Saml Provider #{n}" } creator factory: :user available { true } + idp_cert { CertificateHelper.valid_certificate.to_pem } + idp_cert_fingerprint { nil } + + sp_entity_id { "http://#{Setting.host_name}" } idp_sso_service_url { "https://example.com/sso" } idp_slo_service_url { "https://example.com/slo" } diff --git a/modules/auth_saml/spec/fixtures/idp_cert_plain.txt b/modules/auth_saml/spec/fixtures/idp_cert_plain.txt index bb5ce9674d63..a220aa269674 100644 --- a/modules/auth_saml/spec/fixtures/idp_cert_plain.txt +++ b/modules/auth_saml/spec/fixtures/idp_cert_plain.txt @@ -1 +1 @@ -MIIDHTCCAgWgAwIBAgIJaPVOFuER4IyxMA0GCSqGSIb3DQEBCwUAMCwxKjAoBgNVBAMTIWRldi10eXQzaDZkNTZjeTB4aXA3LnVzLmF1dGgwLmNvbTAeFw0yMzA0MjUwODU0MThaFw0zNzAxMDEwODU0MThaMCwxKjAoBgNVBAMTIWRldi10eXQzaDZkNTZjeTB4aXA3LnVzLmF1dGgwLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAK/2idoD9sJDzYukmOCVC2Qp4nLm2WGkIbdAudSDzb3hEDVTebAUXeDUqi9nQMtXjS5lvXYVQMIHdq1yRLGciSBviO+qT/FGNRm/VncpWYt0mVvf6t/9Y1PJ1krFlo/72FN3VBEClqAccVryPq+pjwswZMWbgBi3BsMdi+HYW2mgPRKeAemaEYcPwPyUpCBiVQo/6cCSlBB+Kigyp33LAJZVakwNA5pxEfnhUwifiyBzO+xH5m4ol8mcDznsmiBYddxqVQ7qaYds4HSpwSzArdxLnlFj1nVwusJyGsjZD4pCMZuoWXLGPs4ldBgzLH5Dq6UtsCKPZGhRzYA4Au7qVGcCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUrh9OF0EmZv3h3BmcuCF05qLDYokwDgYDVR0PAQH/BAQDAgKEMA0GCSqGSIb3DQEBCwUAA4IBAQAoo/z1LTjEmGrm9ilObVDzPSWlvOqDq1lkP8nrROpYkghDPxXQl+8nJ4J0gKwDP6BgTjWzhMk8MyoeCn8wZGI7oBOeBPOeJCbc6WYW2HGvtc86nO3JHHdvDYMO3HbR9LFoxBXhF95nWwgmPd+9uxdHrdfQ1CIXasATWToQgud6rkCgMcNP25LFds36iMCzXDxKiYvn54JaXmGo567U1Rx6O4AwnCG1RHTdtx/CdiIuPAOBl/xUnnsVeCPQgqNl53/6Qtmk9kg15GeeFDsjJoHoZuLZANaGMUBLRMzLU4al3/SRgPJLaEq46BaRq/oX0WvWlGiVegHNWjpOJ5ZSEeS5 +MIICpzCCAhACCQDuFX0Db5iljDANBgkqhkiG9w0BAQsFADCBlzELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExEjAQBgNVBAcMCVBhbG8gQWx0bzEQMA4GA1UECgwHU2FtbGluZzEPMA0GA1UECwwGU2FsaW5nMRQwEgYDVQQDDAtjYXByaXphLmNvbTEmMCQGCSqGSIb3DQEJARYXZW5naW5lZXJpbmdAY2Fwcml6YS5jb20wHhcNMTgwNTE1MTgxMTEwWhcNMjgwNTEyMTgxMTEwWjCBlzELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExEjAQBgNVBAcMCVBhbG8gQWx0bzEQMA4GA1UECgwHU2FtbGluZzEPMA0GA1UECwwGU2FsaW5nMRQwEgYDVQQDDAtjYXByaXphLmNvbTEmMCQGCSqGSIb3DQEJARYXZW5naW5lZXJpbmdAY2Fwcml6YS5jb20wgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAJEBNDJKH5nXr0hZKcSNIY1l4HeYLPBEKJLXyAnoFTdgGrvi40YyIx9lHh0LbDVWCgxJp21BmKll0CkgmeKidvGlr3FUwtETro44L+SgmjiJNbftvFxhNkgA26O2GDQuBoQwgSiagVadWXwJKkodH8tx4ojBPYK1pBO8fHf3wOnxAgMBAAEwDQYJKoZIhvcNAQELBQADgYEACIylhvh6T758hcZjAQJiV7rMRg+Omb68iJI4L9f0cyBcJENR+1LQNgUGyFDMm9Wm9o81CuIKBnfpEE2Jfcs76YVWRJy5xJ11GFKJJ5T0NEB7txbUQPoJOeNoE736lF5vYw6YKp8fJqPW0L2PLWe9qTn8hxpdnjo3k6r5gXyl8tk= diff --git a/modules/auth_saml/spec/fixtures/saml_response.xml b/modules/auth_saml/spec/fixtures/saml_response.xml index b995b7f130f7..691b6b8a1c6c 100644 --- a/modules/auth_saml/spec/fixtures/saml_response.xml +++ b/modules/auth_saml/spec/fixtures/saml_response.xml @@ -1 +1 @@ -https://foobar.org/B4p7Ab3QK9Y3XQ5LoUMcJ3hxWkBoFTLMVKmBNS01e0=f4WLA8kcPXYTIn/8Ra1PjByizB8fqM22H+AJXGfPoO2ZqXEkQzWNcS66FluYns/3XOSP/8yTk5fK7AhOAssXCif6O94XCdk7+roj/Xl+AG9BrgHDQU9ytblNmTU0Q0EFEarlgAuPocCimBqjLcvRFLzyr/nia6XGoREx77bRjSw=MIICpzCCAhACCQDuFX0Db5iljDANBgkqhkiG9w0BAQsFADCBlzELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExEjAQBgNVBAcMCVBhbG8gQWx0bzEQMA4GA1UECgwHU2FtbGluZzEPMA0GA1UECwwGU2FsaW5nMRQwEgYDVQQDDAtjYXByaXphLmNvbTEmMCQGCSqGSIb3DQEJARYXZW5naW5lZXJpbmdAY2Fwcml6YS5jb20wHhcNMTgwNTE1MTgxMTEwWhcNMjgwNTEyMTgxMTEwWjCBlzELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExEjAQBgNVBAcMCVBhbG8gQWx0bzEQMA4GA1UECgwHU2FtbGluZzEPMA0GA1UECwwGU2FsaW5nMRQwEgYDVQQDDAtjYXByaXphLmNvbTEmMCQGCSqGSIb3DQEJARYXZW5naW5lZXJpbmdAY2Fwcml6YS5jb20wgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAJEBNDJKH5nXr0hZKcSNIY1l4HeYLPBEKJLXyAnoFTdgGrvi40YyIx9lHh0LbDVWCgxJp21BmKll0CkgmeKidvGlr3FUwtETro44L+SgmjiJNbftvFxhNkgA26O2GDQuBoQwgSiagVadWXwJKkodH8tx4ojBPYK1pBO8fHf3wOnxAgMBAAEwDQYJKoZIhvcNAQELBQADgYEACIylhvh6T758hcZjAQJiV7rMRg+Omb68iJI4L9f0cyBcJENR+1LQNgUGyFDMm9Wm9o81CuIKBnfpEE2Jfcs76YVWRJy5xJ11GFKJJ5T0NEB7txbUQPoJOeNoE736lF5vYw6YKp8fJqPW0L2PLWe9qTn8hxpdnjo3k6r5gXyl8tk=https://foobar.orgZtp3jfFiai++82S52hLRZZMUEAm0gYpkSonPC0aVV7I=bQuXxqLvkyFZuEpD8jab4xBGOz0Xg2i1DhZheCR2CN12VM8PcLhvWjZF7APvNsI6D7HC1SmBQIg2dAQUB1TGO5+ZD5TDDQd90qiKvesW1uYWWh5QP7rvphKvJl/cBXH8w3hbMnC/noC9DMQVL1ugjpa5y7Gzsj6JwNYhjWDPjHc=MIICpzCCAhACCQDuFX0Db5iljDANBgkqhkiG9w0BAQsFADCBlzELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExEjAQBgNVBAcMCVBhbG8gQWx0bzEQMA4GA1UECgwHU2FtbGluZzEPMA0GA1UECwwGU2FsaW5nMRQwEgYDVQQDDAtjYXByaXphLmNvbTEmMCQGCSqGSIb3DQEJARYXZW5naW5lZXJpbmdAY2Fwcml6YS5jb20wHhcNMTgwNTE1MTgxMTEwWhcNMjgwNTEyMTgxMTEwWjCBlzELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExEjAQBgNVBAcMCVBhbG8gQWx0bzEQMA4GA1UECgwHU2FtbGluZzEPMA0GA1UECwwGU2FsaW5nMRQwEgYDVQQDDAtjYXByaXphLmNvbTEmMCQGCSqGSIb3DQEJARYXZW5naW5lZXJpbmdAY2Fwcml6YS5jb20wgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAJEBNDJKH5nXr0hZKcSNIY1l4HeYLPBEKJLXyAnoFTdgGrvi40YyIx9lHh0LbDVWCgxJp21BmKll0CkgmeKidvGlr3FUwtETro44L+SgmjiJNbftvFxhNkgA26O2GDQuBoQwgSiagVadWXwJKkodH8tx4ojBPYK1pBO8fHf3wOnxAgMBAAEwDQYJKoZIhvcNAQELBQADgYEACIylhvh6T758hcZjAQJiV7rMRg+Omb68iJI4L9f0cyBcJENR+1LQNgUGyFDMm9Wm9o81CuIKBnfpEE2Jfcs76YVWRJy5xJ11GFKJJ5T0NEB7txbUQPoJOeNoE736lF5vYw6YKp8fJqPW0L2PLWe9qTn8hxpdnjo3k6r5gXyl8tk=testuserurn:oasis:names:tc:SAML:2.0:ac:classes:unspecifiedfoo@example.comtestuser +https://foobar.org1vN2lq+9ndeRFBMdkGaZxQdubvqu2NbLN0tW15WVDAk=cJp2jnvy7FanTRJGh7cvZhywfspxyJSvNpK5HJntIkaxgCoviApZRk0zdTuiJUiV/dSfp9MvGh0tAQ2cUWwlnMuBXASR6RIsd9itBQAoyCQwHyi7/cDKgreF2M/so6G9Phyglek154759mh/7EvTu+P5+KAgof+YB41zQdsi8EY=MIICpzCCAhACCQDuFX0Db5iljDANBgkqhkiG9w0BAQsFADCBlzELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExEjAQBgNVBAcMCVBhbG8gQWx0bzEQMA4GA1UECgwHU2FtbGluZzEPMA0GA1UECwwGU2FsaW5nMRQwEgYDVQQDDAtjYXByaXphLmNvbTEmMCQGCSqGSIb3DQEJARYXZW5naW5lZXJpbmdAY2Fwcml6YS5jb20wHhcNMTgwNTE1MTgxMTEwWhcNMjgwNTEyMTgxMTEwWjCBlzELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExEjAQBgNVBAcMCVBhbG8gQWx0bzEQMA4GA1UECgwHU2FtbGluZzEPMA0GA1UECwwGU2FsaW5nMRQwEgYDVQQDDAtjYXByaXphLmNvbTEmMCQGCSqGSIb3DQEJARYXZW5naW5lZXJpbmdAY2Fwcml6YS5jb20wgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAJEBNDJKH5nXr0hZKcSNIY1l4HeYLPBEKJLXyAnoFTdgGrvi40YyIx9lHh0LbDVWCgxJp21BmKll0CkgmeKidvGlr3FUwtETro44L+SgmjiJNbftvFxhNkgA26O2GDQuBoQwgSiagVadWXwJKkodH8tx4ojBPYK1pBO8fHf3wOnxAgMBAAEwDQYJKoZIhvcNAQELBQADgYEACIylhvh6T758hcZjAQJiV7rMRg+Omb68iJI4L9f0cyBcJENR+1LQNgUGyFDMm9Wm9o81CuIKBnfpEE2Jfcs76YVWRJy5xJ11GFKJJ5T0NEB7txbUQPoJOeNoE736lF5vYw6YKp8fJqPW0L2PLWe9qTn8hxpdnjo3k6r5gXyl8tk=https://foobar.orgkavIddInjlzr6lJ6iGeIUU2DerATKPNhZcnezG60seE=Y2SLLZ2+M4wX0dTd+b/MS+wt9wrPiyi32kL/qTSNIIW9xBHRYkp21xqJ+kwFyk3EjPbcpUUvrAxztlJ6GHsc/rUrWfykHZp/NSFDKtaSeRL44m8jH+AA5lDFIWWl8Zw/OIWKJLfE4IfQTfvjDZz12SiJaj4wgby2enXkvLlUxC8=MIICpzCCAhACCQDuFX0Db5iljDANBgkqhkiG9w0BAQsFADCBlzELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExEjAQBgNVBAcMCVBhbG8gQWx0bzEQMA4GA1UECgwHU2FtbGluZzEPMA0GA1UECwwGU2FsaW5nMRQwEgYDVQQDDAtjYXByaXphLmNvbTEmMCQGCSqGSIb3DQEJARYXZW5naW5lZXJpbmdAY2Fwcml6YS5jb20wHhcNMTgwNTE1MTgxMTEwWhcNMjgwNTEyMTgxMTEwWjCBlzELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExEjAQBgNVBAcMCVBhbG8gQWx0bzEQMA4GA1UECgwHU2FtbGluZzEPMA0GA1UECwwGU2FsaW5nMRQwEgYDVQQDDAtjYXByaXphLmNvbTEmMCQGCSqGSIb3DQEJARYXZW5naW5lZXJpbmdAY2Fwcml6YS5jb20wgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAJEBNDJKH5nXr0hZKcSNIY1l4HeYLPBEKJLXyAnoFTdgGrvi40YyIx9lHh0LbDVWCgxJp21BmKll0CkgmeKidvGlr3FUwtETro44L+SgmjiJNbftvFxhNkgA26O2GDQuBoQwgSiagVadWXwJKkodH8tx4ojBPYK1pBO8fHf3wOnxAgMBAAEwDQYJKoZIhvcNAQELBQADgYEACIylhvh6T758hcZjAQJiV7rMRg+Omb68iJI4L9f0cyBcJENR+1LQNgUGyFDMm9Wm9o81CuIKBnfpEE2Jfcs76YVWRJy5xJ11GFKJJ5T0NEB7txbUQPoJOeNoE736lF5vYw6YKp8fJqPW0L2PLWe9qTn8hxpdnjo3k6r5gXyl8tk=foourn:oasis:names:tc:SAML:2.0:ac:classes:unspecifiedfoo@example.comFooUserfoo_user diff --git a/modules/auth_saml/spec/requests/saml_provider_callback_spec.rb b/modules/auth_saml/spec/requests/saml_provider_callback_spec.rb index fdf296962b5c..5a1a32eafb86 100644 --- a/modules/auth_saml/spec/requests/saml_provider_callback_spec.rb +++ b/modules/auth_saml/spec/requests/saml_provider_callback_spec.rb @@ -28,6 +28,7 @@ require "spec_helper" require "rack/test" +require_relative "../support/certificate_helper" RSpec.describe "SAML provider callback", type: :rails_request, @@ -35,6 +36,19 @@ include Rack::Test::Methods include API::V3::Utilities::PathHelper + let!(:provider) do + create(:saml_provider, + display_name: "SAML", + slug: "saml", + digest_method: "http://www.w3.org/2001/04/xmlenc#sha256", + sp_entity_id: "https://foobar.org", + idp_cert:, + idp_cert_fingerprint:) + end + + let(:idp_cert) { nil } + let(:idp_cert_fingerprint) { "B7:11:A4:22:A0:57:9D:A6:30:06:3C:BF:AC:44:8F:90:BE:5A:E2:3F" } + let(:saml_response) do xml = File.read("#{File.dirname(__FILE__)}/../fixtures/saml_response.xml") Base64.encode64(xml) @@ -44,41 +58,12 @@ { SAMLResponse: saml_response } end - let(:issuer) { "https://foobar.org" } - let(:fingerprint) { "b711a422a0579da630063cbfac448f90be5ae23f" } - - let(:config) do - { - "name" => "saml", - "display_name" => "SAML", - "assertion_consumer_service_url" => "http://localhost:3000/auth/saml/callback", - "issuer" => issuer, - "idp_cert_fingerprint" => fingerprint, - "idp_sso_target_url" => "https://foobar.org/login", - "idp_slo_target_url" => "https://foobar.org/logout", - "security" => { - "digest_method" => "http://www.w3.org/2001/04/xmlenc#sha256", - "check_idp_cert_expiration" => false - }, - "attribute_statements" => { - "email" => ["email", "urn:oid:0.9.2342.19200300.100.1.3"], - "login" => ["uid", "email", "urn:oid:0.9.2342.19200300.100.1.3"], - "first_name" => ["givenName", "urn:oid:2.5.4.42"], - "last_name" => ["sn", "urn:oid:2.5.4.4"] - } - } + let(:request) do + post "/auth/saml/callback", body end - let(:request) { post "/auth/saml/callback", body } - subject do - Timecop.freeze("2023-04-19T09:37:00Z".to_datetime) { request } - end - - before do - Setting.plugin_openproject_auth_saml = { - "providers" => { "saml" => config } - } + Timecop.freeze("2024-08-22T09:22:00Z".to_datetime) { request } end shared_examples "request fails" do |message| @@ -89,9 +74,15 @@ end end - it "redirects user when no errors occured" do - expect(subject.status).to eq(302) - expect(subject.headers["Location"]).to eq("http://#{Setting.host_name}/two_factor_authentication/request") + shared_examples "request succeeds" do + it "redirects user when no errors occured" do + expect(subject.status).to eq(302) + expect(subject.headers["Location"]).to eq("http://#{Setting.host_name}/two_factor_authentication/request") + end + end + + context "with valid basic configuration" do + it_behaves_like "request succeeds" end context "with an invalid timestamp" do @@ -105,7 +96,21 @@ end context "with an invalid fingerprint" do - let(:fingerprint) { "invalid" } + let(:idp_cert_fingerprint) { "invalid" } + + it_behaves_like "request fails" + end + + context "when providing the valid certificate" do + let(:idp_cert) { File.read(Rails.root.join("modules/auth_saml/spec/fixtures/idp_cert_plain.txt").to_s) } + let(:idp_cert_fingerprint) { nil } + + it_behaves_like "request succeeds" + end + + context "when providing an invalid certificate" do + let(:idp_cert) { CertificateHelper.expired_certificate.to_pem } + let(:idp_cert_fingerprint) { nil } it_behaves_like "request fails", "Fingerprint mismatch" end From 7af51312fd83d1f3ed2643355085930a9c91e115 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 22 Aug 2024 13:56:14 +0200 Subject: [PATCH 65/80] Strip whitespace in URL validator --- app/validators/url_validator.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/validators/url_validator.rb b/app/validators/url_validator.rb index 812927a7166d..939f6132387b 100644 --- a/app/validators/url_validator.rb +++ b/app/validators/url_validator.rb @@ -13,8 +13,8 @@ def validate_each(record, attribute, value) end def parse(value) - url = URI.parse(value) - rescue StandardError => e + URI.parse(value.to_s.strip) + rescue StandardError nil end From 3c8b529404f3333a87ceac659ae4c39f27133055 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 22 Aug 2024 14:50:04 +0200 Subject: [PATCH 66/80] Model specs --- .../contracts/saml/providers/base_contract.rb | 2 +- modules/auth_saml/app/models/saml/provider.rb | 6 +- .../spec/models/saml/provider_spec.rb | 284 ++++++++++++++++++ .../requests/saml_provider_callback_spec.rb | 1 - .../providers/set_attributes_service_spec.rb | 1 - modules/auth_saml/spec/spec_helper.rb | 1 + 6 files changed, 289 insertions(+), 6 deletions(-) create mode 100644 modules/auth_saml/spec/models/saml/provider_spec.rb diff --git a/modules/auth_saml/app/contracts/saml/providers/base_contract.rb b/modules/auth_saml/app/contracts/saml/providers/base_contract.rb index c0d018638b9c..e57f00b69a17 100644 --- a/modules/auth_saml/app/contracts/saml/providers/base_contract.rb +++ b/modules/auth_saml/app/contracts/saml/providers/base_contract.rb @@ -76,7 +76,7 @@ def type_is_saml_provider end def idp_cert_not_expired - unless model.idp_certificate_expired? + unless model.idp_certificate_valid? errors.add :idp_cert, :certificate_expired end rescue OpenSSL::X509::CertificateError => e diff --git a/modules/auth_saml/app/models/saml/provider.rb b/modules/auth_saml/app/models/saml/provider.rb index 4191bd0db798..3ae89b0fb726 100644 --- a/modules/auth_saml/app/models/saml/provider.rb +++ b/modules/auth_saml/app/models/saml/provider.rb @@ -43,8 +43,6 @@ class Provider < AuthProvider store_attribute :options, :requested_lastname_format, :string store_attribute :options, :requested_uid_format, :string - attr_accessor :readonly - def self.slug_fragment = "saml" def seeded_from_env? @@ -98,7 +96,9 @@ def idp_certificate_configured? idp_cert.present? end - def idp_certificate_expired? + def idp_certificate_valid? + return false if idp_cert.blank? + !loaded_idp_certificates.all? { |cert| OneLogin::RubySaml::Utils.is_cert_expired(cert) } end diff --git a/modules/auth_saml/spec/models/saml/provider_spec.rb b/modules/auth_saml/spec/models/saml/provider_spec.rb new file mode 100644 index 000000000000..3c88b8624d84 --- /dev/null +++ b/modules/auth_saml/spec/models/saml/provider_spec.rb @@ -0,0 +1,284 @@ +#-- 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. +#++ + +require "spec_helper" +require_module_spec_helper + +RSpec.describe Saml::Provider do + let(:instance) { described_class.new(display_name: "saml", slug: "saml") } + + describe "#seeded_from_env?" do + subject { instance.seeded_from_env? } + + context "when the provider is not seeded from the environment" do + it { is_expected.to be false } + end + + context "when the provider is seeded from the environment", + with_settings: { seed_saml_provider: { saml: {} } } do + it { is_expected.to be true } + end + end + + describe "#has_metadata?" do + subject { instance.has_metadata? } + + context "when metadata_xml is set" do + before { instance.metadata_xml = "metadata" } + + it { is_expected.to be true } + end + + context "when metadata_url is set" do + before { instance.metadata_url = "metadata" } + + it { is_expected.to be true } + end + + context "when metadata_xml and metadata_url are not set" do + it { is_expected.to be false } + end + end + + describe "#metadata_changed?" do + subject { instance.metadata_updated? } + + context "when metadata_xml is changed" do + before { instance.metadata_xml = "metadata" } + + it { is_expected.to be true } + end + + context "when metadata_url is changed" do + before { instance.metadata_url = "metadata" } + + it { is_expected.to be true } + end + + context "when metadata_xml and metadata_url are not changed" do + it { is_expected.to be false } + end + end + + describe "#metadata_endpoint", with_settings: { host_name: "example.com" } do + subject { instance.metadata_endpoint } + + it { is_expected.to eq "http://example.com/auth/saml/metadata" } + end + + describe "#configured?" do + subject { instance.configured? } + + context "when fully present" do + let(:instance) { build_stubbed(:saml_provider) } + + it { is_expected.to be true } + end + + context "when details missing" do + it { is_expected.to be false } + end + end + + describe "#mapping_configured?" do + subject { instance.mapping_configured? } + + context "when fully present" do + let(:instance) { build_stubbed(:saml_provider) } + + it { is_expected.to be true } + end + + context "when parts missing" do + before do + instance.mapping_mail = "foo" + end + + it { is_expected.to be false } + end + + context "when optional uid missing" do + before do + instance.mapping_mail = "foo" + instance.mapping_login = "foo" + instance.mapping_firstname = "foo" + instance.mapping_lastname = "foo" + end + + it { is_expected.to be true } + end + end + + describe "#loaded_certificate" do + subject { instance.loaded_certificate } + + before do + instance.certificate = certificate + end + + context "when blank" do + let(:certificate) { nil } + + it { is_expected.to be_nil } + end + + context "when present" do + let(:certificate) { CertificateHelper.valid_certificate.to_pem } + + it { is_expected.to be_a(OpenSSL::X509::Certificate) } + end + + context "when invalid" do + let(:certificate) { "invalid" } + + it "raises an error" do + expect { subject }.to raise_error(OpenSSL::X509::CertificateError) + end + end + end + + describe "#loaded_private_key" do + subject { instance.loaded_private_key } + + before do + instance.private_key = private_key + end + + context "when blank" do + let(:private_key) { nil } + + it { is_expected.to be_nil } + end + + context "when present" do + let(:private_key) { CertificateHelper.private_key.private_to_pem } + + it { is_expected.to be_a(OpenSSL::PKey::RSA) } + end + + context "when invalid" do + let(:private_key) { "invalid" } + + it "raises an error" do + expect { subject }.to raise_error(OpenSSL::PKey::RSAError) + end + end + end + + describe "#loaded_idp_certificates" do + subject { instance.loaded_idp_certificates } + + before do + instance.idp_cert = certificate + end + + context "when blank" do + let(:certificate) { nil } + + it { is_expected.to be_nil } + end + + context "when single" do + let(:certificate) { CertificateHelper.valid_certificate.to_pem } + + it "is an array of one certificate", :aggregate_failures do + expect(subject).to be_a(Array) + expect(subject.count).to eq(1) + expect(subject).to all(be_a(OpenSSL::X509::Certificate)) + end + end + + context "when multi" do + let(:input) { CertificateHelper.valid_certificate.to_pem } + let(:certificate) { "#{input}\n#{input}" } + + it "is an array of two certificates", :aggregate_failures do + expect(subject).to be_a(Array) + expect(subject.count).to eq(2) + expect(subject).to all(be_a(OpenSSL::X509::Certificate)) + end + end + + context "when invalid" do + let(:certificate) { "invalid" } + + it "raises an error" do + expect { subject }.to raise_error(OpenSSL::X509::CertificateError) + end + end + end + + describe "#idp_certificate_valid?" do + subject { instance.idp_certificate_valid? } + + before do + instance.idp_cert = certificate + end + + context "when blank" do + let(:certificate) { nil } + + it { is_expected.to be false } + end + + context "when single valid" do + let(:certificate) { CertificateHelper.valid_certificate.to_pem } + + it { is_expected.to be true } + end + + context "when single expired" do + let(:certificate) { CertificateHelper.expired_certificate.to_pem } + + it { is_expected.to be false } + end + + context "when first valid, second expired" do + let(:valid) { CertificateHelper.valid_certificate.to_pem } + let(:invalid) { CertificateHelper.expired_certificate.to_pem } + let(:certificate) { "#{valid}\n#{invalid}" } + + it { is_expected.to be true } + end + + context "when first expired, second valid" do + let(:valid) { CertificateHelper.valid_certificate.to_pem } + let(:invalid) { CertificateHelper.expired_certificate.to_pem } + let(:certificate) { "#{invalid}\n#{valid}" } + + it { is_expected.to be true } + end + + context "when both expired" do + let(:invalid) { CertificateHelper.expired_certificate.to_pem } + let(:certificate) { "#{invalid}\n#{invalid}" } + + it { is_expected.to be false } + end + end +end diff --git a/modules/auth_saml/spec/requests/saml_provider_callback_spec.rb b/modules/auth_saml/spec/requests/saml_provider_callback_spec.rb index 5a1a32eafb86..4069e63b8226 100644 --- a/modules/auth_saml/spec/requests/saml_provider_callback_spec.rb +++ b/modules/auth_saml/spec/requests/saml_provider_callback_spec.rb @@ -28,7 +28,6 @@ require "spec_helper" require "rack/test" -require_relative "../support/certificate_helper" RSpec.describe "SAML provider callback", type: :rails_request, diff --git a/modules/auth_saml/spec/services/saml/providers/set_attributes_service_spec.rb b/modules/auth_saml/spec/services/saml/providers/set_attributes_service_spec.rb index 7dc74db109b6..76217a775600 100644 --- a/modules/auth_saml/spec/services/saml/providers/set_attributes_service_spec.rb +++ b/modules/auth_saml/spec/services/saml/providers/set_attributes_service_spec.rb @@ -29,7 +29,6 @@ #++ require "spec_helper" -require_relative "../../../support/certificate_helper" require_module_spec_helper RSpec.describe Saml::Providers::SetAttributesService, type: :model do diff --git a/modules/auth_saml/spec/spec_helper.rb b/modules/auth_saml/spec/spec_helper.rb index 4351818bd68d..4ee67b06a691 100644 --- a/modules/auth_saml/spec/spec_helper.rb +++ b/modules/auth_saml/spec/spec_helper.rb @@ -1,2 +1,3 @@ # -- load spec_helper from OpenProject core require "spec_helper" +require_relative "support/certificate_helper" From 005c949b98387c5a06d34d4f64fed3a9c5896931 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 22 Aug 2024 15:53:05 +0200 Subject: [PATCH 67/80] Feature spec --- frontend/src/stimulus/setup.ts | 4 +- .../sections/metadata_form_component.html.erb | 1 + .../sections/show_component.html.erb | 5 +- .../controllers/saml/providers_controller.rb | 6 + .../saml/providers/set_attributes_service.rb | 7 +- .../spec/factories/saml_provider_factory.rb | 1 + .../features/administration/saml_crud_spec.rb | 177 ++++++++++++++++++ .../auth_saml/spec/fixtures/idp_metadata.xml | 23 +++ .../spec/support/certificate_helper.rb | 12 +- 9 files changed, 221 insertions(+), 15 deletions(-) create mode 100644 modules/auth_saml/spec/features/administration/saml_crud_spec.rb create mode 100644 modules/auth_saml/spec/fixtures/idp_metadata.xml diff --git a/frontend/src/stimulus/setup.ts b/frontend/src/stimulus/setup.ts index eb160bfe6943..e1789326e07d 100644 --- a/frontend/src/stimulus/setup.ts +++ b/frontend/src/stimulus/setup.ts @@ -8,8 +8,8 @@ import RefreshOnFormChangesController from './controllers/refresh-on-form-change import AsyncDialogController from './controllers/async-dialog.controller'; import PollForChangesController from './controllers/poll-for-changes.controller'; import TableHighlightingController from './controllers/table-highlighting.controller'; -import OpShowWhenCheckedController from "./controllers/show-when-checked.controller"; -import OpShowWhenValueSelectedController from "./controllers/show-when-value-selected.controller"; +import OpShowWhenCheckedController from './controllers/show-when-checked.controller'; +import OpShowWhenValueSelectedController from './controllers/show-when-value-selected.controller'; declare global { interface Window { diff --git a/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.html.erb index 0e6ef653deb4..aeb532f046ba 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.html.erb @@ -48,6 +48,7 @@ form, provider:, submit_button_options: { label: button_label }, + cancel_button_options: { hidden: edit_mode }, state: :metadata)) end end diff --git a/modules/auth_saml/app/components/saml/providers/sections/show_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/show_component.html.erb index ceb8ae5e22fe..f2777b1de598 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/show_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/sections/show_component.html.erb @@ -1,5 +1,8 @@ <%= - grid_layout('op-saml-view-row', tag: :div, align_items: :center) do |grid| + grid_layout('op-saml-view-row', + tag: :div, + test_selector: "saml_provider_#{@target_state}", + align_items: :center) do |grid| grid.with_area(:title, mr: 3) do concat render(Primer::Beta::Text.new(font_weight: :bold)) { @heading } if @label diff --git a/modules/auth_saml/app/controllers/saml/providers_controller.rb b/modules/auth_saml/app/controllers/saml/providers_controller.rb index 0429fe712500..15889b734b0e 100644 --- a/modules/auth_saml/app/controllers/saml/providers_controller.rb +++ b/modules/auth_saml/app/controllers/saml/providers_controller.rb @@ -152,6 +152,12 @@ def check_ee end end + def default_breadcrumb; end + + def show_local_breadcrumb + false + end + def update_provider_metadata_call Saml::Providers::UpdateService .new(model: @provider, user: User.current) diff --git a/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb b/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb index 322072a72ba4..180c3e61c8de 100644 --- a/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb +++ b/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb @@ -95,12 +95,7 @@ def set_default_creator end def update_idp_cert(cert) - model.idp_cert = - if cert.include?("BEGIN CERTIFICATE") - cert - else - OneLogin::RubySaml::Utils.format_cert(cert) - end + model.idp_cert = cert end def update_certificate(cert) diff --git a/modules/auth_saml/spec/factories/saml_provider_factory.rb b/modules/auth_saml/spec/factories/saml_provider_factory.rb index 848da24df12c..4b9e5d4be6c0 100644 --- a/modules/auth_saml/spec/factories/saml_provider_factory.rb +++ b/modules/auth_saml/spec/factories/saml_provider_factory.rb @@ -31,6 +31,7 @@ FactoryBot.define do factory(:saml_provider, class: "Saml::Provider") do sequence(:display_name) { |n| "Saml Provider #{n}" } + sequence(:slug) { |n| "saml-#{n}" } creator factory: :user available { true } diff --git a/modules/auth_saml/spec/features/administration/saml_crud_spec.rb b/modules/auth_saml/spec/features/administration/saml_crud_spec.rb new file mode 100644 index 000000000000..8ab02e14eda0 --- /dev/null +++ b/modules/auth_saml/spec/features/administration/saml_crud_spec.rb @@ -0,0 +1,177 @@ +#-- 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. +#++ + +require "spec_helper" +require_module_spec_helper + +RSpec.describe "SAML administration CRUD", + :js, + :with_cuprite do + shared_let(:user) { create(:admin) } + + before do + login_as(user) + end + + context "with EE", with_ee: %i[sso_auth_providers] do + it "can manage SAML providers through the UI" do + visit "/admin/saml/providers" + expect(page).to have_text "No SAML providers configured yet." + click_link_or_button "SAML identity provider" + + fill_in "Name", with: "My provider" + click_link_or_button "Continue" + + expect(page).to have_css("h1", text: "My provider") + + # Skip metadata + click_link_or_button "Continue" + + # Fill out configuration + fill_in "Identity provider login endpoint", with: "https://example.com/sso" + fill_in "Identity provider logout endpoint", with: "https://example.com/slo" + fill_in "Public certificate of identity provider", with: CertificateHelper.valid_certificate.to_pem + + click_link_or_button "Continue" + + # Encryption form + check "Sign SAML AuthnRequests" + fill_in "Certificate used by OpenProject for SAML requests", with: CertificateHelper.valid_certificate.to_pem + fill_in "Corresponding private key for OpenProject SAML requests", with: CertificateHelper.private_key.private_to_pem + + click_link_or_button "Continue" + + # Mapping form + fill_in "Mapping for: Username", with: "login\nmail", fill_options: { clear: :backspace } + fill_in "Mapping for: Email", with: "mail", fill_options: { clear: :backspace } + fill_in "Mapping for: First name", with: "myName", fill_options: { clear: :backspace } + fill_in "Mapping for: Last name", with: "myLastName", fill_options: { clear: :backspace } + fill_in "Mapping for: Internal user id", with: "uid", fill_options: { clear: :backspace } + + click_link_or_button "Continue" + + # Skip requested attributes form + click_link_or_button "Finish setup" + + # We're now on the show page + within_test_selector("saml_provider_metadata") do + expect(page).to have_text "Not configured" + end + + within_test_selector("saml_provider_mapping") do + expect(page).to have_text "Completed" + end + + # Back to index + visit "/admin/saml/providers" + expect(page).to have_text "My provider" + expect(page).to have_css(".users", text: 0) + expect(page).to have_css(".creator", text: user.name) + + click_link_or_button "My provider" + + provider = Saml::Provider.find_by!(display_name: "My provider") + expect(provider.slug).to eq "saml-my-provider" + expect(provider.idp_cert.strip.gsub("\r\n", "\n")).to eq CertificateHelper.valid_certificate.to_pem.strip + expect(provider.certificate.strip.gsub("\r\n", "\n")).to eq CertificateHelper.valid_certificate.to_pem.strip + expect(provider.private_key.strip.gsub("\r\n", "\n")).to eq CertificateHelper.private_key.private_to_pem.strip + expect(provider.idp_sso_service_url).to eq "https://example.com/sso" + expect(provider.idp_slo_service_url).to eq "https://example.com/slo" + expect(provider.mapping_login).to eq "login\nmail" + expect(provider.mapping_mail).to eq "mail" + expect(provider.mapping_firstname).to eq "myName" + expect(provider.mapping_lastname).to eq "myLastName" + expect(provider.mapping_uid).to eq "uid" + expect(provider.authn_requests_signed).to be true + + accept_confirm do + click_link_or_button "Delete" + end + + expect(page).to have_text "No SAML providers configured yet." + end + + it "can import metadata from XML" do + visit "/admin/saml/providers/new" + fill_in "Name", with: "My provider" + click_link_or_button "Continue" + + choose "Metadata XML" + + metadata = Rails.root.join("modules/auth_saml/spec/fixtures/idp_metadata.xml").read + fill_in "saml_provider_metadata_xml", with: metadata + + click_link_or_button "Continue" + expect(page).to have_text "This information has been pre-filled using the supplied metadata." + expect(page).to have_field "Service entity ID", with: "http://#{Setting.host_name}/" + expect(page).to have_field "Identity provider login endpoint", with: "https://example.com/login" + expect(page).to have_field "Identity provider logout endpoint", with: "https://example.com/logout" + end + + it "can import metadata from URL", :webmock do + visit "/admin/saml/providers/new" + fill_in "Name", with: "My provider" + click_link_or_button "Continue" + + url = "https://example.com/metadata" + metadata = Rails.root.join("modules/auth_saml/spec/fixtures/idp_metadata.xml").read + stub_request(:get, url).to_return(status: 200, body: metadata) + + choose "Metadata URL" + + fill_in "saml_provider_metadata_url", with: url + + click_link_or_button "Continue" + expect(page).to have_text "This information has been pre-filled using the supplied metadata." + expect(page).to have_field "Service entity ID", with: "http://#{Setting.host_name}/" + expect(page).to have_field "Identity provider login endpoint", with: "https://example.com/login" + expect(page).to have_field "Identity provider logout endpoint", with: "https://example.com/logout" + + expect(WebMock).to have_requested(:get, url) + end + + context "when provider exists already" do + let!(:provider) { create(:saml_provider, display_name: "My provider") } + + it "shows an error trying to use the same name" do + visit "/admin/saml/providers/new" + fill_in "Name", with: "My provider" + click_link_or_button "Continue" + + expect(page).to have_text "Display name has already been taken." + end + end + end + + context "without EE", without_ee: %i[sso_auth_providers] do + it "renders the upsale page" do + visit "/admin/saml/providers" + expect(page).to have_text "SAML identity providers is an Enterprise add-on" + end + end +end diff --git a/modules/auth_saml/spec/fixtures/idp_metadata.xml b/modules/auth_saml/spec/fixtures/idp_metadata.xml new file mode 100644 index 000000000000..9ccda10771d7 --- /dev/null +++ b/modules/auth_saml/spec/fixtures/idp_metadata.xml @@ -0,0 +1,23 @@ + + + + + + MIIDHTCCAgWgAwIBAgIJaPVOFuER4IyxMA0GCSqGSIb3DQEBCwUAMCwxKjAoBgNVBAMTIWRldi10eXQzaDZkNTZjeTB4aXA3LnVzLmF1dGgwLmNvbTAeFw0yMzA0MjUwODU0MThaFw0zNzAxMDEwODU0MThaMCwxKjAoBgNVBAMTIWRldi10eXQzaDZkNTZjeTB4aXA3LnVzLmF1dGgwLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAK/2idoD9sJDzYukmOCVC2Qp4nLm2WGkIbdAudSDzb3hEDVTebAUXeDUqi9nQMtXjS5lvXYVQMIHdq1yRLGciSBviO+qT/FGNRm/VncpWYt0mVvf6t/9Y1PJ1krFlo/72FN3VBEClqAccVryPq+pjwswZMWbgBi3BsMdi+HYW2mgPRKeAemaEYcPwPyUpCBiVQo/6cCSlBB+Kigyp33LAJZVakwNA5pxEfnhUwifiyBzO+xH5m4ol8mcDznsmiBYddxqVQ7qaYds4HSpwSzArdxLnlFj1nVwusJyGsjZD4pCMZuoWXLGPs4ldBgzLH5Dq6UtsCKPZGhRzYA4Au7qVGcCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUrh9OF0EmZv3h3BmcuCF05qLDYokwDgYDVR0PAQH/BAQDAgKEMA0GCSqGSIb3DQEBCwUAA4IBAQAoo/z1LTjEmGrm9ilObVDzPSWlvOqDq1lkP8nrROpYkghDPxXQl+8nJ4J0gKwDP6BgTjWzhMk8MyoeCn8wZGI7oBOeBPOeJCbc6WYW2HGvtc86nO3JHHdvDYMO3HbR9LFoxBXhF95nWwgmPd+9uxdHrdfQ1CIXasATWToQgud6rkCgMcNP25LFds36iMCzXDxKiYvn54JaXmGo567U1Rx6O4AwnCG1RHTdtx/CdiIuPAOBl/xUnnsVeCPQgqNl53/6Qtmk9kg15GeeFDsjJoHoZuLZANaGMUBLRMzLU4al3/SRgPJLaEq46BaRq/oX0WvWlGiVegHNWjpOJ5ZSEeS5 + + + + + + urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress + urn:oasis:names:tc:SAML:2.0:nameid-format:persistent + urn:oasis:names:tc:SAML:2.0:nameid-format:transient + + + + + + + + + diff --git a/modules/auth_saml/spec/support/certificate_helper.rb b/modules/auth_saml/spec/support/certificate_helper.rb index 1574c8e5cdbb..8fcc4aa1daf6 100644 --- a/modules/auth_saml/spec/support/certificate_helper.rb +++ b/modules/auth_saml/spec/support/certificate_helper.rb @@ -12,8 +12,8 @@ def valid_certificate cert.version = 2 cert.serial = 1234 - cert.not_before = Time.now - cert.not_after = Time.now + 606024364.251 + cert.not_before = Time.current + cert.not_after = Time.current + 606024364.251 cert.public_key = private_key.public_key cert.subject = name cert.issuer = name @@ -28,8 +28,8 @@ def expired_certificate cert.version = 2 cert.serial = 1234 - cert.not_before = Time.now - 2.years - cert.not_after = Time.now - 30.days + cert.not_before = 2.years.ago + cert.not_after = 30.days.ago cert.public_key = private_key.public_key cert.subject = name cert.issuer = name @@ -45,8 +45,8 @@ def mismatched_certificate cert.serial = 1234 key = OpenSSL::PKey::RSA.new(1024) - cert.not_before = Time.now - cert.not_after = Time.now + 606024364.251 + cert.not_before = Time.current + cert.not_after = Time.current + 606024364.251 cert.public_key = key.public_key cert.subject = name cert.issuer = name From 6c4497b96231fd330b147a01fe05b396d0364cc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 23 Aug 2024 08:55:40 +0200 Subject: [PATCH 68/80] Linting --- lib_static/redmine/i18n.rb | 2 +- .../app/components/saml/providers/row_component.rb | 7 +++++++ .../saml/providers/sections/section_component.rb | 1 + .../app/models/saml/provider/hash_builder.rb | 2 +- .../saml/providers/set_attributes_service.rb | 14 +++++++++----- .../seeders/env_data/saml/provider_seeder_spec.rb | 6 +++--- .../auth_saml/spec/support/certificate_helper.rb | 8 ++++++++ 7 files changed, 30 insertions(+), 10 deletions(-) diff --git a/lib_static/redmine/i18n.rb b/lib_static/redmine/i18n.rb index 2284a31fb1a3..01179d7dd3b1 100644 --- a/lib_static/redmine/i18n.rb +++ b/lib_static/redmine/i18n.rb @@ -97,7 +97,7 @@ def link_translate(i18n_key, links: {}, locale: ::I18n.locale, target: nil) result = translation.scan(link_regex).inject(translation) do |t, matches| link, text, key = matches href = String(links[key.to_sym]) - link_tag = content_tag(:a, text, href: href, target: target) + link_tag = content_tag(:a, text, href:, target:) t.sub(link, link_tag) end diff --git a/modules/auth_saml/app/components/saml/providers/row_component.rb b/modules/auth_saml/app/components/saml/providers/row_component.rb index 50005ef98586..39a8ed02650d 100644 --- a/modules/auth_saml/app/components/saml/providers/row_component.rb +++ b/modules/auth_saml/app/components/saml/providers/row_component.rb @@ -19,10 +19,17 @@ def name href: url_for(action: :show, id: provider.id) )) { provider.display_name || provider.name } + render_availability_label + render_idp_sso_service_url + end + + def render_availability_label unless provider.available? concat render(Primer::Beta::Label.new(ml: 2, scheme: :attention, size: :medium)) { t(:label_incomplete) } end + end + def render_idp_sso_service_url if provider.idp_sso_service_url concat render(Primer::Beta::Text.new( tag: :p, diff --git a/modules/auth_saml/app/components/saml/providers/sections/section_component.rb b/modules/auth_saml/app/components/saml/providers/sections/section_component.rb index acad97d22cd9..8f43d64b0e44 100644 --- a/modules/auth_saml/app/components/saml/providers/sections/section_component.rb +++ b/modules/auth_saml/app/components/saml/providers/sections/section_component.rb @@ -35,6 +35,7 @@ class SectionComponent < ApplicationComponent attr_reader :provider def initialize(provider) + super() @provider = provider end end diff --git a/modules/auth_saml/app/models/saml/provider/hash_builder.rb b/modules/auth_saml/app/models/saml/provider/hash_builder.rb index cc02b2c83619..574556356a86 100644 --- a/modules/auth_saml/app/models/saml/provider/hash_builder.rb +++ b/modules/auth_saml/app/models/saml/provider/hash_builder.rb @@ -60,7 +60,7 @@ def security_options_hash }.compact end - def to_h + def to_h # rubocop:disable Metrics/AbcSize { name: slug, display_name:, diff --git a/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb b/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb index 180c3e61c8de..2481c4c2964a 100644 --- a/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb +++ b/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb @@ -127,11 +127,11 @@ def set_default_mapping model.mapping_lastname ||= Saml::Defaults::LASTNAME_MAPPING end - def set_default_requested_attributes - model.requested_login_attribute ||= Saml::Defaults::MAIL_MAPPING.split("\n").first - model.requested_mail_attribute ||= Saml::Defaults::MAIL_MAPPING.split("\n").first - model.requested_firstname_attribute ||= Saml::Defaults::FIRSTNAME_MAPPING.split("\n").first - model.requested_lastname_attribute ||= Saml::Defaults::LASTNAME_MAPPING.split("\n").first + def set_default_requested_attributes # rubocop:disable Metrics/AbcSize,Metrics/PerceivedComplexity + model.requested_login_attribute ||= first_mapping(Saml::Defaults::MAIL_MAPPING) + model.requested_mail_attribute ||= first_mapping(Saml::Defaults::MAIL_MAPPING) + model.requested_firstname_attribute ||= first_mapping(Saml::Defaults::FIRSTNAME_MAPPING) + model.requested_lastname_attribute ||= first_mapping(Saml::Defaults::LASTNAME_MAPPING) model.requested_login_format ||= Saml::Defaults::ATTRIBUTE_FORMATS.first model.requested_mail_format ||= Saml::Defaults::ATTRIBUTE_FORMATS.first @@ -139,6 +139,10 @@ def set_default_requested_attributes model.requested_lastname_format ||= Saml::Defaults::ATTRIBUTE_FORMATS.first end + def first_mapping(mapping) + mapping.split("\n").first + end + def set_issuer model.sp_entity_id ||= OpenProject::StaticRouting::StaticUrlHelpers.new.root_url end diff --git a/modules/auth_saml/spec/seeders/env_data/saml/provider_seeder_spec.rb b/modules/auth_saml/spec/seeders/env_data/saml/provider_seeder_spec.rb index 23f1681f67ff..7fb41c26db90 100644 --- a/modules/auth_saml/spec/seeders/env_data/saml/provider_seeder_spec.rb +++ b/modules/auth_saml/spec/seeders/env_data/saml/provider_seeder_spec.rb @@ -57,7 +57,7 @@ OPENPROJECT_SAML_SAML_ASSERTION__CONSUMER__SERVICE__URL: "some wrong value", OPENPROJECT_SAML_SAML_ASSERTION__CONSUMER__SERVICE__BINDING: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", OPENPROJECT_SAML_SAML_SP__ENTITY__ID: "http://localhost:3000", - OPENPROJECT_SAML_SAML_IDP__CERT: "MIIDHTCCAgWgAwIBAgIJaPVOFuER4IyxMA0GCSqGSIb3DQEBCwUAMCwxKjAoBgNVBAMTIWRldi10eXQzaDZkNTZjeTB4aXA3LnVzLmF1dGgwLmNvbTAeFw0yMzA0MjUwODU0MThaFw0zNzAxMDEwODU0MThaMCwxKjAoBgNVBAMTIWRldi10eXQzaDZkNTZjeTB4aXA3LnVzLmF1dGgwLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAK/2idoD9sJDzYukmOCVC2Qp4nLm2WGkIbdAudSDzb3hEDVTebAUXeDUqi9nQMtXjS5lvXYVQMIHdq1yRLGciSBviO+qT/FGNRm/VncpWYt0mVvf6t/9Y1PJ1krFlo/72FN3VBEClqAccVryPq+pjwswZMWbgBi3BsMdi+HYW2mgPRKeAemaEYcPwPyUpCBiVQo/6cCSlBB+Kigyp33LAJZVakwNA5pxEfnhUwifiyBzO+xH5m4ol8mcDznsmiBYddxqVQ7qaYds4HSpwSzArdxLnlFj1nVwusJyGsjZD4pCMZuoWXLGPs4ldBgzLH5Dq6UtsCKPZGhRzYA4Au7qVGcCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUrh9OF0EmZv3h3BmcuCF05qLDYokwDgYDVR0PAQH/BAQDAgKEMA0GCSqGSIb3DQEBCwUAA4IBAQAoo/z1LTjEmGrm9ilObVDzPSWlvOqDq1lkP8nrROpYkghDPxXQl+8nJ4J0gKwDP6BgTjWzhMk8MyoeCn8wZGI7oBOeBPOeJCbc6WYW2HGvtc86nO3JHHdvDYMO3HbR9LFoxBXhF95nWwgmPd+9uxdHrdfQ1CIXasATWToQgud6rkCgMcNP25LFds36iMCzXDxKiYvn54JaXmGo567U1Rx6O4AwnCG1RHTdtx/CdiIuPAOBl/xUnnsVeCPQgqNl53/6Qtmk9kg15GeeFDsjJoHoZuLZANaGMUBLRMzLU4al3/SRgPJLaEq46BaRq/oX0WvWlGiVegHNWjpOJ5ZSEeS5", + OPENPROJECT_SAML_SAML_IDP__CERT: CertificateHelper.valid_certificate.to_pem, OPENPROJECT_SAML_SAML_IDP__SSO__TARGET__URL: "https://saml.example.com/samlp/tPLrXaiBZhLNtzBMN8oNFQAzhFdGlUPK", OPENPROJECT_SAML_SAML_IDP__SLO__TARGET__URL: "https://saml.example.com/samlp/tPLrXaiBZhLNtzBMN8oNFQAzhFdGlUPK/logout", OPENPROJECT_SAML_SAML_ATTRIBUTE__STATEMENTS_EMAIL: "['mail', 'urn:oid:2.5.4.42', 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']", @@ -117,7 +117,7 @@ OPENPROJECT_SAML_SAML_ASSERTION__CONSUMER__SERVICE__URL: "some wrong value", OPENPROJECT_SAML_SAML_ASSERTION__CONSUMER__SERVICE__BINDING: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", OPENPROJECT_SAML_SAML_SP__ENTITY__ID: "http://localhost:3000", - OPENPROJECT_SAML_SAML_IDP__CERT: "MIIDHTCCAgWgAwIBAgIJaPVOFuER4IyxMA0GCSqGSIb3DQEBCwUAMCwxKjAoBgNVBAMTIWRldi10eXQzaDZkNTZjeTB4aXA3LnVzLmF1dGgwLmNvbTAeFw0yMzA0MjUwODU0MThaFw0zNzAxMDEwODU0MThaMCwxKjAoBgNVBAMTIWRldi10eXQzaDZkNTZjeTB4aXA3LnVzLmF1dGgwLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAK/2idoD9sJDzYukmOCVC2Qp4nLm2WGkIbdAudSDzb3hEDVTebAUXeDUqi9nQMtXjS5lvXYVQMIHdq1yRLGciSBviO+qT/FGNRm/VncpWYt0mVvf6t/9Y1PJ1krFlo/72FN3VBEClqAccVryPq+pjwswZMWbgBi3BsMdi+HYW2mgPRKeAemaEYcPwPyUpCBiVQo/6cCSlBB+Kigyp33LAJZVakwNA5pxEfnhUwifiyBzO+xH5m4ol8mcDznsmiBYddxqVQ7qaYds4HSpwSzArdxLnlFj1nVwusJyGsjZD4pCMZuoWXLGPs4ldBgzLH5Dq6UtsCKPZGhRzYA4Au7qVGcCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUrh9OF0EmZv3h3BmcuCF05qLDYokwDgYDVR0PAQH/BAQDAgKEMA0GCSqGSIb3DQEBCwUAA4IBAQAoo/z1LTjEmGrm9ilObVDzPSWlvOqDq1lkP8nrROpYkghDPxXQl+8nJ4J0gKwDP6BgTjWzhMk8MyoeCn8wZGI7oBOeBPOeJCbc6WYW2HGvtc86nO3JHHdvDYMO3HbR9LFoxBXhF95nWwgmPd+9uxdHrdfQ1CIXasATWToQgud6rkCgMcNP25LFds36iMCzXDxKiYvn54JaXmGo567U1Rx6O4AwnCG1RHTdtx/CdiIuPAOBl/xUnnsVeCPQgqNl53/6Qtmk9kg15GeeFDsjJoHoZuLZANaGMUBLRMzLU4al3/SRgPJLaEq46BaRq/oX0WvWlGiVegHNWjpOJ5ZSEeS5", + OPENPROJECT_SAML_SAML_IDP__CERT: CertificateHelper.non_padded_string(:valid_certificate), OPENPROJECT_SAML_SAML_IDP__SSO__TARGET__URL: "https://saml.example.com/samlp/tPLrXaiBZhLNtzBMN8oNFQAzhFdGlUPK", OPENPROJECT_SAML_SAML_IDP__SLO__TARGET__URL: "https://saml.example.com/samlp/tPLrXaiBZhLNtzBMN8oNFQAzhFdGlUPK/logout", OPENPROJECT_SAML_SAML_ATTRIBUTE__STATEMENTS_EMAIL: "['mail', 'urn:oid:2.5.4.42', 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']", @@ -129,7 +129,7 @@ OPENPROJECT_SAML_MYSAML_ASSERTION__CONSUMER__SERVICE__URL: "some wrong value", OPENPROJECT_SAML_MYSAML_ASSERTION__CONSUMER__SERVICE__BINDING: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", OPENPROJECT_SAML_MYSAML_SP__ENTITY__ID: "http://localhost:3000", - OPENPROJECT_SAML_MYSAML_IDP__CERT: "MIIDHTCCAgWgAwIBAgIJaPVOFuER4IyxMA0GCSqGSIb3DQEBCwUAMCwxKjAoBgNVBAMTIWRldi10eXQzaDZkNTZjeTB4aXA3LnVzLmF1dGgwLmNvbTAeFw0yMzA0MjUwODU0MThaFw0zNzAxMDEwODU0MThaMCwxKjAoBgNVBAMTIWRldi10eXQzaDZkNTZjeTB4aXA3LnVzLmF1dGgwLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAK/2idoD9sJDzYukmOCVC2Qp4nLm2WGkIbdAudSDzb3hEDVTebAUXeDUqi9nQMtXjS5lvXYVQMIHdq1yRLGciSBviO+qT/FGNRm/VncpWYt0mVvf6t/9Y1PJ1krFlo/72FN3VBEClqAccVryPq+pjwswZMWbgBi3BsMdi+HYW2mgPRKeAemaEYcPwPyUpCBiVQo/6cCSlBB+Kigyp33LAJZVakwNA5pxEfnhUwifiyBzO+xH5m4ol8mcDznsmiBYddxqVQ7qaYds4HSpwSzArdxLnlFj1nVwusJyGsjZD4pCMZuoWXLGPs4ldBgzLH5Dq6UtsCKPZGhRzYA4Au7qVGcCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUrh9OF0EmZv3h3BmcuCF05qLDYokwDgYDVR0PAQH/BAQDAgKEMA0GCSqGSIb3DQEBCwUAA4IBAQAoo/z1LTjEmGrm9ilObVDzPSWlvOqDq1lkP8nrROpYkghDPxXQl+8nJ4J0gKwDP6BgTjWzhMk8MyoeCn8wZGI7oBOeBPOeJCbc6WYW2HGvtc86nO3JHHdvDYMO3HbR9LFoxBXhF95nWwgmPd+9uxdHrdfQ1CIXasATWToQgud6rkCgMcNP25LFds36iMCzXDxKiYvn54JaXmGo567U1Rx6O4AwnCG1RHTdtx/CdiIuPAOBl/xUnnsVeCPQgqNl53/6Qtmk9kg15GeeFDsjJoHoZuLZANaGMUBLRMzLU4al3/SRgPJLaEq46BaRq/oX0WvWlGiVegHNWjpOJ5ZSEeS5", + OPENPROJECT_SAML_MYSAML_IDP__CERT: CertificateHelper.non_padded_string(:valid_certificate), OPENPROJECT_SAML_MYSAML_IDP__SSO__TARGET__URL: "https://saml.example.com/samlp/tPLrXaiBZhLNtzBMN8oNFQAzhFdGlUPK", OPENPROJECT_SAML_MYSAML_IDP__SLO__TARGET__URL: "https://saml.example.com/samlp/tPLrXaiBZhLNtzBMN8oNFQAzhFdGlUPK/logout", OPENPROJECT_SAML_MYSAML_ATTRIBUTE__STATEMENTS_EMAIL: "['mail', 'urn:oid:2.5.4.42', 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']", diff --git a/modules/auth_saml/spec/support/certificate_helper.rb b/modules/auth_saml/spec/support/certificate_helper.rb index 8fcc4aa1daf6..19d0ce58634e 100644 --- a/modules/auth_saml/spec/support/certificate_helper.rb +++ b/modules/auth_saml/spec/support/certificate_helper.rb @@ -5,6 +5,14 @@ def private_key @private_key ||= OpenSSL::PKey::RSA.new(1024) end + def non_padded_string(certificate_name) + public_send(certificate_name) + .to_pem + .gsub("-----BEGIN CERTIFICATE-----", "") + .gsub("-----END CERTIFICATE-----", "") + .strip + end + def valid_certificate @valid_certificate ||= begin name = OpenSSL::X509::Name.parse "/CN=valid-testing" From 7727641d14a0811831d176512456ae9e006a3360 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 23 Aug 2024 09:51:50 +0200 Subject: [PATCH 69/80] Add metadata spec --- .../app/models/saml/provider/hash_builder.rb | 8 +- .../spec/factories/saml_provider_factory.rb | 21 ++++ .../requests/saml_metadata_endpoint_spec.rb | 116 ++++++++++++++++++ .../spec/support/certificate_helper.rb | 1 + 4 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 modules/auth_saml/spec/requests/saml_metadata_endpoint_spec.rb diff --git a/modules/auth_saml/app/models/saml/provider/hash_builder.rb b/modules/auth_saml/app/models/saml/provider/hash_builder.rb index 574556356a86..926bdcee4ee4 100644 --- a/modules/auth_saml/app/models/saml/provider/hash_builder.rb +++ b/modules/auth_saml/app/models/saml/provider/hash_builder.rb @@ -18,10 +18,10 @@ def split_attribute_mapping(mapping) def formatted_request_attributes [ - { name: requested_login_attribute, format: requested_login_format, friendly_name: "Login" }, - { name: requested_mail_attribute, format: requested_mail_format, friendly_name: "Email" }, - { name: requested_firstname_attribute, format: requested_firstname_format, friendly_name: "First Name" }, - { name: requested_lastname_attribute, format: requested_lastname_format, friendly_name: "Last Name" } + { name: requested_login_attribute, name_format: requested_login_format, friendly_name: "Login" }, + { name: requested_mail_attribute, name_format: requested_mail_format, friendly_name: "Email" }, + { name: requested_firstname_attribute, name_format: requested_firstname_format, friendly_name: "First Name" }, + { name: requested_lastname_attribute, name_format: requested_lastname_format, friendly_name: "Last Name" } ] end diff --git a/modules/auth_saml/spec/factories/saml_provider_factory.rb b/modules/auth_saml/spec/factories/saml_provider_factory.rb index 4b9e5d4be6c0..a53c2c6857c6 100644 --- a/modules/auth_saml/spec/factories/saml_provider_factory.rb +++ b/modules/auth_saml/spec/factories/saml_provider_factory.rb @@ -47,5 +47,26 @@ mapping_mail { Saml::Defaults::MAIL_MAPPING } mapping_firstname { Saml::Defaults::FIRSTNAME_MAPPING } mapping_lastname { Saml::Defaults::LASTNAME_MAPPING } + + trait :with_requested_attributes do + requested_mail_attribute { Saml::Defaults::MAIL_MAPPING.split("\n").first.strip } + requested_login_attribute { Saml::Defaults::MAIL_MAPPING.split("\n").first.strip } + requested_firstname_attribute { Saml::Defaults::FIRSTNAME_MAPPING.split("\n").first.strip } + requested_lastname_attribute { Saml::Defaults::LASTNAME_MAPPING.split("\n").first.strip } + requested_login_format { Saml::Defaults::ATTRIBUTE_FORMATS.first } + requested_mail_format { Saml::Defaults::ATTRIBUTE_FORMATS.first } + requested_firstname_format { Saml::Defaults::ATTRIBUTE_FORMATS.first } + requested_lastname_format { Saml::Defaults::ATTRIBUTE_FORMATS.first } + end + + trait :with_encryption do + certificate { CertificateHelper.valid_certificate.to_pem } + private_key { CertificateHelper.private_key.to_pem } + authn_requests_signed { true } + want_assertions_signed { true } + want_assertions_encrypted { true } + digest_method { Saml::Defaults::DIGEST_METHODS["SHA-256"] } + signature_method { Saml::Defaults::SIGNATURE_METHODS["RSA SHA-256"] } + end end end diff --git a/modules/auth_saml/spec/requests/saml_metadata_endpoint_spec.rb b/modules/auth_saml/spec/requests/saml_metadata_endpoint_spec.rb new file mode 100644 index 000000000000..eeab93f846ea --- /dev/null +++ b/modules/auth_saml/spec/requests/saml_metadata_endpoint_spec.rb @@ -0,0 +1,116 @@ +#-- 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. +#++ + +require "spec_helper" +require "rack/test" + +RSpec.describe "SAML metadata endpoint", with_ee: %i[sso_auth_providers] do + include Rack::Test::Methods + include API::V3::Utilities::PathHelper + + subject do + temp = Nokogiri::XML(last_response.body) + + # The ds prefix is not defined on root, + # which Nokogiri complains about + temp.root["xmlns:ds"] = "http://www.w3.org/2000/09/xmldsig#" + + Nokogiri::XML(temp.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML)) + end + + before do + provider + get "/auth/saml/metadata" + end + + context "with basic provider" do + let(:provider) do + create(:saml_provider, slug: "saml") + end + + it "returns the metadata" do + expect(last_response).to be_successful + expect(subject.at_xpath("//md:EntityDescriptor")["entityID"]).to eq "http://test.host" + + sso = subject.at_xpath("//md:SPSSODescriptor") + expect(sso["AuthnRequestsSigned"]).to eq "false" + expect(sso["WantAssertionsSigned"]).to eq "false" + + consumer = sso.at_xpath("//md:AssertionConsumerService") + expect(consumer["Binding"]).to eq "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" + expect(consumer["Location"]).to eq "http://test.host/auth/saml/callback" + end + end + + context "with elaborate provider" do + let(:provider) do + create(:saml_provider, + :with_encryption, + :with_requested_attributes, + slug: "saml") + end + + it "returns the metadata", :aggregate_failures do # rubocop:disable RSpec/ExampleLength + expect(last_response).to be_successful + expect(subject.at_xpath("//md:EntityDescriptor")["entityID"]).to eq "http://test.host" + + sso = subject.at_xpath("//md:SPSSODescriptor") + expect(sso["AuthnRequestsSigned"]).to eq "true" + expect(sso["WantAssertionsSigned"]).to eq "true" + + # Expect signature present + signature = subject.at_xpath("//ds:Signature") + expect(signature.at_xpath("//ds:SignatureMethod")["Algorithm"]).to eq "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" + expect(signature.at_xpath("//ds:DigestMethod")["Algorithm"]).to eq "http://www.w3.org/2001/04/xmlenc#sha256" + + expect(signature.at_xpath("//ds:DigestValue")).to be_present + + signing = signature.at_xpath("//md:KeyDescriptor[@use='signing']/ds:KeyInfo/ds:X509Data/ds:X509Certificate").text + expect(signing).to eq CertificateHelper.non_padded_string(:valid_certificate) + + encryption = signature.at_xpath("//md:KeyDescriptor[@use='encryption']/ds:KeyInfo/ds:X509Data/ds:X509Certificate").text + expect(encryption).to eq CertificateHelper.non_padded_string(:valid_certificate) + + consumer = sso.at_xpath("//md:AssertionConsumerService") + expect(consumer["Binding"]).to eq "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" + expect(consumer["Location"]).to eq "http://test.host/auth/saml/callback" + + consuming = consumer.at_xpath("//md:AttributeConsumingService") + requested = consuming.xpath("md:RequestedAttribute") + attributes = requested.map { |x| [x["FriendlyName"], x["Name"]] } + expect(attributes).to contain_exactly ["Login", "mail"], + ["First Name", "givenName"], + ["Last Name", "sn"], + ["Email", "mail"] + + requested.each do |attr| + expect(attr["NameFormat"]).to eq "urn:oasis:names:tc:SAML:2.0:attrname-format:basic" + end + end + end +end diff --git a/modules/auth_saml/spec/support/certificate_helper.rb b/modules/auth_saml/spec/support/certificate_helper.rb index 19d0ce58634e..85bf1efe6f28 100644 --- a/modules/auth_saml/spec/support/certificate_helper.rb +++ b/modules/auth_saml/spec/support/certificate_helper.rb @@ -10,6 +10,7 @@ def non_padded_string(certificate_name) .to_pem .gsub("-----BEGIN CERTIFICATE-----", "") .gsub("-----END CERTIFICATE-----", "") + .delete("\n") .strip end From 8c4ef34aee3acbabf8c0bb7a4860f38d73b5e9e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 23 Aug 2024 10:56:30 +0200 Subject: [PATCH 70/80] Add information to side panel --- .../copy_to_clipboard_component.html.erb | 21 +++++++++ .../op_primer/copy_to_clipboard_component.rb | 43 +++++++++++++++++++ .../copy_to_clipboard_component_preview.rb | 16 +++++++ .../metadata_dialog_component.html.erb | 40 ----------------- .../providers/metadata_dialog_component.rb | 15 ------- .../side_panel/information_component.html.erb | 24 +++++++++++ .../side_panel/information_component.rb | 39 +++++++++++++++++ .../side_panel/metadata_component.html.erb | 17 +++++--- .../side_panel/metadata_component.rb | 8 +--- .../providers/side_panel_component.html.erb | 3 +- .../saml/providers/view_component.html.erb | 17 +------- .../controllers/saml/providers_controller.rb | 9 +--- modules/auth_saml/config/locales/en.yml | 7 ++- modules/auth_saml/config/routes.rb | 1 - 14 files changed, 166 insertions(+), 94 deletions(-) create mode 100644 app/components/op_primer/copy_to_clipboard_component.html.erb create mode 100644 app/components/op_primer/copy_to_clipboard_component.rb create mode 100644 lookbook/previews/op_primer/copy_to_clipboard_component_preview.rb delete mode 100644 modules/auth_saml/app/components/saml/providers/metadata_dialog_component.html.erb delete mode 100644 modules/auth_saml/app/components/saml/providers/metadata_dialog_component.rb create mode 100644 modules/auth_saml/app/components/saml/providers/side_panel/information_component.html.erb create mode 100644 modules/auth_saml/app/components/saml/providers/side_panel/information_component.rb diff --git a/app/components/op_primer/copy_to_clipboard_component.html.erb b/app/components/op_primer/copy_to_clipboard_component.html.erb new file mode 100644 index 000000000000..0546b100c46c --- /dev/null +++ b/app/components/op_primer/copy_to_clipboard_component.html.erb @@ -0,0 +1,21 @@ +<%= + flex_layout(align_items: :center, **@system_arguments) do |flex| + if @scheme == :link + flex.with_column(classes: "ellipsis") do + render(Primer::Beta::Link.new( + id: @id, + href: value, + title: value, + target: :_blank + )) { value } + end + else + flex.with_column(classes: "ellipsis") do + render(Primer::Beta::Text.new(title: value)) { value } + end + end + flex.with_column(ml: 1) do + render(Primer::Beta::ClipboardCopy.new(value:, "aria-label": t(:button_copy_to_clipboard))) + end + end +%> diff --git a/app/components/op_primer/copy_to_clipboard_component.rb b/app/components/op_primer/copy_to_clipboard_component.rb new file mode 100644 index 000000000000..c892938dc53f --- /dev/null +++ b/app/components/op_primer/copy_to_clipboard_component.rb @@ -0,0 +1,43 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 OpPrimer + class CopyToClipboardComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + alias_method :value, :model + + def initialize(value = nil, scheme: :value, **system_arguments) + super(value) + + @scheme = scheme + @system_arguments = system_arguments + @id = SecureRandom.hex(8) + end + end +end diff --git a/lookbook/previews/op_primer/copy_to_clipboard_component_preview.rb b/lookbook/previews/op_primer/copy_to_clipboard_component_preview.rb new file mode 100644 index 000000000000..971ad18ac71a --- /dev/null +++ b/lookbook/previews/op_primer/copy_to_clipboard_component_preview.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module OpPrimer + # @logical_path OpenProject/Primer + class CopyToClipboardComponentPreview < Lookbook::Preview + # @param value text + def default(value: "Copy me!") + render(OpPrimer::CopyToClipboardComponent.new(value)) + end + + # @param url text + def as_link(url: "http://example.org") + render(OpPrimer::CopyToClipboardComponent.new(url, scheme: :link)) + end + end +end diff --git a/modules/auth_saml/app/components/saml/providers/metadata_dialog_component.html.erb b/modules/auth_saml/app/components/saml/providers/metadata_dialog_component.html.erb deleted file mode 100644 index 68b1fe14c033..000000000000 --- a/modules/auth_saml/app/components/saml/providers/metadata_dialog_component.html.erb +++ /dev/null @@ -1,40 +0,0 @@ -<%= - render(Primer::Alpha::Dialog.new( - id:, - title: t("saml.providers.label_metadata_endpoint"), - size: :large - )) do |d| - d.with_header - d.with_body do - flex_layout do |flex| - flex.with_row(mb: 4) do - t("saml.providers.metadata.dialog") - end - - flex.with_row do - render(Primer::OpenProject::InputGroup.new) do |input_group| - input_group.with_text_input(name: :metadata_endpoint, - label: t("saml.providers.label_metadata_endpoint"), - visually_hide_label: true, - value: provider.metadata_endpoint) - input_group.with_trailing_action_clipboard_copy_button( - value: provider.metadata_endpoint, - aria: { - label: I18n.t('button_copy_to_clipboard') - }) - end - end - - flex.with_row(justify_items: :flex_end) do - - end - end - end - - d.with_footer do - render(Primer::ButtonComponent.new(data: { 'close-dialog-id': id })) do - I18n.t("button_close") - end - end - end -%> diff --git a/modules/auth_saml/app/components/saml/providers/metadata_dialog_component.rb b/modules/auth_saml/app/components/saml/providers/metadata_dialog_component.rb deleted file mode 100644 index 016e9a5a7558..000000000000 --- a/modules/auth_saml/app/components/saml/providers/metadata_dialog_component.rb +++ /dev/null @@ -1,15 +0,0 @@ -module Saml - module Providers - class MetadataDialogComponent < ApplicationComponent - include ApplicationHelper - include OpTurbo::Streamable - include OpPrimer::ComponentHelpers - - alias_method :provider, :model - - def id - "saml-metadata-dialog" - end - end - end -end diff --git a/modules/auth_saml/app/components/saml/providers/side_panel/information_component.html.erb b/modules/auth_saml/app/components/saml/providers/side_panel/information_component.html.erb new file mode 100644 index 000000000000..7a38adc481b3 --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/side_panel/information_component.html.erb @@ -0,0 +1,24 @@ +<%= + render(Primer::OpenProject::SidePanel::Section.new) do |section| + section.with_title { I18n.t("saml.providers.label_openproject_information") } + section.with_description { I18n.t("saml.instructions.metadata_for_idp") } + + component_collection do |collection| + collection.with_component(Primer::Beta::Heading.new(tag: :h5, mb: 1)) do + I18n.t("activemodel.attributes.saml/provider.sp_entity_id") + end + + collection.with_component( + OpPrimer::CopyToClipboardComponent.new(provider.sp_entity_id, scheme: :input) + ) + + collection.with_component(Primer::Beta::Heading.new(tag: :h5, mt: 4, mb: 1)) do + I18n.t("activemodel.attributes.saml/provider.assertion_consumer_service_url") + end + + collection.with_component( + OpPrimer::CopyToClipboardComponent.new(provider.callback_url, scheme: :link) + ) + end + end +%> diff --git a/modules/auth_saml/app/components/saml/providers/side_panel/information_component.rb b/modules/auth_saml/app/components/saml/providers/side_panel/information_component.rb new file mode 100644 index 000000000000..1d509fda9fd8 --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/side_panel/information_component.rb @@ -0,0 +1,39 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Saml::Providers + module SidePanel + class InformationComponent < ApplicationComponent + include ApplicationHelper + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + alias_method :provider, :model + end + end +end diff --git a/modules/auth_saml/app/components/saml/providers/side_panel/metadata_component.html.erb b/modules/auth_saml/app/components/saml/providers/side_panel/metadata_component.html.erb index 51d7f2b7e180..f8989607f588 100644 --- a/modules/auth_saml/app/components/saml/providers/side_panel/metadata_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/side_panel/metadata_component.html.erb @@ -3,12 +3,17 @@ section.with_title { I18n.t("saml.providers.label_metadata_endpoint") } section.with_description { I18n.t("saml.instructions.sp_metadata_endpoint") } - component_wrapper do - render(Primer::Beta::Link.new( - href: metadata_endpoint, - classes: "-break-word", - target: :_blank - )) { metadata_endpoint } + flex_layout do |flex| + flex.with_column(classes: "ellipsis") do + render(Primer::Beta::Link.new( + href: metadata_endpoint, + title: metadata_endpoint, + target: :_blank + )) { metadata_endpoint } + end + flex.with_column(ml: 1) do + render(Primer::Beta::ClipboardCopy.new(value: metadata_endpoint, "aria-label": t(:button_copy_to_clipboard))) + end end end %> diff --git a/modules/auth_saml/app/components/saml/providers/side_panel/metadata_component.rb b/modules/auth_saml/app/components/saml/providers/side_panel/metadata_component.rb index b04fd9e7709a..9affbd43f2da 100644 --- a/modules/auth_saml/app/components/saml/providers/side_panel/metadata_component.rb +++ b/modules/auth_saml/app/components/saml/providers/side_panel/metadata_component.rb @@ -33,14 +33,10 @@ class MetadataComponent < ApplicationComponent include OpTurbo::Streamable include OpPrimer::ComponentHelpers - def initialize(provider:) - super() - - @provider = provider - end + alias_method :provider, :model def metadata_endpoint - URI.join(helpers.root_url, "/auth/#{@provider.slug}/metadata").to_s + URI.join(helpers.root_url, "/auth/#{provider.slug}/metadata").to_s end end end diff --git a/modules/auth_saml/app/components/saml/providers/side_panel_component.html.erb b/modules/auth_saml/app/components/saml/providers/side_panel_component.html.erb index d2182f1a908f..c9d27ca3a71f 100644 --- a/modules/auth_saml/app/components/saml/providers/side_panel_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/side_panel_component.html.erb @@ -2,7 +2,8 @@ component_wrapper do render(Primer::OpenProject::SidePanel.new(spacious: true)) do |panel| [ - Saml::Providers::SidePanel::MetadataComponent.new(provider: @provider), + Saml::Providers::SidePanel::MetadataComponent.new(@provider), + Saml::Providers::SidePanel::InformationComponent.new(@provider), ].each do |component| panel.with_section(component) end diff --git a/modules/auth_saml/app/components/saml/providers/view_component.html.erb b/modules/auth_saml/app/components/saml/providers/view_component.html.erb index 0e2c86ad2db3..0b69b8c522aa 100644 --- a/modules/auth_saml/app/components/saml/providers/view_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/view_component.html.erb @@ -44,26 +44,13 @@ edit_mode:, )) else - dialog_button = - if provider.persisted? - Primer::Beta::Button.new( - tag: :a, - href: show_metadata_saml_provider_path(provider), - scheme: :invisible, - color: :subtle, - 'aria-label': t('saml.providers.label_show_metadata'), - data: { turbo: true, 'turbo-stream': true } - ).with_content(t('saml.providers.label_show_metadata')) - end - render(Saml::Providers::Sections::ShowComponent.new( provider, target_state: :metadata, view_mode:, - action: dialog_button, heading: t("saml.providers.label_metadata"), description: t("saml.providers.section_texts.metadata"), - label: provider.has_metadata? ? t(:label_completed) : t(:label_not_configured), + label: !provider.has_metadata? ? t(:label_completed) : t(:label_not_configured), label_scheme: provider.has_metadata? ? :success : :secondary )) end @@ -139,7 +126,7 @@ view_mode:, heading: t("saml.providers.label_mapping"), description: t("saml.providers.section_texts.mapping"), - label: provider.mapping_configured? ? t(:label_completed) : t(:label_incomplete), + label: provider.mapping_configured? ? nil : t(:label_incomplete), label_scheme: provider.mapping_configured? ? :success : :attention )) end diff --git a/modules/auth_saml/app/controllers/saml/providers_controller.rb b/modules/auth_saml/app/controllers/saml/providers_controller.rb index 15889b734b0e..8d022fc98a8b 100644 --- a/modules/auth_saml/app/controllers/saml/providers_controller.rb +++ b/modules/auth_saml/app/controllers/saml/providers_controller.rb @@ -1,14 +1,13 @@ module Saml class ProvidersController < ::ApplicationController include OpTurbo::ComponentStream - include OpTurbo::DialogStreamHelper layout "admin" menu_item :plugin_saml before_action :require_admin before_action :check_ee - before_action :find_provider, only: %i[show edit show_metadata import_metadata update destroy] + before_action :find_provider, only: %i[show edit import_metadata update destroy] before_action :check_provider_writable, only: %i[update import_metadata] before_action :set_edit_state, only: %i[create edit update import_metadata] @@ -68,12 +67,6 @@ def import_metadata end end - def show_metadata - respond_with_dialog( - Saml::Providers::MetadataDialogComponent.new(@provider) - ) - end - def create call = ::Saml::Providers::CreateService .new(user: User.current) diff --git a/modules/auth_saml/config/locales/en.yml b/modules/auth_saml/config/locales/en.yml index 7a61e57311a0..20e533e8dfd6 100644 --- a/modules/auth_saml/config/locales/en.yml +++ b/modules/auth_saml/config/locales/en.yml @@ -6,6 +6,7 @@ en: identifier: Identifier secret: Secret scope: Scope + assertion_consumer_service_url: Assertion consumer service URL limit_self_registration: Limit self registration sp_entity_id: Service entity ID metadata_url: Identity provider metadata URL @@ -45,8 +46,8 @@ en: label_empty_description: "Add a provider to see them here." label_automatic_configuration: Automatic configuration label_metadata: Metadata - label_show_metadata: Show OpenProject metadata - label_metadata_endpoint: Metadata endpoint + label_metadata_endpoint: OpenProject metadata endpoint + label_openproject_information: OpenProject information label_configuration_details: "Identity provider endpoints and certificates" label_configuration_encryption: "Signatures and Encryption" label_add_new: New SAML identity provider @@ -156,3 +157,5 @@ en: Select the digest algorithm to use for the SAML request signature performed by OpenProject (Default: %{default_option}). icon: > Optionally provide a public URL to an icon graphic that will be displayed next to the provider name. + metadata_for_idp: > + This information might be requested by your SAML identity provider. diff --git a/modules/auth_saml/config/routes.rb b/modules/auth_saml/config/routes.rb index 6af0d120b4e4..926f2427ae98 100644 --- a/modules/auth_saml/config/routes.rb +++ b/modules/auth_saml/config/routes.rb @@ -4,7 +4,6 @@ resources :providers do member do post :import_metadata - get :show_metadata end end end From 79707b9ed1d21e95f06cdda4dfd583bfd78cda1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 27 Aug 2024 19:41:05 +0200 Subject: [PATCH 71/80] Fix reset of settings --- modules/auth_saml/lib/open_project/auth_saml/engine.rb | 2 +- spec/support/shared/with_settings.rb | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/auth_saml/lib/open_project/auth_saml/engine.rb b/modules/auth_saml/lib/open_project/auth_saml/engine.rb index 41cf1c3a9ee2..455ac439112d 100644 --- a/modules/auth_saml/lib/open_project/auth_saml/engine.rb +++ b/modules/auth_saml/lib/open_project/auth_saml/engine.rb @@ -63,7 +63,7 @@ class Engine < ::Rails::Engine description: "Provide a SAML provider and sync its settings through ENV", env_alias: "OPENPROJECT_SAML", writable: false, - default: nil, + default: {}, format: :hash end end diff --git a/spec/support/shared/with_settings.rb b/spec/support/shared/with_settings.rb index 6d516dbd799b..d4286554d096 100644 --- a/spec/support/shared/with_settings.rb +++ b/spec/support/shared/with_settings.rb @@ -42,7 +42,8 @@ def aggregate_mocked_settings(example, settings) shared_let(:definitions_before) { Settings::Definition.all.dup } def reset(setting, **definitions) - definitions ||= Settings::Definition::DEFINITIONS[setting] + definitions = Settings::Definition::DEFINITIONS[setting] if definitions.empty? + Settings::Definition.all.delete(setting) Settings::Definition.add(setting, **definitions) end From 4f09d8c60a2ea9aa4648b0d3e1b58bea0eed85d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 27 Aug 2024 20:02:04 +0200 Subject: [PATCH 72/80] Fix order of ee --- app/services/authorization/enterprise_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/authorization/enterprise_service.rb b/app/services/authorization/enterprise_service.rb index b3acea653243..7ddd9730e29f 100644 --- a/app/services/authorization/enterprise_service.rb +++ b/app/services/authorization/enterprise_service.rb @@ -41,10 +41,10 @@ class Authorization::EnterpriseService grid_widget_wp_graph ldap_groups one_drive_sharepoint_file_storage - sso_auth_providers placeholder_users project_list_sharing readonly_work_packages + sso_auth_providers team_planner_view two_factor_authentication virus_scanning From afd1d4f50a422f0a7a2bf0347f49d58b84440278 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 27 Aug 2024 20:02:13 +0200 Subject: [PATCH 73/80] Fix moved label --- .../app/components/saml/providers/view_component.html.erb | 2 +- .../auth_saml/app/controllers/saml/providers_controller.rb | 4 ++-- .../auth_saml/spec/features/administration/saml_crud_spec.rb | 4 ---- .../components/storages/admin/storage_row_component.html.erb | 2 +- .../app/components/storages/admin/storage_view_information.rb | 2 +- 5 files changed, 5 insertions(+), 9 deletions(-) diff --git a/modules/auth_saml/app/components/saml/providers/view_component.html.erb b/modules/auth_saml/app/components/saml/providers/view_component.html.erb index 0b69b8c522aa..51732c14c1ae 100644 --- a/modules/auth_saml/app/components/saml/providers/view_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/view_component.html.erb @@ -50,7 +50,7 @@ view_mode:, heading: t("saml.providers.label_metadata"), description: t("saml.providers.section_texts.metadata"), - label: !provider.has_metadata? ? t(:label_completed) : t(:label_not_configured), + label: provider.has_metadata? ? t(:label_completed) : t(:label_not_configured), label_scheme: provider.has_metadata? ? :success : :secondary )) end diff --git a/modules/auth_saml/app/controllers/saml/providers_controller.rb b/modules/auth_saml/app/controllers/saml/providers_controller.rb index 8d022fc98a8b..ab8a43a11fe4 100644 --- a/modules/auth_saml/app/controllers/saml/providers_controller.rb +++ b/modules/auth_saml/app/controllers/saml/providers_controller.rb @@ -62,7 +62,7 @@ def import_metadata else @edit_state = :metadata - flash[:error] = call.message + flash.now[:error] = call.message render action: :edit end end @@ -77,7 +77,7 @@ def create if call.success? successful_save_response else - flash[:error] = call.message + flash.now[:error] = call.message render action: :new end end diff --git a/modules/auth_saml/spec/features/administration/saml_crud_spec.rb b/modules/auth_saml/spec/features/administration/saml_crud_spec.rb index 8ab02e14eda0..2df6ed5f2512 100644 --- a/modules/auth_saml/spec/features/administration/saml_crud_spec.rb +++ b/modules/auth_saml/spec/features/administration/saml_crud_spec.rb @@ -83,10 +83,6 @@ expect(page).to have_text "Not configured" end - within_test_selector("saml_provider_mapping") do - expect(page).to have_text "Completed" - end - # Back to index visit "/admin/saml/providers" expect(page).to have_text "My provider" diff --git a/modules/storages/app/components/storages/admin/storage_row_component.html.erb b/modules/storages/app/components/storages/admin/storage_row_component.html.erb index d44b4819eb27..df912ff06deb 100644 --- a/modules/storages/app/components/storages/admin/storage_row_component.html.erb +++ b/modules/storages/app/components/storages/admin/storage_row_component.html.erb @@ -4,7 +4,7 @@ concat(render(Primer::Beta::Link.new(href: url_helpers.edit_admin_settings_storage_path(storage), font_weight: :bold, mr: 1, data: { 'test-selector': 'storage-name' })) { storage.name }) unless storage.configured? - concat(render(Primer::Beta::Label.new(scheme: :attention, test_selector: "label-incomplete")) { I18n.t('storages.label_incomplete') }) + concat(render(Primer::Beta::Label.new(scheme: :attention, test_selector: "label-incomplete")) { I18n.t(:label_incomplete) }) end if storage.health_unhealthy? diff --git a/modules/storages/app/components/storages/admin/storage_view_information.rb b/modules/storages/app/components/storages/admin/storage_view_information.rb index f12f5a69d3f5..a43f41d9f61a 100644 --- a/modules/storages/app/components/storages/admin/storage_view_information.rb +++ b/modules/storages/app/components/storages/admin/storage_view_information.rb @@ -74,7 +74,7 @@ def automatically_managed_project_folders_status_label if storage.automatic_management_enabled? status_label(I18n.t("storages.label_active"), scheme: :success, test_selector:) elsif storage.automatic_management_unspecified? - status_label(I18n.t("storages.label_incomplete"), scheme: :attention, test_selector:) + status_label(I18n.t(:label_incomplete), scheme: :attention, test_selector:) else status_label(I18n.t("storages.label_inactive"), scheme: :secondary, test_selector:) end From 3de3de4c03ca39a40a5ceeac14df1a342b6f0610 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 27 Aug 2024 20:02:16 +0200 Subject: [PATCH 74/80] Fix html expect --- .../spec/requests/project_storages_open_spec.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/modules/storages/spec/requests/project_storages_open_spec.rb b/modules/storages/spec/requests/project_storages_open_spec.rb index e3b6c2cd37e8..a04401e1057d 100644 --- a/modules/storages/spec/requests/project_storages_open_spec.rb +++ b/modules/storages/spec/requests/project_storages_open_spec.rb @@ -86,9 +86,9 @@ before do Storages::Peripherals::Registry.stub( "nextcloud.queries.file_info", ->(_) do - ServiceResult.failure(result: code, - errors: Storages::StorageError.new(code:)) - end + ServiceResult.failure(result: code, + errors: Storages::StorageError.new(code:)) + end ) end @@ -101,8 +101,8 @@ expect(last_response).to have_http_status(:found) expect(last_response.headers["Location"]).to eq ( - "http://#{Setting.host_name}/oauth_clients/#{storage.oauth_client.client_id}/ensure_connection?destination_url=http%3A%2F%2F#{CGI.escape(Setting.host_name)}%2Fprojects%2F#{project.identifier}%2Fproject_storages%2F#{project_storage.id}%2Fopen&storage_id=#{storage.id}" - ) + "http://#{Setting.host_name}/oauth_clients/#{storage.oauth_client.client_id}/ensure_connection?destination_url=http%3A%2F%2F#{CGI.escape(Setting.host_name)}%2Fprojects%2F#{project.identifier}%2Fproject_storages%2F#{project_storage.id}%2Fopen&storage_id=#{storage.id}" + ) end end From 4faca153719023d58bc14d25ab2e370c027cb3e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 5 Sep 2024 08:45:43 +0200 Subject: [PATCH 75/80] Optional validation of slo URL --- .../contracts/saml/providers/base_contract.rb | 2 +- .../saml/providers/set_attributes_service.rb | 2 +- .../providers/set_attributes_service_spec.rb | 61 +++++++++++++++++++ 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/modules/auth_saml/app/contracts/saml/providers/base_contract.rb b/modules/auth_saml/app/contracts/saml/providers/base_contract.rb index e57f00b69a17..3e2a24ac82fe 100644 --- a/modules/auth_saml/app/contracts/saml/providers/base_contract.rb +++ b/modules/auth_saml/app/contracts/saml/providers/base_contract.rb @@ -52,7 +52,7 @@ def self.model attribute :idp_slo_service_url validates :idp_slo_service_url, - url: { schemes: %w[http https] }, + url: { allow_blank: true, allow_nil: true, schemes: %w[http https] }, if: -> { model.idp_slo_service_url_changed? } attribute :idp_cert diff --git a/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb b/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb index 2481c4c2964a..ccd0285f5e4f 100644 --- a/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb +++ b/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb @@ -54,7 +54,7 @@ def update_options(options) # rubocop:disable Metrics/AbcSize options .select { |key, _| Saml::Provider.stored_attributes[:options].include?(key.to_s) } .each do |key, value| - model.public_send(:"#{key}=", value) + model.public_send(:"#{key}=", value) end end diff --git a/modules/auth_saml/spec/services/saml/providers/set_attributes_service_spec.rb b/modules/auth_saml/spec/services/saml/providers/set_attributes_service_spec.rb index 76217a775600..ae5086a97cf3 100644 --- a/modules/auth_saml/spec/services/saml/providers/set_attributes_service_spec.rb +++ b/modules/auth_saml/spec/services/saml/providers/set_attributes_service_spec.rb @@ -82,6 +82,67 @@ end end + describe "SLO URL" do + let(:options) do + { + idp_slo_service_url: idp_slo_service_url + } + end + + context "when nil" do + let(:idp_slo_service_url) { nil } + + it "is valid" do + expect(call).to be_success + expect(call.errors).to be_empty + + expect(subject.idp_slo_service_url).to be_nil + end + end + + context "when blank" do + let(:idp_slo_service_url) { "" } + + it "is valid" do + expect(call).to be_success + expect(call.errors).to be_empty + + expect(subject.idp_slo_service_url).to eq "" + end + end + + context "when not a URL" do + let(:idp_slo_service_url) { "foo!" } + + it "is valid" do + expect(call).not_to be_success + expect(call.errors.details[:idp_slo_service_url]) + .to contain_exactly({ error: :url, value: idp_slo_service_url }) + end + end + + context "when invalid scheme" do + let(:idp_slo_service_url) { "urn:some:info" } + + it "is valid" do + expect(call).not_to be_success + expect(call.errors.details[:idp_slo_service_url]) + .to contain_exactly({ error: :url, value: idp_slo_service_url }) + end + end + + context "when valid" do + let(:idp_slo_service_url) { "https://foobar.example.com/slo" } + + it "is valid" do + expect(call).to be_success + expect(call.errors).to be_empty + + expect(subject.idp_slo_service_url).to eq idp_slo_service_url + end + end + end + describe "IDP certificate" do let(:options) do { From 6d59acab79af78ac9da378fbadfe9a2e50938d76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 5 Sep 2024 08:46:52 +0200 Subject: [PATCH 76/80] Change matched count for identity_url --- .../auth_saml/app/components/saml/providers/row_component.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/auth_saml/app/components/saml/providers/row_component.rb b/modules/auth_saml/app/components/saml/providers/row_component.rb index 39a8ed02650d..67abe31d7bc3 100644 --- a/modules/auth_saml/app/components/saml/providers/row_component.rb +++ b/modules/auth_saml/app/components/saml/providers/row_component.rb @@ -53,7 +53,7 @@ def edit_link end def users - User.where("identity_url LIKE ?", "%#{provider.slug}%").count.to_s + User.where("identity_url LIKE ?", "#{provider.slug}%").count.to_s end def creator From 2ce8b06c57bbc08639b676350896d1103a25fc14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 5 Sep 2024 09:13:56 +0200 Subject: [PATCH 77/80] Indent --- .../saml/providers/set_attributes_service.rb | 2 +- .../providers/set_attributes_service_spec.rb | 2 +- .../requests/project_storages_open_spec.rb | 19 +++++++++---------- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb b/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb index ccd0285f5e4f..2481c4c2964a 100644 --- a/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb +++ b/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb @@ -54,7 +54,7 @@ def update_options(options) # rubocop:disable Metrics/AbcSize options .select { |key, _| Saml::Provider.stored_attributes[:options].include?(key.to_s) } .each do |key, value| - model.public_send(:"#{key}=", value) + model.public_send(:"#{key}=", value) end end diff --git a/modules/auth_saml/spec/services/saml/providers/set_attributes_service_spec.rb b/modules/auth_saml/spec/services/saml/providers/set_attributes_service_spec.rb index ae5086a97cf3..1c389d585608 100644 --- a/modules/auth_saml/spec/services/saml/providers/set_attributes_service_spec.rb +++ b/modules/auth_saml/spec/services/saml/providers/set_attributes_service_spec.rb @@ -85,7 +85,7 @@ describe "SLO URL" do let(:options) do { - idp_slo_service_url: idp_slo_service_url + idp_slo_service_url: } end diff --git a/modules/storages/spec/requests/project_storages_open_spec.rb b/modules/storages/spec/requests/project_storages_open_spec.rb index a04401e1057d..4baeb7b0f06c 100644 --- a/modules/storages/spec/requests/project_storages_open_spec.rb +++ b/modules/storages/spec/requests/project_storages_open_spec.rb @@ -75,7 +75,7 @@ get route, {}, { "HTTP_ACCEPT" => "text/vnd.turbo-stream.html" } expect(last_response).to have_http_status(:ok) - expect(last_response.body).to eq("\n \n\n\n") # rubocop:disable Layout/LineLength + expect(last_response.body).to be_html_eql("\n \n\n\n") # rubocop:disable Layout/LineLength end end end @@ -85,10 +85,8 @@ before do Storages::Peripherals::Registry.stub( - "nextcloud.queries.file_info", ->(_) do - ServiceResult.failure(result: code, - errors: Storages::StorageError.new(code:)) - end + "nextcloud.queries.file_info", + ->(_) { ServiceResult.failure(result: code, errors: Storages::StorageError.new(code:)) } ) end @@ -100,9 +98,10 @@ get route, {}, { "HTTP_ACCEPT" => "text/html" } expect(last_response).to have_http_status(:found) - expect(last_response.headers["Location"]).to eq ( - "http://#{Setting.host_name}/oauth_clients/#{storage.oauth_client.client_id}/ensure_connection?destination_url=http%3A%2F%2F#{CGI.escape(Setting.host_name)}%2Fprojects%2F#{project.identifier}%2Fproject_storages%2F#{project_storage.id}%2Fopen&storage_id=#{storage.id}" - ) + expect(last_response.headers["Location"]) + .to eq("http://#{Setting.host_name}/oauth_clients/#{storage.oauth_client.client_id}/ensure_connection?" \ + "destination_url=http%3A%2F%2F#{CGI.escape(Setting.host_name)}%2Fprojects%2F#{project.identifier}" \ + "%2Fproject_storages%2F#{project_storage.id}%2Fopen&storage_id=#{storage.id}") end end @@ -111,7 +110,7 @@ get route, {}, { "HTTP_ACCEPT" => "text/html" } expect(last_response).to have_http_status(:found) - expect(last_response.headers["Location"]).to eq ("http://#{Setting.host_name}/projects/#{project.identifier}") + expect(last_response.headers["Location"]).to eq("http://#{Setting.host_name}/projects/#{project.identifier}") expect(last_request.session["flash"]["flashes"]) .to eq({ "modal" => { @@ -142,7 +141,7 @@ get route, {}, { "HTTP_ACCEPT" => "text/html" } expect(last_response).to have_http_status(:found) - expect(last_response.headers["Location"]).to eq ("http://#{Setting.host_name}/projects/#{project.identifier}") + expect(last_response.headers["Location"]).to eq("http://#{Setting.host_name}/projects/#{project.identifier}") expect(last_request.session["flash"]["flashes"]) .to eq({ "modal" => { From 9dca9a327d7add5c9f16acda0bb753b1442701b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 5 Sep 2024 09:49:33 +0200 Subject: [PATCH 78/80] Naming --- modules/auth_saml/config/locales/en.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/modules/auth_saml/config/locales/en.yml b/modules/auth_saml/config/locales/en.yml index 20e533e8dfd6..3ca767a5a951 100644 --- a/modules/auth_saml/config/locales/en.yml +++ b/modules/auth_saml/config/locales/en.yml @@ -6,7 +6,7 @@ en: identifier: Identifier secret: Secret scope: Scope - assertion_consumer_service_url: Assertion consumer service URL + assertion_consumer_service_url: ACS (Assertion consumer service) URL limit_self_registration: Limit self registration sp_entity_id: Service entity ID metadata_url: Identity provider metadata URL @@ -70,7 +70,9 @@ en: description: Connect OpenProject to a SAML identity provider request_attributes: title: 'Requested attributes' - legend: 'These attributes are added to the SAML XML metadata to signify to the identify provider which attributes OpenProject requires.' + legend: > + These attributes are added to the SAML XML metadata to signify to the identify provider which attributes OpenProject requires. + You may still need to explicitly configure your identity provider to send these attributes. Please refer to your IdP's documentation. name: 'Requested attribute key' format: 'Attribute format' section_headers: @@ -79,7 +81,7 @@ en: section_texts: display_name: "Configure the display name of the SAML provider." metadata: "Pre-fill configuration using a metadata URL or by pasting metadata XML" - metadata_form: "If your identity provider has a metadata endpoint or XML download, add it below to pre-fill configuration." + metadata_form: "If your identity provider has a metadata endpoint or XML download, add it below to pre-fill the configuration." metadata_form_banner: "Editing the metadata may override existing values in other sections. " configuration: "Configure the endpoint URLs for the identity provider, certificates, and further SAML options." configuration_metadata: "This information has been pre-filled using the supplied metadata. In most cases, they do not require editing." @@ -106,7 +108,7 @@ en: limit_self_registration: > If enabled users can only register using this provider if the self registration setting allows for it. sp_entity_id: > - The entity ID of the service provider. This is the unique client identifier of the OpenProject instance. + The entity ID of the service provider (SP). Sometimes also referred to as Audience. This is the unique client identifier of the OpenProject instance. idp_sso_service_url: > The URL of the identity provider login endpoint. idp_slo_service_url: > From a304a6cd206850fc672865e1183313abddabc43f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 9 Sep 2024 20:37:40 +0200 Subject: [PATCH 79/80] Change color scheme --- .../auth_saml/app/components/saml/providers/row_component.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/auth_saml/app/components/saml/providers/row_component.rb b/modules/auth_saml/app/components/saml/providers/row_component.rb index 67abe31d7bc3..30f6a3de52f0 100644 --- a/modules/auth_saml/app/components/saml/providers/row_component.rb +++ b/modules/auth_saml/app/components/saml/providers/row_component.rb @@ -15,7 +15,7 @@ def column_args(column) def name concat render(Primer::Beta::Link.new( - scheme: :primary, + font_weight: :bold, href: url_for(action: :show, id: provider.id) )) { provider.display_name || provider.name } From 7b58928a7b2cbe27d62a037bc00b2d669b519acc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 26 Sep 2024 12:54:41 +0200 Subject: [PATCH 80/80] Fix labels --- .github/workflows/test-core.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-core.yml b/.github/workflows/test-core.yml index da196cdf94bc..30d3d658d320 100644 --- a/.github/workflows/test-core.yml +++ b/.github/workflows/test-core.yml @@ -24,7 +24,7 @@ jobs: name: Units + Features if: github.repository == 'opf/openproject' runs-on: - labels + labels: - runs-on - runner=32cpu-linux-x64 - family=m7