[#62107] Add SCIM server API

https://community.openproject.org/work_packages/62107
This commit is contained in:
Pavel Balashou
2025-03-26 10:35:15 +01:00
parent 25f61a6781
commit af05f29bbf
18 changed files with 1154 additions and 12 deletions
+2
View File
@@ -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"
+4
View File
@@ -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
+3
View File
@@ -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
+4 -1
View File
@@ -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
@@ -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
+103
View File
@@ -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
+76
View File
@@ -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
+97 -7
View File
@@ -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?
+4
View File
@@ -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.
+2
View File
@@ -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."
+37
View File
@@ -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
+2
View File
@@ -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"
+18
View File
@@ -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
@@ -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
+3 -2
View File
@@ -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,
+303
View File
@@ -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
+384
View File
@@ -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
+2 -2
View File
@@ -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