mirror of
https://github.com/opf/openproject.git
synced 2026-06-13 19:20:00 +00:00
[#62107] Add SCIM server API
https://community.openproject.org/work_packages/62107
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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?
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user