Commit e5afd147 authored by Drew Blessing's avatar Drew Blessing Committed by Drew Blessing

Separate SCIM Identities from SAML

This lays the framework for separating SCIM and SAML identities and
will make SCIM support more flexible.
parent 3f63ffaf
---
title: Create scim_identities table in preparation for newer SCIM features in the
future
merge_request: 26124
author:
type: added
# frozen_string_literal: true
class CreateScimIdentities < ActiveRecord::Migration[6.0]
DOWNTIME = false
def change
create_table :scim_identities do |t|
t.references :group, foreign_key: { to_table: :namespaces, on_delete: :cascade }, null: false
t.references :user, index: false, foreign_key: { on_delete: :cascade }, null: false
t.timestamps_with_timezone
t.boolean :active, default: false
t.string :extern_uid, null: false, limit: 255
t.index 'LOWER(extern_uid),group_id', name: 'index_scim_identities_on_lower_extern_uid_and_group_id', unique: true
t.index [:user_id, :group_id], unique: true
end
end
end
......@@ -3800,6 +3800,18 @@ ActiveRecord::Schema.define(version: 2020_03_04_160823) do
t.index ["group_id"], name: "index_saml_providers_on_group_id"
end
create_table "scim_identities", force: :cascade do |t|
t.bigint "group_id", null: false
t.bigint "user_id", null: false
t.datetime_with_timezone "created_at", null: false
t.datetime_with_timezone "updated_at", null: false
t.boolean "active", default: false
t.string "extern_uid", limit: 255, null: false
t.index "lower((extern_uid)::text), group_id", name: "index_scim_identities_on_lower_extern_uid_and_group_id", unique: true
t.index ["group_id"], name: "index_scim_identities_on_group_id"
t.index ["user_id", "group_id"], name: "index_scim_identities_on_user_id_and_group_id", unique: true
end
create_table "scim_oauth_access_tokens", id: :serial, force: :cascade do |t|
t.datetime_with_timezone "created_at", null: false
t.datetime_with_timezone "updated_at", null: false
......@@ -5016,6 +5028,8 @@ ActiveRecord::Schema.define(version: 2020_03_04_160823) do
add_foreign_key "reviews", "projects", on_delete: :cascade
add_foreign_key "reviews", "users", column: "author_id", on_delete: :nullify
add_foreign_key "saml_providers", "namespaces", column: "group_id", on_delete: :cascade
add_foreign_key "scim_identities", "namespaces", column: "group_id", on_delete: :cascade
add_foreign_key "scim_identities", "users", on_delete: :cascade
add_foreign_key "scim_oauth_access_tokens", "namespaces", column: "group_id", on_delete: :cascade
add_foreign_key "security_scans", "ci_builds", column: "build_id", on_delete: :cascade
add_foreign_key "self_managed_prometheus_alert_events", "environments", on_delete: :cascade
......
# frozen_string_literal: true
class ScimFinder
attr_reader :saml_provider
include ::Gitlab::Utils::StrongMemoize
attr_reader :group, :saml_provider
UnsupportedFilter = Class.new(StandardError)
def initialize(group)
@group = group
@saml_provider = group&.saml_provider
end
def search(params)
return Identity.none unless saml_provider&.enabled?
return saml_provider.identities if unfiltered?(params)
return null_identity unless saml_provider&.enabled?
return all_identities if unfiltered?(params)
filter_identities(params)
end
private
def scim_identities_enabled?
strong_memoize(:scim_identities_enabled) do
Feature.enabled?(:scim_identities, group)
end
end
def null_identity
return ScimIdentity.none if scim_identities_enabled?
Identity.none
end
def all_identities
return group.scim_identities if scim_identities_enabled?
saml_provider.identities
end
def unfiltered?(params)
params[:filter].blank?
end
......@@ -39,6 +60,8 @@ class ScimFinder
end
def by_extern_uid(parser)
return group.scim_identities.with_extern_uid(parser.filter_params[:extern_uid]) if scim_identities_enabled?
Identity.where_group_saml_uid(saml_provider, parser.filter_params[:extern_uid])
end
......@@ -48,6 +71,9 @@ class ScimFinder
def by_username(parser)
user = User.find_by_username(parser.filter_params[:username])
return group.scim_identities.for_user(user) if scim_identities_enabled?
saml_provider.identities.for_user(user)
end
end
......@@ -19,6 +19,7 @@ module EE
has_many :epics
has_one :saml_provider
has_many :scim_identities
has_many :ip_restrictions, autosave: true
has_one :insight, foreign_key: :namespace_id
accepts_nested_attributes_for :insight, allow_destroy: true
......
......@@ -53,6 +53,7 @@ module EE
has_many :protected_branch_unprotect_access_levels, dependent: :destroy, class_name: "::ProtectedBranch::UnprotectAccessLevel" # rubocop:disable Cop/ActiveRecordDependent
has_many :smartcard_identities
has_many :scim_identities
belongs_to :managing_group, class_name: 'Group', optional: true, inverse_of: :managed_users
......
# frozen_string_literal: true
class ScimIdentity < ApplicationRecord
include Sortable
include CaseSensitivity
include ScimPaginatable
belongs_to :group
belongs_to :user
validates :group, presence: true
validates :user, presence: true, uniqueness: { scope: [:group_id] }
validates :extern_uid, presence: true,
uniqueness: { case_sensitive: false, scope: [:group_id] }
scope :for_user, ->(user) { where(user: user) }
scope :with_extern_uid, ->(extern_uid) { iwhere(extern_uid: extern_uid) }
end
# frozen_string_literal: true
FactoryBot.define do
factory :scim_identity do
extern_uid { generate(:username) }
group
user
active { true }
end
end
......@@ -10,9 +10,17 @@ describe ScimFinder do
describe '#search' do
context 'without a SAML provider' do
it 'returns an empty relation when there is no saml provider' do
it 'returns an empty identity relation when scim_identities is disabled' do
stub_feature_flags(scim_identities: false)
expect(finder.search(unused_params)).to eq Identity.none
end
it 'returns an empty scim identity relation when scim_identities is enabled' do
stub_feature_flags(scim_identities: true)
expect(finder.search(unused_params)).to eq ScimIdentity.none
end
end
context 'SCIM/SAML is not enabled' do
......@@ -20,35 +28,70 @@ describe ScimFinder do
create(:saml_provider, group: group, enabled: false)
end
it 'returns an empty relation when SCIM/SAML is not enabled' do
it 'returns an empty identity relation when scim_identities is disabled' do
stub_feature_flags(scim_identities: false)
expect(finder.search(unused_params)).to eq Identity.none
end
it 'returns an empty scim identity relation when scim_identities is enabled' do
stub_feature_flags(scim_identities: true)
expect(finder.search(unused_params)).to eq ScimIdentity.none
end
end
context 'with SCIM enabled' do
let!(:saml_provider) { create(:saml_provider, group: group) }
context 'with an eq filter' do
let!(:identity) { create(:group_saml_identity, saml_provider: saml_provider) }
let!(:other_identity) { create(:group_saml_identity, saml_provider: saml_provider) }
shared_examples 'valid lookups' do
it 'allows identity lookup by id/externalId' do
expect(finder.search(filter: "id eq #{identity.extern_uid}")).to be_a ActiveRecord::Relation
expect(finder.search(filter: "id eq #{identity.extern_uid}").first).to eq identity
expect(finder.search(filter: "externalId eq #{identity.extern_uid}").first).to eq identity
expect(finder.search(filter: "id eq #{id.extern_uid}")).to be_a ActiveRecord::Relation
expect(finder.search(filter: "id eq #{id.extern_uid}").first).to eq id
expect(finder.search(filter: "externalId eq #{id.extern_uid}").first).to eq id
end
it 'allows lookup by userName' do
expect(finder.search(filter: "userName eq \"#{identity.user.username}\"").first).to eq identity
expect(finder.search(filter: "userName eq \"#{id.user.username}\"").first).to eq id
end
end
context 'when scim_identities is disabled' do
before do
stub_feature_flags(scim_identities: false)
end
let(:id) { create(:group_saml_identity, saml_provider: saml_provider) }
it_behaves_like 'valid lookups'
end
context 'when scim_identities is enabled' do
before do
stub_feature_flags(scim_identities: true)
end
let(:id) { create(:scim_identity, group: group) }
it_behaves_like 'valid lookups'
end
end
it 'returns all related identities if there is no filter' do
context 'with no filter' do
it 'returns all related identities when scim_identities is disabled' do
stub_feature_flags(scim_identities: false)
create_list(:group_saml_identity, 2, saml_provider: saml_provider)
expect(finder.search({}).count).to eq 2
end
it 'returns all related identities when scim_identities is enabled' do
stub_feature_flags(scim_identities: true)
create_list(:scim_identity, 4, group: group)
expect(finder.search({}).count).to eq 4
end
end
it 'raises an error if the filter is unsupported' do
expect { finder.search(filter: 'id ne 1').count }.to raise_error(ScimFinder::UnsupportedFilter)
end
......
# frozen_string_literal: true
require 'spec_helper'
describe ScimIdentity do
describe 'relations' do
it { is_expected.to belong_to(:group) }
it { is_expected.to belong_to(:user) }
end
describe 'validations' do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
context 'with existing user and group' do
before do
create(:scim_identity, user: user, group: group, extern_uid: user.email)
end
it 'returns false for a duplicate identity with the same extern_uid' do
identity = user.scim_identities.build(group: group, extern_uid: user.email)
expect(identity.validate).to eq(false)
end
it 'returns false for a duplicate identity with different extern_uid' do
identity = user.scim_identities.build(group: group, extern_uid: '1234abcd')
expect(identity.validate).to eq(false)
end
it 'returns true when a different group is used' do
other_group = create(:group)
identity = user.scim_identities.build(group: other_group, extern_uid: user.email)
expect(identity.validate).to eq(true)
end
it 'returns false for a duplicate extern_uid with different case' do
identity = user.scim_identities.build(group: group, extern_uid: user.email.upcase)
expect(identity.validate).to eq(false)
end
end
end
describe '.with_extern_uid' do
it 'finds identity regardless of case' do
user = create(:user)
group = create(:group)
identity = user.scim_identities.create(group: group, extern_uid: user.email)
expect(group.scim_identities.with_extern_uid(user.email.upcase).first).to eq identity
end
end
end
......@@ -9,6 +9,7 @@ describe API::Scim do
before do
stub_licensed_features(group_allowed_email_domains: true, group_saml: true)
stub_feature_flags(scim_identities: false)
group.add_owner(user)
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