Commit 443c19f8 authored by Drew Blessing's avatar Drew Blessing Committed by Drew Blessing

Full SCIM Identities support for the SCIM API

The SCIM API now supports SCIM Identities in all endpoints
when the `scim_identities` feature flag is enabled for a group.
For now the feature flag remains disabled by default.
parent 232c6d22
......@@ -28,9 +28,7 @@ module Users
end
end
unless identity_params.empty?
user.identities.build(identity_params)
end
build_identity(user)
user
end
......@@ -41,6 +39,12 @@ module Users
[:extern_uid, :provider]
end
def build_identity(user)
return if identity_params.empty?
user.identities.build(identity_params)
end
def can_create_user?
(current_user.nil? && Gitlab::CurrentSettings.allow_signup?) || current_user&.admin?
end
......
......@@ -23,7 +23,7 @@ class ScimFinder
def scim_identities_enabled?
strong_memoize(:scim_identities_enabled) do
Feature.enabled?(:scim_identities, group)
::EE::Gitlab::Scim::Feature.scim_identities_enabled?(group)
end
end
......@@ -47,9 +47,9 @@ class ScimFinder
parser = EE::Gitlab::Scim::ParamsParser.new(params)
if eq_filter_on_extern_uid?(parser)
by_extern_uid(parser)
by_extern_uid(parser.filter_params[:extern_uid])
elsif eq_filter_on_username?(parser)
by_username(parser)
by_username(parser.filter_params[:username])
else
raise UnsupportedFilter
end
......@@ -59,18 +59,18 @@ class ScimFinder
parser.filter_operator == :eq && parser.filter_params[:extern_uid].present?
end
def by_extern_uid(parser)
return group.scim_identities.with_extern_uid(parser.filter_params[:extern_uid]) if scim_identities_enabled?
def by_extern_uid(extern_uid)
return group.scim_identities.with_extern_uid(extern_uid) if scim_identities_enabled?
Identity.where_group_saml_uid(saml_provider, parser.filter_params[:extern_uid])
Identity.where_group_saml_uid(saml_provider, extern_uid)
end
def eq_filter_on_username?(parser)
parser.filter_operator == :eq && parser.filter_params[:username].present?
end
def by_username(parser)
user = User.find_by_username(parser.filter_params[:username])
def by_username(username)
user = User.find_by_username(username) || User.find_by_any_email(username)
return group.scim_identities.for_user(user) if scim_identities_enabled?
......
......@@ -51,6 +51,13 @@ module EE
super.push(:saml_provider_id)
end
override :build_identity
def build_identity(user)
return build_scim_identity(user) if params[:provider] == 'group_scim'
super
end
override :identity_params
def identity_params
if group_id_for_saml.present?
......@@ -60,6 +67,10 @@ module EE
end
end
def scim_identity_attributes
[:group_id, :extern_uid]
end
def saml_provider_id
strong_memoize(:saml_provider_id) do
group = GroupFinder.new(current_user).execute(id: group_id_for_saml)
......@@ -75,6 +86,12 @@ module EE
issuer: params[:certificate_issuer])
end
end
def build_scim_identity(user)
scim_identity_params = params.slice(*scim_identity_attributes)
user.scim_identities.build(scim_identity_params.merge(active: true))
end
end
end
end
......@@ -2,6 +2,8 @@
module API
class Scim < Grape::API
include ::Gitlab::Utils::StrongMemoize
prefix 'api/scim'
version 'v2'
content_type :json, 'application/scim+json'
......@@ -19,16 +21,6 @@ module API
API.logger
end
def destroy_identity(identity)
GroupSaml::Identity::DestroyService.new(identity).execute(transactional: true)
true
rescue => e
logger.error(identity: identity, error: e.class.name, message: e.message, source: "#{__FILE__}:#{__LINE__}")
false
end
def render_scim_error(error_class, message)
error!({ with: error_class }.merge(detail: message), error_class::STATUS)
end
......@@ -69,25 +61,70 @@ module API
parsed_hash = parser.update_params
if parser.deprovision_user?
destroy_identity(identity)
deprovision(identity)
elsif reprovisionable?(identity) && parser.reprovision_user?
reprovision(identity)
elsif parsed_hash[:extern_uid]
identity.update(parsed_hash.slice(:extern_uid))
else
scim_conflict!(message: 'Email has already been taken') if email_taken?(parsed_hash[:email], identity)
result = ::Users::UpdateService.new(identity.user,
parsed_hash.except(:extern_uid)
parsed_hash.except(:extern_uid, :active)
.merge(user: identity.user)).execute
result[:status] == :success
end
end
def reprovisionable?(identity)
return true if identity.respond_to?(:active) && !identity.active?
false
end
def email_taken?(email, identity)
return unless email
User.by_any_email(email.downcase).where.not(id: identity.user.id).exists?
end
def find_user_identity(group, extern_uid)
return unless group.saml_provider
return group.scim_identities.with_extern_uid(extern_uid).first if scim_identities_enabled?
GroupSamlIdentityFinder.find_by_group_and_uid(group: group, uid: extern_uid)
end
def scim_identities_enabled?
strong_memoize(:scim_identities_enabled) do
::EE::Gitlab::Scim::Feature.scim_identities_enabled?(@group)
end
end
def deprovision(identity)
if scim_identities_enabled?
::EE::Gitlab::Scim::DeprovisionService.new(identity).execute
else
GroupSaml::Identity::DestroyService.new(identity).execute(transactional: true)
end
true
rescue => e
logger.error(identity: identity, error: e.class.name, message: e.message, source: "#{__FILE__}:#{__LINE__}")
false
end
def reprovision(identity)
::EE::Gitlab::Scim::ReprovisionService.new(identity).execute
true
rescue => e
logger.error(identity: identity, error: e.class.name, message: e.message, source: "#{__FILE__}:#{__LINE__}")
false
end
end
resource :Users do
......@@ -118,7 +155,7 @@ module API
get ':id', requirements: USER_ID_REQUIREMENTS do
group = find_and_authenticate_group!(params[:group])
identity = GroupSamlIdentityFinder.find_by_group_and_uid(group: group, uid: params[:id])
identity = find_user_identity(group, params[:id])
scim_not_found!(message: "Resource #{params[:id]} not found") unless identity
......@@ -154,7 +191,7 @@ module API
scim_error!(message: 'Missing ID') unless params[:id]
group = find_and_authenticate_group!(params[:group])
identity = GroupSamlIdentityFinder.find_by_group_and_uid(group: group, uid: params[:id])
identity = find_user_identity(group, params[:id])
scim_not_found!(message: "Resource #{params[:id]} not found") unless identity
......@@ -174,11 +211,11 @@ module API
scim_error!(message: 'Missing ID') unless params[:id]
group = find_and_authenticate_group!(params[:group])
identity = GroupSamlIdentityFinder.find_by_group_and_uid(group: group, uid: params[:id])
identity = find_user_identity(group, params[:id])
scim_not_found!(message: "Resource #{params[:id]} not found") unless identity
destroyed = destroy_identity(identity)
destroyed = deprovision(identity)
scim_not_found!(message: "Resource #{params[:id]} not found") unless destroyed
......
......@@ -29,9 +29,11 @@ module EE
end
def active
# We don't block the user yet when deprovisioning
# So the user is always active, until the identity link is removed.
true
object_active = object.try(:active)
return true if object_active.nil?
object_active
end
def email_type
......
# frozen_string_literal: true
module EE
module Gitlab
module Scim
class DeprovisionService
attr_reader :identity
delegate :user, :group, to: :identity
def initialize(identity)
@identity = identity
end
def execute
ScimIdentity.transaction do
identity.update!(active: false)
remove_group_access
end
end
private
def remove_group_access
return unless group_membership
return if group.last_owner?(user)
::Members::DestroyService.new(user).execute(group_membership)
end
def group_membership
@group_membership ||= group.group_member(user)
end
end
end
end
end
# frozen_string_literal: true
module EE
module Gitlab
module Scim
class Feature
def self.scim_identities_enabled?(group)
::Feature.enabled?(:scim_identities, group)
end
end
end
end
end
......@@ -15,6 +15,10 @@ module EE
update_params[:active] == false
end
def reprovision_user?
!deprovision_user?
end
def post_params
@post_params ||= process_post_params
end
......
......@@ -6,7 +6,6 @@ module EE
class ProvisioningService
include ::Gitlab::Utils::StrongMemoize
IDENTITY_PROVIDER = 'group_saml'
PASSWORD_AUTOMATICALLY_SET = true
SKIP_EMAIL_CONFIRMATION = false
DEFAULT_ACCESS = :guest
......@@ -39,9 +38,27 @@ module EE
ProvisioningResponse.new(status: :success, identity: identity)
end
def scim_identities_enabled?
strong_memoize(:scim_identities_enabled) do
::EE::Gitlab::Scim::Feature.scim_identities_enabled?(@group)
end
end
def identity_provider
strong_memoize(:identity_provider) do
next 'group_scim' if scim_identities_enabled?
'group_saml'
end
end
def identity
strong_memoize(:identity) do
::Identity.with_extern_uid(IDENTITY_PROVIDER, @parsed_hash[:extern_uid]).first
if scim_identities_enabled?
@group.scim_identities.with_extern_uid(@parsed_hash[:extern_uid]).first
else
::Identity.with_extern_uid(identity_provider, @parsed_hash[:extern_uid]).first
end
end
end
......@@ -67,8 +84,9 @@ module EE
def user_params
@parsed_hash.tap do |hash|
hash[:skip_confirmation] = SKIP_EMAIL_CONFIRMATION
hash[:saml_provider_id] = @group.saml_provider.id
hash[:provider] = IDENTITY_PROVIDER
hash[:saml_provider_id] = @group.saml_provider&.id
hash[:group_id] = @group.id
hash[:provider] = identity_provider
hash[:email_confirmation] = hash[:email]
hash[:username] = valid_username
hash[:password] = hash[:password_confirmation] = random_password
......
# frozen_string_literal: true
module EE
module Gitlab
module Scim
class ReprovisionService
attr_reader :identity
delegate :user, :group, to: :identity
DEFAULT_ACCESS = :guest
def initialize(identity)
@identity = identity
end
def execute
ScimIdentity.transaction do
identity.update!(active: true)
add_member unless existing_member?
end
end
private
def add_member
group.add_user(user, DEFAULT_ACCESS)
end
def existing_member?
::GroupMember.member_of_group?(group, user)
end
end
end
end
end
......@@ -55,6 +55,10 @@ describe ScimFinder do
it 'allows lookup by userName' do
expect(finder.search(filter: "userName eq \"#{id.user.username}\"").first).to eq id
end
it 'allows lookup by userName when userName is an email address' do
expect(finder.search(filter: "userName eq #{id.user.email}").first).to eq id
end
end
context 'when scim_identities is disabled' do
......
......@@ -39,4 +39,14 @@ describe ::EE::API::Entities::Scim::User do
it 'contains the resource type' do
expect(subject[:meta][:resourceType]).to eq('User')
end
context 'with a SCIM identity' do
let(:identity) { build(:scim_identity, user: user) }
it 'contains active false when the identity is not active' do
identity.active = false
expect(subject[:active]).to be false
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe ::EE::Gitlab::Scim::DeprovisionService do
describe '#execute' do
let_it_be(:identity) { create(:scim_identity, active: true) }
let_it_be(:group) { identity.group }
let_it_be(:user) { identity.user }
let(:service) { described_class.new(identity) }
it 'deactivates scim identity' do
service.execute
expect(identity.active).to be false
end
it 'removes group access' do
create(:group_member, group: group, user: user, access_level: GroupMember::REPORTER)
service.execute
expect(group.members.pluck(:user_id)).not_to include(user.id)
end
it 'does not remove the last owner' do
create(:group_member, group: group, user: user, access_level: GroupMember::OWNER)
service.execute
expect(identity.group.members.pluck(:user_id)).to include(user.id)
end
it 'does not change group membership when the user is not a member' do
expect { service.execute }.not_to change { group.members.count }
end
end
end
......@@ -98,4 +98,18 @@ describe EE::Gitlab::Scim::ParamsParser do
expect(described_class.new(Operations: operations).deprovision_user?).to be false
end
end
describe '#reprovision_user?' do
it 'returns true when reprovisioning' do
operations = [{ 'op': 'Replace', 'path': 'active', 'value': 'True' }]
expect(described_class.new(Operations: operations).reprovision_user?).to be true
end
it 'returns false when not reprovisioning' do
operations = [{ 'op': 'Replace', 'path': 'active', 'value': 'False' }]
expect(described_class.new(Operations: operations).reprovision_user?).to be false
end
end
end
......@@ -9,86 +9,138 @@ describe ::EE::Gitlab::Scim::ProvisioningService do
before do
stub_licensed_features(group_saml: true)
create(:saml_provider, group: group)
end
context 'valid params' do
let(:service_params) do
{
email: 'work@example.com',
name: 'Test Name',
extern_uid: 'test_uid',
username: 'username'
}.freeze
end
shared_examples 'scim provisioning' do
context 'valid params' do
let_it_be(:service_params) do
{
email: 'work@example.com',
name: 'Test Name',
extern_uid: 'test_uid',
username: 'username'
}
end
it 'succeeds' do
expect(service.execute.status).to eq(:success)
end
def user
User.find_by(email: service_params[:email])
end
it 'creates the identity' do
expect { service.execute }.to change { Identity.count }.by(1)
end
it 'succeeds' do
expect(service.execute.status).to eq(:success)
end
it 'creates the user' do
expect { service.execute }.to change { User.count }.by(1)
end
it 'creates the user' do
expect { service.execute }.to change { User.count }.by(1)
end
it 'creates the group member' do
expect { service.execute }.to change { GroupMember.count }.by(1)
end
it 'creates the group member' do
expect { service.execute }.to change { GroupMember.count }.by(1)
end
it 'creates the correct user attributes' do
service.execute
it 'creates the correct user attributes' do
service.execute
expect(User.find_by(service_params.except(:extern_uid))).to be_a(User)
end
expect(user).to be_a(User)
end
it 'user record requires confirmation' do
service.execute
it 'creates the member with guest access level' do
service.execute
user = User.find_by(email: service_params[:email])
access_level = group.group_member(user).access_level
expect(user).to be_present
expect(user).not_to be_confirmed
end
expect(access_level).to eq(Gitlab::Access::GUEST)
end
context 'when the current minimum password length is different from the default minimum password length' do
before do
stub_application_setting minimum_password_length: 21
it 'user record requires confirmation' do
service.execute
expect(user).to be_present
expect(user).not_to be_confirmed
end
it 'creates the user' do
expect { service.execute }.to change { User.count }.by(1)
context 'when the current minimum password length is different from the default minimum password length' do
before do
stub_application_setting minimum_password_length: 21
end
it 'creates the user' do
expect { service.execute }.to change { User.count }.by(1)
end
end
end
context 'existing user' do
before do
create(:user, email: 'work@example.com')
context 'existing user' do
before do
create(:user, email: 'work@example.com')
end
it 'does not create a new user' do
expect { service.execute }.not_to change { User.count }
end
it 'fails with conflict' do
expect(service.execute.status).to eq(:conflict)
end
end
end
it 'does not create a new user' do
expect { service.execute }.not_to change { User.count }
context 'invalid params' do
let_it_be(:service_params) do
{
email: 'work@example.com',
name: 'Test Name',
extern_uid: 'test_uid'
}
end
it 'fails with conflict' do
expect(service.execute.status).to eq(:conflict)
it 'fails with error' do
expect(service.execute.status).to eq(:error)
end
end
end
context 'invalid params' do
let(:service_params) do
context 'when scim_identities is disabled' do
before do
stub_feature_flags(scim_identities: false)
create(:saml_provider, group: group)
end
let_it_be(:service_params) do
{
email: 'work@example.com',
name: 'Test Name',
extern_uid: 'test_uid'
}.freeze
extern_uid: 'test_uid',
username: 'username'
}
end
it 'fails with error' do
expect(service.execute.status).to eq(:error)
it_behaves_like 'scim provisioning'
it 'creates the identity' do
expect { service.execute }.to change { Identity.count }.by(1)
expect { service.execute }.not_to change { ScimIdentity.count }
end
end
context 'when scim_identities is enabled' do
before do
stub_feature_flags(scim_identities: true)
end
let_it_be(:service_params) do
{
email: 'work@example.com',
name: 'Test Name',
extern_uid: 'test_uid',
username: 'username'
}
end
it_behaves_like 'scim provisioning'
it 'creates the scim identity' do
expect { service.execute }.to change { ScimIdentity.count }.by(1)
expect { service.execute }.not_to change { Identity.count }
end
end
end
......
# frozen_string_literal: true
require 'spec_helper'
describe ::EE::Gitlab::Scim::ReprovisionService do
describe '#execute' do
let_it_be(:identity) { create(:scim_identity, active: false) }
let_it_be(:group) { identity.group }
let_it_be(:user) { identity.user }
let(:service) { described_class.new(identity) }
it 'activates scim identity' do
service.execute
expect(identity.active).to be true
end
it 'creates the member' do
service.execute
expect(group.members.pluck(:user_id)).to include(user.id)
end
it 'creates the member with guest access level' do
service.execute
access_level = group.group_member(user).access_level
expect(access_level).to eq(Gitlab::Access::GUEST)
end
it 'does not change group membership when the user is already a member' do
create(:group_member, group: group, user: user)
expect { service.execute }.not_to change { group.members.count }
end
end
end
This diff is collapsed.
......@@ -23,6 +23,23 @@ describe Users::BuildService do
it 'sets all allowed attributes' do
expect(Identity).to receive(:new).with(hash_including(identity_params)).and_call_original
expect(ScimIdentity).not_to receive(:new)
service.execute
end
end
context 'with scim identity' do
before do
params.merge!(scim_identity_params)
end
let_it_be(:scim_identity_params) { { extern_uid: 'uid', provider: 'group_scim', group_id: 1 } }
it 'passes allowed attributes to scim identity' do
scim_identity_params.delete(:provider)
expect(ScimIdentity).to receive(:new).with(hash_including(scim_identity_params)).and_call_original
expect(Identity).not_to receive(:new)
service.execute
end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment