From af05f29bbfd9e1fc26e5e46046e8d2f49c6e318a Mon Sep 17 00:00:00 2001 From: Pavel Balashou Date: Wed, 26 Mar 2025 10:35:15 +0100 Subject: [PATCH] [#62107] Add SCIM server API https://community.openproject.org/work_packages/62107 --- Gemfile | 2 + Gemfile.lock | 4 + app/contracts/groups/base_contract.rb | 3 + app/contracts/users/delete_contract.rb | 5 +- app/controllers/scim_v2/groups_controller.rb | 107 ++++++ app/controllers/scim_v2/users_controller.rb | 103 +++++ app/models/group.rb | 76 ++++ app/models/user.rb | 104 ++++- config/initializers/doorkeeper.rb | 4 + config/initializers/feature_decisions.rb | 2 + config/initializers/scimitar.rb | 37 ++ config/locales/en.yml | 2 + config/routes.rb | 18 + lib_static/open_project/authentication.rb | 3 + modules/bim/lib/open_project/bim/engine.rb | 5 +- spec/requests/scim_v2/groups_spec.rb | 303 +++++++++++++++ spec/requests/scim_v2/users_spec.rb | 384 +++++++++++++++++++ spec/services/users/delete_service_spec.rb | 4 +- 18 files changed, 1154 insertions(+), 12 deletions(-) create mode 100644 app/controllers/scim_v2/groups_controller.rb create mode 100644 app/controllers/scim_v2/users_controller.rb create mode 100644 config/initializers/scimitar.rb create mode 100644 spec/requests/scim_v2/groups_spec.rb create mode 100644 spec/requests/scim_v2/users_spec.rb diff --git a/Gemfile b/Gemfile index fc11aebd50b..f7d5ee725d1 100644 --- a/Gemfile +++ b/Gemfile @@ -59,6 +59,8 @@ gem "will_paginate", "~> 4.0.0" gem "friendly_id", "~> 5.5.0" +gem "scimitar" + gem "acts_as_list", "~> 1.2.0" gem "acts_as_tree", "~> 2.9.0" gem "awesome_nested_set", "~> 3.8.0" diff --git a/Gemfile.lock b/Gemfile.lock index d1b5f2519bc..ffe0ddcfba7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1158,6 +1158,8 @@ GEM sanitize (7.0.0) crass (~> 1.0.2) nokogiri (>= 1.16.8) + scimitar (2.11.0) + rails (>= 7.0) secure_headers (7.1.0) securerandom (0.4.1) selenium-devtools (0.137.0) @@ -1504,6 +1506,7 @@ DEPENDENCIES ruby-progressbar (~> 1.13.0) rubytree (~> 2.1.0) sanitize (~> 7.0.0) + scimitar secure_headers (~> 7.1.0) selenium-devtools selenium-webdriver (~> 4.20) @@ -1935,6 +1938,7 @@ CHECKSUMS rubyzip (2.3.2) sha256=3f57e3935dc2255c414484fbf8d673b4909d8a6a57007ed754dde39342d2373f safety_net_attestation (0.4.0) sha256=96be2d74e7ed26453a51894913449bea0e072f44490021545ac2d1c38b0718ce sanitize (7.0.0) sha256=269d1b9d7326e69307723af5643ec032ff86ad616e72a3b36d301ac75a273984 + scimitar (2.11.0) sha256=77cf779a843be7d572046acdcf0a1829bd3b1c33db993fa83faf7f1863d8c625 secure_headers (7.1.0) sha256=6b1f9d5f9507af2948f4636452c41c09371927836396c2185438ffdf0a731124 securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 selenium-devtools (0.137.0) sha256=ea29581fe0502f10eb1e45492a62e2228398822180655ea94d146050fc44fa3c diff --git a/app/contracts/groups/base_contract.rb b/app/contracts/groups/base_contract.rb index 4c13c77b7d6..9f5f436c051 100644 --- a/app/contracts/groups/base_contract.rb +++ b/app/contracts/groups/base_contract.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -35,6 +37,7 @@ module Groups # hence we need to put "lastname" as an attribute here attribute :name attribute :lastname + attribute :identity_url validate :validate_unique_users diff --git a/app/contracts/users/delete_contract.rb b/app/contracts/users/delete_contract.rb index a65fb3c57fb..f76eb13837e 100644 --- a/app/contracts/users/delete_contract.rb +++ b/app/contracts/users/delete_contract.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -41,7 +43,8 @@ module Users if actor == user Setting.users_deletable_by_self? else - actor.admin? && actor.active? && Setting.users_deletable_by_admins? + (actor.admin? && actor.active? && Setting.users_deletable_by_admins?) || + User.system == actor end end end diff --git a/app/controllers/scim_v2/groups_controller.rb b/app/controllers/scim_v2/groups_controller.rb new file mode 100644 index 00000000000..6ab739a5123 --- /dev/null +++ b/app/controllers/scim_v2/groups_controller.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +module ScimV2 + class GroupsController < Scimitar::ResourcesController + skip_before_action :verify_authenticity_token + + rescue_from "ActiveRecord::RecordNotFound", with: :handle_resource_not_found + + def index + query = if params[:filter].blank? + storage_scope + else + attribute_map = storage_class.new.scim_queryable_attributes + parser = ::Scimitar::Lists::QueryParser.new(attribute_map) + + parser.parse(params[:filter]) + parser.to_activerecord_query(storage_scope) + end + + pagination_info = scim_pagination_info(query.count) + page_of_results = query + .order(id: :asc) + .offset(pagination_info.offset) + .limit(pagination_info.limit) + .to_a + + super(pagination_info, page_of_results) do |record| + record.to_scim(location: url_for(action: :show, id: record.id)) + end + end + + def show + super do |group_id| + group = storage_scope.find(group_id) + group.to_scim(location: url_for(action: :show, id: group_id)) + end + end + + def create + super do |scim_resource| + storage_class.transaction do + group = storage_class.new + group.from_scim!(scim_hash: scim_resource.as_json) + call = Groups::CreateService + .new(user: User.system) + .call(group.attributes) + .on_failure { |result| raise result.message } + group = call.result + Groups::AddUsersService + .new(group, current_user: User.system) + .call(ids: scim_resource.members.map(&:value), send_notifications: false) + .on_failure { |call| raise call.message } + + group.to_scim(location: url_for(action: :show, id: group.id)) + end + end + end + + def replace + super do |group_id, scim_resource| + storage_class.transaction do + group = storage_scope.find(group_id) + group.from_scim!(scim_hash: scim_resource.as_json) + Groups::UpdateService + .new(user: User.system, model: group) + .call + .on_failure { |call| raise call.message } + group.to_scim(location: url_for(action: :show, id: group.id)) + end + end + end + + def update + super do |group_id, patch_hash| + storage_class.transaction do + group = storage_scope.find(group_id) + group.from_scim_patch!(patch_hash: patch_hash) + Groups::UpdateService + .new(user: User.system, model: group) + .call + .on_failure { |call| raise call.message } + group.to_scim(location: url_for(action: :show, id: group.id)) + end + end + end + + def destroy + super do |group_id| + group = storage_scope.find(group_id) + Groups::DeleteService + .new(user: User.system, model: group) + .call + .on_failure { |call| raise call.message } + end + end + + protected + + def storage_class + Group + end + + def storage_scope + Group.all + end + end +end diff --git a/app/controllers/scim_v2/users_controller.rb b/app/controllers/scim_v2/users_controller.rb new file mode 100644 index 00000000000..566d6cc0e0f --- /dev/null +++ b/app/controllers/scim_v2/users_controller.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +module ScimV2 + class UsersController < Scimitar::ResourcesController + skip_before_action :verify_authenticity_token + + rescue_from "ActiveRecord::RecordNotFound", with: :handle_resource_not_found + + def index + query = if params[:filter].blank? + storage_scope + else + attribute_map = storage_class.new.scim_queryable_attributes + parser = ::Scimitar::Lists::QueryParser.new(attribute_map) + + parser.parse(params[:filter]) + parser.to_activerecord_query(storage_scope) + end + + pagination_info = scim_pagination_info(query.count) + page_of_results = query + .order(id: :asc) + .offset(pagination_info.offset) + .limit(pagination_info.limit) + .to_a + + super(pagination_info, page_of_results) do |record| + record.to_scim(location: url_for(action: :show, id: record.id)) + end + end + + def show + super do |user_id| + user = storage_scope.find(user_id) + user.to_scim(location: url_for(action: :show, id: user_id)) + end + end + + def create + super do |scim_resource| + storage_class.transaction do + user = storage_class.new + user.from_scim!(scim_hash: scim_resource.as_json) + call = Users::CreateService + .new(user: User.system) + .call(user.attributes) + .on_failure { |result| raise result.message } + + user = call.result + user.to_scim(location: url_for(action: :show, id: user.id)) + end + end + end + + def replace + super do |user_id, scim_resource| + storage_class.transaction do + user = storage_scope.find(user_id) + user.from_scim!(scim_hash: scim_resource.as_json) + Users::UpdateService + .new(user: User.system, model: user) + .call + .on_failure { |call| raise call.message } + user.to_scim(location: url_for(action: :show, id: user.id)) + end + end + end + + def update + super do |user_id, patch_hash| + storage_class.transaction do + user = storage_scope.find(user_id) + user.from_scim_patch!(patch_hash: patch_hash) + Users::UpdateService + .new(user: User.system, model: user) + .call + .on_failure { |call| raise call.message } + user.to_scim(location: url_for(action: :show, id: user.id)) + end + end + end + + def destroy + super do |user_id| + user = storage_scope.find(user_id) + Users::DeleteService + .new(user: User.system, model: user) + .call + .on_failure { |call| raise call.message } + end + end + + protected + + def storage_class + User + end + + def storage_scope + User.user.where.not(identity_url: [nil, ""]) + end + end +end diff --git a/app/models/group.rb b/app/models/group.rb index 277f6871d71..7d9016cb0b7 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -67,6 +69,80 @@ class Group < Principal lastname end + def scim_external_id + return nil if identity_url.blank? + + identity_url.split(":", 2).second + end + + def scim_external_id=(external_id) + oidc_provider = OpenIDConnect::Provider.first + raise "There should at least one OIDC Provider for SCIM to work with" unless oidc_provider + + self.identity_url = "#{oidc_provider.slug}:#{external_id}" + external_id + end + + def scim_users_and_groups + users + end + + def scim_users_and_groups=(_array) + users + end + + def self.scim_resource_type + Scimitar::Resources::Group + end + + def self.scim_attributes_map + { + id: :id, + externalId: :scim_external_id, + displayName: :name, + members: [ + list: :scim_users_and_groups, + using: { + value: :id + }, + find_with: ->(scim_list_entry) { + id = scim_list_entry["value"] + type = scim_list_entry["type"] || "User" # Some online examples omit 'type' and believe 'User' will be assumed + + case type.downcase + when "user" + User.user.where.not(identity_url: [nil, ""]).find_by(id:) + when "group" + # TODO OP does not support nesting of groups but SCIM does. + # For now raises exception in case of group as a member arrival. + raise Scimitar::InvalidSyntaxError.new("Unsupported type #{type.inspect}") + else + raise Scimitar::InvalidSyntaxError.new("Unrecognised type #{type.inspect}") + end + } + ] + } + end + + def self.scim_mutable_attributes + nil + end + + def self.scim_queryable_attributes + { + displayName: :name + } + end + + def self.scim_timestamps_map + { + created: :created_at, + lastModified: :updated_at + } + end + + include Scimitar::Resources::Mixin + private def uniqueness_of_name diff --git a/app/models/user.rb b/app/models/user.rb index b74fd4cdec5..db93b634272 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -30,7 +32,7 @@ require "digest/sha1" class User < Principal VALID_NAME_REGEX = /\A[\d\p{Alpha}\p{Mark}\p{Space}\p{Emoji}'’´\-_.,@()+&*–]+\z/ - CURRENT_USER_LOGIN_ALIAS = "me".freeze + CURRENT_USER_LOGIN_ALIAS = "me" USER_FORMATS_STRUCTURE = { firstname_lastname: %i[firstname lastname], firstname: [:firstname], @@ -115,7 +117,7 @@ class User < Principal def self.blocked_condition(blocked) block_duration = Setting.brute_force_block_minutes.to_i.minutes - blocked_if_login_since = Time.now - block_duration + blocked_if_login_since = Time.zone.now - block_duration negation = blocked ? "" : "NOT" ["#{negation} (users.failed_login_count >= ? AND users.last_failed_login_on > ?)", @@ -274,7 +276,7 @@ class User < Principal def self.try_to_autologin(key) token = Token::AutoLogin.find_by_plaintext_value(key) # rubocop:disable Rails/DynamicFindBy # Make sure there's only 1 token that matches the key - if token && ((token.created_at > Setting.autologin.to_i.day.ago) && token.user && token.user.active?) + if token && ((token.created_at > Setting.autologin.to_i.day.ago) && token.user&.active?) token.user end end @@ -486,7 +488,7 @@ class User < Principal # Returns the current day according to user's time zone def today if time_zone.nil? - Date.today + Time.zone.today else Time.now.in_time_zone(time_zone).to_date end @@ -561,6 +563,94 @@ class User < Principal SystemUser.first end + def scim_external_id + return nil if identity_url.blank? + + identity_url.split(":", 2).second + end + + def scim_external_id=(external_id) + oidc_provider = OpenIDConnect::Provider.first + raise "There should at least one OIDC Provider for SCIM to work with" unless oidc_provider + + self.identity_url = "#{oidc_provider.slug}:#{external_id}" + external_id + end + + def scim_active=(is_active) + if is_active + activate + true + else + lock if active? + false + end + end + + def scim_active + active? + end + + def self.scim_resource_type + Scimitar::Resources::User + end + + def self.scim_attributes_map + { + id: :id, + externalId: :scim_external_id, + userName: :login, + name: { + givenName: :firstname, + familyName: :lastname + }, + emails: [ + { + match: "type", + with: "work", + using: { + value: :mail, + primary: true + } + } + ], + + groups: [ + { + list: :groups, + using: { + value: :id, + # TODO $ref seems to be mandadory accroding to the spec. + } + } + ], + active: :scim_active + } + end + + def self.scim_mutable_attributes + nil + end + + def self.scim_queryable_attributes + { + givenName: { column: :firstname }, + familyName: { column: :lastname }, + emails: { column: :mail }, + groups: { column: Group.arel_table[:id] }, + "groups.value" => { column: Group.arel_table[:id] } + } + end + + def self.scim_timestamps_map + { + created: :created_at, + lastModified: :updated_at + } + end + + include Scimitar::Resources::Mixin + protected # Login must not be aliased value 'me' @@ -610,7 +700,7 @@ class User < Principal def clean_up_former_passwords # minimum 1 to keep the actual user password keep_count = [1, Setting[:password_count_former_banned].to_i].max - (passwords[keep_count..-1] || []).each(&:destroy) + (passwords[keep_count..] || []).each(&:destroy) end def clean_up_password_attribute @@ -654,7 +744,7 @@ class User < Principal def last_failed_login_within_block_time? block_duration = Setting.brute_force_block_minutes.to_i.minutes last_failed_login_on and - Time.now - last_failed_login_on < block_duration + Time.zone.now - last_failed_login_on < block_duration end def log_failed_login_count @@ -666,7 +756,7 @@ class User < Principal end def log_failed_login_timestamp - self.last_failed_login_on = Time.now + self.last_failed_login_on = Time.zone.now end def self.default_admin_account_changed? diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index aba43849043..7c941f35ceb 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + Doorkeeper.configure do # Change the ORM that doorkeeper will use (needs plugins) orm :active_record @@ -99,6 +101,8 @@ Doorkeeper.configure do # default_scopes :api_v3 + optional_scopes :scim_v2 + # Change the way client credentials are retrieved from the request object. # By default it retrieves first from the `HTTP_AUTHORIZATION` header, then # falls back to the `:client_id` and `:client_secret` params from the `params` object. diff --git a/config/initializers/feature_decisions.rb b/config/initializers/feature_decisions.rb index b1721a94fca..6106f22c432 100644 --- a/config/initializers/feature_decisions.rb +++ b/config/initializers/feature_decisions.rb @@ -46,3 +46,5 @@ OpenProject::FeatureDecisions.add :built_in_oauth_applications, OpenProject::FeatureDecisions.add :stages_and_gates, description: "Enables the under construction feature of phases." +OpenProject::FeatureDecisions.add :scim_api, + description: "Enables SCIM API." diff --git a/config/initializers/scimitar.rb b/config/initializers/scimitar.rb new file mode 100644 index 00000000000..9fede9b3c55 --- /dev/null +++ b/config/initializers/scimitar.rb @@ -0,0 +1,37 @@ +# 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. +#++ + +Rails.application.config.to_prepare do + Scimitar.engine_configuration = Scimitar::EngineConfiguration.new( + token_authenticator: Proc.new do |_token, _options| + OpenProject::FeatureDecisions.scim_api_active? + end + ) +end diff --git a/config/locales/en.yml b/config/locales/en.yml index facd613dcbb..351b47f766b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1144,6 +1144,8 @@ en: new_password: "New password" password_confirmation: "Confirmation" consented_at: "Consented at" + group: + identity_url: "Identity URL" user_preference: comments_sorting: "Display comments" dismissed_enterprise_banners: "Hidden enterprise banners" diff --git a/config/routes.rb b/config/routes.rb index d5f23c73e73..418e4e26217 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -883,6 +883,24 @@ Rails.application.routes.draw do get "ensure_connection", controller: "oauth_clients", action: :ensure_connection, as: "oauth_clients_ensure_connection" end + namespace :scim_v2 do + mount Scimitar::Engine, at: "/" + + get "Users", to: "users#index" + get "Users/:id", to: "users#show" + post "Users", to: "users#create" + put "Users/:id", to: "users#replace" + patch "Users/:id", to: "users#update" + delete "Users/:id", to: "users#destroy" + + get "Groups", to: "groups#index" + get "Groups/:id", to: "groups#show" + post "Groups", to: "groups#create" + put "Groups/:id", to: "groups#replace" + patch "Groups/:id", to: "groups#update" + delete "Groups/:id", to: "groups#destroy" + end + if OpenProject::Configuration.lookbook_enabled? mount Lookbook::Engine, at: "/lookbook" end diff --git a/lib_static/open_project/authentication.rb b/lib_static/open_project/authentication.rb index d66e2d6b531..3ea837e6989 100644 --- a/lib_static/open_project/authentication.rb +++ b/lib_static/open_project/authentication.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH # @@ -92,6 +94,7 @@ module OpenProject # Plugins can declare new scopes by declaring new constants in this module. module Scope API_V3 = :api_v3 + SCIM_V2 = :scim_v2 class << self def values diff --git a/modules/bim/lib/open_project/bim/engine.rb b/modules/bim/lib/open_project/bim/engine.rb index 53e9e5a3aff..24b28f1220c 100644 --- a/modules/bim/lib/open_project/bim/engine.rb +++ b/modules/bim/lib/open_project/bim/engine.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -214,10 +216,9 @@ module OpenProject::Bim end config.to_prepare do - Doorkeeper.configuration.scopes.add(:bcf_v2_1) - unless defined? OpenProject::Authentication::Scope::BCF_V2_1 OpenProject::Authentication::Scope::BCF_V2_1 = :bcf_v2_1 + Doorkeeper.configuration.scopes.add(OpenProject::Authentication::Scope::BCF_V2_1) end OpenProject::Authentication.update_strategies(OpenProject::Authentication::Scope::BCF_V2_1, diff --git a/spec/requests/scim_v2/groups_spec.rb b/spec/requests/scim_v2/groups_spec.rb new file mode 100644 index 00000000000..b052dc938dc --- /dev/null +++ b/spec/requests/scim_v2/groups_spec.rb @@ -0,0 +1,303 @@ +# 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 "SCIM API Groups" do + let(:external_user_id) { "idp_user_id_123asdqwe12345" } + let(:external_group_id) { "idp_group_id_123asdqwe12345" } + let(:admin) { create(:admin) } + let(:oidc_provider) { create(:oidc_provider, slug: "keycloak", creator: admin) } + let(:user) { create(:user, identity_url: "#{oidc_provider.slug}:#{external_user_id}") } + let(:group) { create(:group, identity_url: "#{oidc_provider.slug}:#{external_group_id}", members: [user]) } + let(:headers) { { "CONTENT_TYPE" => "application/scim+json", "HTTP_AUTHORIZATION" => "Bearer access_token" } } + + describe "GET /scim_v2/Groups" do + context "with the feature flag enabled", with_flag: { scim_api: true } do + before { group } + + it do + get "/scim_v2/Groups", {}, headers + + response_body = JSON.parse(last_response.body) + expect(response_body).to eq({ "Resources" => [{ "displayName" => group.name, + "externalId" => external_group_id, + "id" => group.id.to_s, + "members" => [{ "value" => user.id.to_s }], + "meta" => { "location" => "http://test.host/scim_v2/Groups/#{group.id}", + "created" => group.created_at.iso8601, + "lastModified" => group.updated_at.iso8601, + "resourceType" => "Group" }, + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"] }], + "itemsPerPage" => 100, + "schemas" => ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "startIndex" => 1, + "totalResults" => 1 }) + end + + # it do + # filter = ERB::Util.url_encode('displayName Eq "john"') + # get "/scim_v2/Groups?filter=#{filter}", {}, headers + + # response_body = JSON.parse(last_response.body) + # expect(response_body).to eq({ "Resources" => [{ "displayName" => group.name, + # "externalId" => external_group_id, + # "id" => group.id.to_s, + # "members" => [{ "value" => user.id.to_s }], + # "meta" => { "location" => "http://test.host/scim_v2/Groups/#{group.id}", + # "created" => group.created_at.iso8601, + # "lastModified" => group.updated_at.iso8601, + # "resourceType" => "Group" }, + # "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"] }], + # "itemsPerPage" => 100, + # "schemas" => ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + # "startIndex" => 1, + # "totalResults" => 1 }) + # end + end + + context "with the feature flag disabled", with_flag: { scim_api: false } do + it do + get "/scim_v2/Groups", {}, headers + + response_body = JSON.parse(last_response.body) + expect(response_body).to eq( + { "detail" => "Requires authentication", "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status" => "401" } + ) + end + end + end + + describe "GET /scim_v2/Groups/:id" do + context "with the feature flag enabled", with_flag: { scim_api: true } do + it do + group + get "/scim_v2/Groups/#{group.id}", {}, headers + + response_body = JSON.parse(last_response.body) + expect(response_body).to eq({ "displayName" => group.name, + "externalId" => external_group_id, + "id" => group.id.to_s, + "members" => [{ "value" => user.id.to_s }], + "meta" => { "location" => "http://test.host/scim_v2/Groups/#{group.id}", + "created" => group.created_at.iso8601, + "lastModified" => group.updated_at.iso8601, + "resourceType" => "Group" }, + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"] }) + end + end + + context "with the feature flag disabled", with_flag: { scim_api: false } do + it do + get "/scim_v2/Groups/#{group.id}", {}, headers + + response_body = JSON.parse(last_response.body) + expect(response_body).to eq( + { "detail" => "Requires authentication", "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status" => "401" } + ) + end + end + end + + describe "POST /scim_v2/Groups/" do + context "with the feature flag enabled", with_flag: { scim_api: true } do + it do + user + request_body = { "displayName" => "Group 123", + "externalId" => external_group_id, + "members" => [{ "value" => user.id.to_s }], + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"] } + post "/scim_v2/Groups/", request_body.to_json, headers + + response_body = JSON.parse(last_response.body) + group = Group.last + expect(Group.count).to eq(1) + expect(response_body).to eq({ "displayName" => group.name, + "externalId" => external_group_id, + "id" => group.id.to_s, + "members" => [{ "value" => user.id.to_s }], + "meta" => { "location" => "http://test.host/scim_v2/Groups/#{group.id}", + "created" => group.created_at.iso8601, + "lastModified" => group.updated_at.iso8601, + "resourceType" => "Group" }, + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"] }) + end + end + + context "with the feature flag disabled", with_flag: { scim_api: false } do + it do + post "/scim_v2/Groups/", "", headers + + response_body = JSON.parse(last_response.body) + expect(response_body).to eq( + { "detail" => "Requires authentication", "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status" => "401" } + ) + end + end + end + + describe "DELETE /scim_v2/Groups/:id" do + context "with the feature flag enabled", with_flag: { scim_api: true } do + it do + group + delete "/scim_v2/Groups/#{group.id}", "", headers + + expect(last_response.body).to eq("") + expect(last_response).to have_http_status(204) + + get "/scim_v2/Groups/#{group.id}", "", headers + + response_body = JSON.parse(last_response.body) + expect(response_body).to eq({ "displayName" => group.name, + "externalId" => external_group_id, + "id" => group.id.to_s, + "members" => [{ "value" => user.id.to_s }], + "meta" => { "location" => "http://test.host/scim_v2/Groups/#{group.id}", + "created" => group.created_at.iso8601, + "lastModified" => group.updated_at.iso8601, + "resourceType" => "Group" }, + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"] }) + + perform_enqueued_jobs + assert_performed_jobs 1 + + get "/scim_v2/Groups/#{group.id}", "", headers + + response_body = JSON.parse(last_response.body) + expect(response_body).to eq( + { "detail" => "Resource \"#{group.id}\" not found", + "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status" => "404" } + ) + end + end + + context "with the feature flag disabled", with_flag: { scim_api: false } do + it do + delete "/scim_v2/Users/123", "", headers + + response_body = JSON.parse(last_response.body) + expect(response_body).to eq( + { "detail" => "Requires authentication", "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status" => "401" } + ) + end + end + end + + describe "PUT /scim_v2/Users/:id" do + context "with the feature flag enabled", with_flag: { scim_api: true } do + it do + group + new_external_group_id = "new_idp_group_id_123asdqwe12345" + request_body = { + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"], + "active" => true, + "externalId" => new_external_group_id, + "displayName" => group.name, + "members" => [{ "value" => user.id.to_s }], + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"] + } + + put "/scim_v2/Groups/#{group.id}", request_body.to_json, headers + + response_body = JSON.parse(last_response.body) + group.reload + expect(response_body).to eq({ "displayName" => group.name, + "externalId" => new_external_group_id, + "id" => group.id.to_s, + "members" => [{ "value" => user.id.to_s }], + "meta" => { "location" => "http://test.host/scim_v2/Groups/#{group.id}", + "created" => group.created_at.iso8601, + "lastModified" => group.updated_at.iso8601, + "resourceType" => "Group" }, + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"] }) + end + end + + context "with the feature flag disabled", with_flag: { scim_api: false } do + it do + put "/scim_v2/Groups/123", "", headers + + response_body = JSON.parse(last_response.body) + expect(response_body).to eq( + { "detail" => "Requires authentication", "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status" => "401" } + ) + end + end + end + + describe "PATCH /scim_v2/Users/:id" do + context "with the feature flag enabled", with_flag: { scim_api: true } do + it "changes external_id" do + group + new_external_group_id = "new_idp_user_id_123asdqwe12345" + request_body = { + "schemas" => + ["urn =>ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations" => [{ + "op" => "replace", + "path" => "externalId", + "value" => new_external_group_id + }] + } + patch "/scim_v2/Groups/#{group.id}", request_body.to_json, headers + + response_body = JSON.parse(last_response.body) + group.reload + expect(response_body).to eq({ "displayName" => group.name, + "externalId" => new_external_group_id, + "id" => group.id.to_s, + "members" => [{ "value" => user.id.to_s }], + "meta" => { "location" => "http://test.host/scim_v2/Groups/#{group.id}", + "created" => group.created_at.iso8601, + "lastModified" => group.updated_at.iso8601, + "resourceType" => "Group" }, + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"] }) + end + end + + context "with the feature flag disabled", with_flag: { scim_api: false } do + it do + patch "/scim_v2/Groups/123", "", headers + + response_body = JSON.parse(last_response.body) + expect(response_body).to eq( + { "detail" => "Requires authentication", "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status" => "401" } + ) + end + end + end +end diff --git a/spec/requests/scim_v2/users_spec.rb b/spec/requests/scim_v2/users_spec.rb new file mode 100644 index 00000000000..652bede2350 --- /dev/null +++ b/spec/requests/scim_v2/users_spec.rb @@ -0,0 +1,384 @@ +# 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 "SCIM API Users" do + let(:external_user_id) { "idp_user_id_123asdqwe12345" } + let(:external_group_id) { "idp_group_id_123asdqwe12345" } + let(:admin) { create(:admin) } + let(:oidc_provider) { create(:oidc_provider, slug: "keycloak", creator: admin) } + let(:user) { create(:user, identity_url: "#{oidc_provider.slug}:#{external_user_id}") } + let(:group) { create(:group, identity_url: "#{oidc_provider.slug}:#{external_group_id}", members: [user]) } + let(:headers) { { "CONTENT_TYPE" => "application/scim+json", "HTTP_AUTHORIZATION" => "Bearer access_token" } } + + describe "GET /scim_v2/Users" do + context "with the feature flag enabled", with_flag: { scim_api: true } do + it do + group + + get "/scim_v2/Users", {}, headers + + response_body = JSON.parse(last_response.body) + expect(response_body).to eq("Resources" => [{ "active" => true, + "emails" => [{ "primary" => true, + "type" => "work", + "value" => user.mail }], + "externalId" => external_user_id, + "groups" => [{ "value" => group.id.to_s }], + "id" => user.id.to_s, + "meta" => { "created" => user.created_at.iso8601, + "lastModified" => user.updated_at.iso8601, + "location" => "http://test.host/scim_v2/Users/#{user.id}", + "resourceType" => "User" }, + "name" => { "familyName" => user.lastname, + "givenName" => user.firstname }, + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName" => user.login }], + "itemsPerPage" => 100, + "schemas" => ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "startIndex" => 1, + "totalResults" => 1) + end + end + + context "with the feature flag disabled", with_flag: { scim_api: false } do + it do + get "/scim_v2/Users", {}, headers + + response_body = JSON.parse(last_response.body) + expect(response_body).to eq( + { "detail" => "Requires authentication", "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status" => "401" } + ) + end + end + end + + describe "GET /scim_v2/Users/:id" do + context "with the feature flag enabled", with_flag: { scim_api: true } do + it do + group + get "/scim_v2/Users/#{user.id}", {}, headers + + response_body = JSON.parse(last_response.body) + expect(response_body).to eq({ "active" => true, + "emails" => [{ "primary" => true, + "type" => "work", + "value" => user.mail }], + "externalId" => external_user_id, + "groups" => [{ "value" => group.id.to_s }], + "id" => user.id.to_s, + "meta" => { "created" => user.created_at.iso8601, + "lastModified" => user.updated_at.iso8601, + "location" => "http://test.host/scim_v2/Users/#{user.id}", + "resourceType" => "User" }, + "name" => { "familyName" => user.lastname, + "givenName" => user.firstname }, + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName" => user.login }) + end + end + + context "with the feature flag disabled", with_flag: { scim_api: false } do + it do + get "/scim_v2/Users/#{user.id}", {}, headers + + response_body = JSON.parse(last_response.body) + expect(response_body).to eq( + { "detail" => "Requires authentication", "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status" => "401" } + ) + end + end + end + + describe "POST /scim_v2/Users/" do + before { oidc_provider } + + context "with the feature flag enabled", with_flag: { scim_api: true } do + it do + request_body = { + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], + "externalId" => external_user_id, + "userName" => "jdoe", + "name" => { + "givenName" => "John", + "familyName" => "Doe" + }, + "active" => true, + "emails" => [ + { + "value" => "jdoe@example.com", + "type" => "work", + "primary" => true + } + ] + } + post "/scim_v2/Users/", request_body.to_json, headers + + response_body = JSON.parse(last_response.body) + created_user = User.find_by(login: "jdoe") + expect(response_body).to eq({ "active" => true, + "emails" => [{ "primary" => true, + "type" => "work", + "value" => "jdoe@example.com" }], + "externalId" => external_user_id, + "groups" => [], + "id" => created_user.id.to_s, + "meta" => { "created" => created_user.created_at.iso8601, + "lastModified" => created_user.updated_at.iso8601, + "location" => "http://test.host/scim_v2/Users/#{created_user.id}", + "resourceType" => "User" }, + "name" => { "familyName" => "Doe", + "givenName" => "John" }, + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName" => "jdoe" }) + end + end + + context "with the feature flag disabled", with_flag: { scim_api: false } do + it do + post "/scim_v2/Users/", "", headers + + response_body = JSON.parse(last_response.body) + expect(response_body).to eq( + { "detail" => "Requires authentication", "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status" => "401" } + ) + end + end + end + + describe "DELETE /scim_v2/Users/:id" do + context "with the feature flag enabled", with_flag: { scim_api: true } do + it do + group + + delete "/scim_v2/Users/#{user.id}", "", headers + + expect(last_response.body).to eq("") + expect(last_response).to have_http_status(204) + + get "/scim_v2/Users/#{user.id}", "", headers + + response_body = JSON.parse(last_response.body) + expect(response_body).to eq({ "active" => false, + "emails" => [{ "primary" => true, + "type" => "work", + "value" => user.mail }], + "externalId" => external_user_id, + "groups" => [{ "value" => group.id.to_s }], + "id" => user.id.to_s, + "meta" => { "created" => user.created_at.iso8601, + "lastModified" => user.updated_at.iso8601, + "location" => "http://test.host/scim_v2/Users/#{user.id}", + "resourceType" => "User" }, + "name" => { "familyName" => user.lastname, + "givenName" => user.firstname }, + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName" => user.login }) + + perform_enqueued_jobs + assert_performed_jobs 1 + + get "/scim_v2/Users/#{user.id}", "", headers + + response_body = JSON.parse(last_response.body) + expect(response_body).to eq( + { "detail" => "Resource \"#{user.id}\" not found", + "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status" => "404" } + ) + end + end + + context "with the feature flag disabled", with_flag: { scim_api: false } do + it do + delete "/scim_v2/Users/123", "", headers + + response_body = JSON.parse(last_response.body) + expect(response_body).to eq( + { "detail" => "Requires authentication", "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status" => "401" } + ) + end + end + end + + describe "PUT /scim_v2/Users/:id" do + before { group } + + context "with the feature flag enabled", with_flag: { scim_api: true } do + let(:new_external_user_id) { "new_idp_user_id_123asdqwe12345" } + + it do + request_body = { + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], + "externalId" => new_external_user_id, + "userName" => "jdoe", + "name" => { + "givenName" => "John", + "familyName" => "Doe" + }, + "active" => true, + "emails" => [ + { + "value" => "jdoe@example.com", + "type" => "work", + "primary" => true + } + ] + } + + put "/scim_v2/Users/#{user.id}", request_body.to_json, headers + + response_body = JSON.parse(last_response.body) + user.reload + expect(response_body).to eq({ "active" => true, + "emails" => [{ "primary" => true, + "type" => "work", + "value" => request_body["emails"].first["value"] }], + "externalId" => new_external_user_id, + "groups" => [{ "value" => group.id.to_s }], + "id" => user.id.to_s, + "meta" => { "created" => user.created_at.iso8601, + "lastModified" => user.updated_at.iso8601, + "location" => "http://test.host/scim_v2/Users/#{user.id}", + "resourceType" => "User" }, + "name" => { "familyName" => request_body["name"]["familyName"], + "givenName" => request_body["name"]["givenName"] }, + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName" => request_body["userName"] }) + end + end + + context "with the feature flag disabled", with_flag: { scim_api: false } do + it do + headers = { "CONTENT_TYPE" => "application/scim+json", "HTTP_AUTHORIZATION" => "Bearer access_token" } + put "/scim_v2/Users/123", "", headers + + response_body = JSON.parse(last_response.body) + expect(response_body).to eq( + { "detail" => "Requires authentication", "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status" => "401" } + ) + end + end + end + + describe "PATCH /scim_v2/Users/:id" do + let(:new_external_user_id) { "new_idp_user_id_123asdqwe12345" } + + before { group } + + context "with the feature flag enabled", with_flag: { scim_api: true } do + it "changes external_id" do + request_body = { + "schemas" => + ["urn =>ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations" => [{ + "op" => "replace", + "path" => "externalId", + "value" => new_external_user_id + }] + } + patch "/scim_v2/Users/#{user.id}", request_body.to_json, headers + + response_body = JSON.parse(last_response.body) + user.reload + expect(response_body).to eq({ "active" => true, + "emails" => [{ "primary" => true, + "type" => "work", + "value" => user.mail }], + "externalId" => new_external_user_id, + "groups" => [{ "value" => group.id.to_s }], + "id" => user.id.to_s, + "meta" => { "created" => user.created_at.iso8601, + "lastModified" => user.updated_at.iso8601, + "location" => "http://test.host/scim_v2/Users/#{user.id}", + "resourceType" => "User" }, + "name" => { "familyName" => user.lastname, + "givenName" => user.firstname }, + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName" => user.login }) + end + + it "changes email value" do + new_email_value = "qwertty@gmail.com" + request_body = { + "schemas" => + ["urn =>ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations" => [{ + "op" => "replace", + "path" => "emails[type eq \"work\"]", + "value" => + { + "type" => "work", + "value" => new_email_value, + "primary" => true + } + }] + } + patch "/scim_v2/Users/#{user.id}", request_body.to_json, headers + + response_body = JSON.parse(last_response.body) + user.reload + expect(response_body).to eq({ "active" => true, + "emails" => [{ "primary" => true, + "type" => "work", + "value" => new_email_value }], + "externalId" => user.scim_external_id, + "groups" => [{ "value" => group.id.to_s }], + "id" => user.id.to_s, + "meta" => { "created" => user.created_at.iso8601, + "lastModified" => user.updated_at.iso8601, + "location" => "http://test.host/scim_v2/Users/#{user.id}", + "resourceType" => "User" }, + "name" => { "familyName" => user.lastname, + "givenName" => user.firstname }, + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName" => user.login }) + end + end + + context "with the feature flag disabled", with_flag: { scim_api: false } do + it do + patch "/scim_v2/Users/123", "", headers + + response_body = JSON.parse(last_response.body) + expect(response_body).to eq( + { "detail" => "Requires authentication", "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status" => "401" } + ) + end + end + end +end diff --git a/spec/services/users/delete_service_spec.rb b/spec/services/users/delete_service_spec.rb index 2b566cdd2d4..9a05b189bdb 100644 --- a/spec/services/users/delete_service_spec.rb +++ b/spec/services/users/delete_service_spec.rb @@ -70,7 +70,7 @@ RSpec.describe Users::DeleteService, type: :model do allow(actor).to receive(:admin?).and_return false end - it_behaves_like "does not delete the user" + it_behaves_like "deletes the user" end context "with privileged system user" do @@ -90,7 +90,7 @@ RSpec.describe Users::DeleteService, type: :model do context "with system user" do let(:actor) { User.system } - it_behaves_like "does not delete the user" + it_behaves_like "deletes the user" end end end