Skip to content
Snippets Groups Projects
Commit bac649c0 authored by Alex Pooley's avatar Alex Pooley
Browse files

Tag a Session with a Namespace

This allows us to authorize a user's session for access to only a
specific portion of the GitLab instance in future MRs.
parent 6800698d
No related merge requests found
# frozen_string_literal: true
# Backing store for GitLab session data.
#
# The raw session information is stored by the Rails session store
# (config/initializers/session_store.rb). These entries are accessible by the
# rack_key_name class method and consistute the base of the session data
# entries. All other entries in the session store can be traced back to these
# entries.
#
# After a user logs in (config/initializers/warden.rb) a further entry is made
# in the session store. This entry holds a record of the user's logged in
# session. These are accessible with the key_name(user_id, session_id) class
# method. These entries will expire. Lookups to these entries are lazilly
# cleaned on future user access.
#
# The user's logged in session information is referenced by two lookup entries.
#
# The first is a reference to all sessions that belong to a specific user. A
# user may login through multiple browsers/devices and thus record multiple
# login sessions. These are accessible through the lookup_key_name(user_id)
# class method.
#
# The second lookup entry is a reference to which part of the system a user
# login has been authorized to access. This is accessible through the
# session_scopes_key_name(user_id, session_id) class method.
#
class ActiveSession
include ActiveModel::Model
......@@ -30,7 +55,7 @@ def public_id
Gitlab::CryptoHelper.aes256_gcm_encrypt(session_id)
end
def self.set(user, request)
def self.set(user, request, scope: nil)
Gitlab::Redis::SharedState.with do |redis|
session_private_id = request.session.id.private_id
client = DeviceDetector.new(request.user_agent)
......@@ -62,6 +87,13 @@ def self.set(user, request)
session_private_id
)
if scope
redis.sadd(
session_scopes_key_name(user.id, session_private_id),
scope
)
end
# We remove the ActiveSession stored by using public_id to avoid
# duplicate entries
remove_deprecated_active_sessions_with_public_id(redis, user.id, request.session.id.public_id)
......@@ -103,10 +135,15 @@ def self.destroy_with_rack_session_id(user, rack_session_id)
end
def self.destroy_sessions(redis, user, session_ids)
key_names = session_ids.map { |session_id| key_name(user.id, session_id) }
redis.srem(lookup_key_name(user.id), session_ids)
key_names = session_ids.flat_map do |session_id|
[
key_name(user.id, session_id),
session_scopes_key_name(user.id, session_id)
]
end
Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
redis.del(key_names)
redis.del(rack_session_keys(session_ids))
......@@ -143,7 +180,11 @@ def self.not_impersonated(user)
list(user).reject(&:is_impersonated)
end
def self.key_name(user_id, session_id = '*')
def self.rack_key_name(session_id)
"#{Gitlab::Redis::SharedState::SESSION_NAMESPACE}:#{session_id}"
end
def self.key_name(user_id, session_id)
"#{Gitlab::Redis::SharedState::USER_SESSIONS_NAMESPACE}:#{user_id}:#{session_id}"
end
......@@ -151,6 +192,10 @@ def self.lookup_key_name(user_id)
"#{Gitlab::Redis::SharedState::USER_SESSIONS_LOOKUP_NAMESPACE}:#{user_id}"
end
def self.session_scopes_key_name(user_id, session_id)
"#{Gitlab::Redis::SharedState::USER_SESSIONS_SCOPES_NAMESPACE}:{#{user_id}}:#{session_id}"
end
def self.list_sessions(user)
sessions_from_ids(session_ids_for_user(user.id))
end
......@@ -185,6 +230,22 @@ def self.sessions_from_ids(session_ids)
end
end
def self.scopes_for_user(user)
Gitlab::Redis::SharedState.with do |redis|
keys = session_ids_for_user(user.id).map do |session_id|
session_scopes_key_name(user.id, session_id)
end
keys.present? ? redis.sunion(keys) : []
end
end
def self.scopes_for_session_id(user, session_id)
Gitlab::Redis::SharedState.with do |redis|
redis.smembers(session_scopes_key_name(user.id, session_id))
end
end
# Deserializes a session Hash object from Redis.
#
# raw_session - Raw bytes from Redis
......@@ -197,7 +258,7 @@ def self.load_raw_session(raw_session)
end
def self.rack_session_keys(rack_session_ids)
rack_session_ids.map { |session_id| "#{Gitlab::Redis::SharedState::SESSION_NAMESPACE}:#{session_id}" }
rack_session_ids.map { |session_id| rack_key_name(session_id) }
end
def self.raw_active_session_entries(redis, session_ids, user_id)
......@@ -247,6 +308,7 @@ def self.cleaned_up_lookup_entries(redis, user)
redis.pipelined do
session_ids_and_entries.reject { |_session_id, entry| entry }.each do |session_id, _entry|
redis.srem(lookup_key_name(user.id), session_id)
redis.del(session_scopes_key_name(user.id, session_id))
end
end
......
# frozen_string_literal: true
Rails.application.configure do |config|
Warden::Manager.after_authentication(scope: :user) do |user, auth, opts|
# Parse gl_scope as a GitLab scoped session.
if opts.has_key?(:gl_scope)
ActiveSession.set(user, auth.request, scope: opts[:gl_scope])
end
end
end
......@@ -31,6 +31,7 @@ def link_identity(identity_linker)
def redirect_identity_linked
flash[:notice] = "SAML for #{@unauthenticated_group.name} was added to your connected accounts"
sign_in(current_user, event: :authentication)
redirect_to after_sign_in_path_for(current_user)
end
......@@ -38,9 +39,14 @@ def redirect_identity_linked
def redirect_identity_exists
flash[:notice] = "Already signed in with SAML for #{@unauthenticated_group.name}"
sign_in(current_user, event: :authentication)
redirect_to after_sign_in_path_for(current_user)
end
def session_scope(group)
"namespace_#{group.id}"
end
override :redirect_identity_link_failed
def redirect_identity_link_failed(error_message)
flash[:notice] = "SAML authentication failed: #{error_message}"
......@@ -61,6 +67,12 @@ def sign_in_and_redirect(user, *args)
def sign_in(resource_or_scope, *args)
store_active_saml_session
# Scope the session by the group.
opts = args.last
opts.merge!(
gl_scope: session_scope(@unauthenticated_group),
force: true
)
super
end
......
......@@ -54,10 +54,13 @@ def stub_last_request_id(id)
shared_examples 'works with session enforcement' do
it 'stores that a SAML session is active' do
expect(Gitlab::Auth::GroupSaml::SsoEnforcer).to receive(:new).with(saml_provider).and_call_original
expect_any_instance_of(Gitlab::Auth::GroupSaml::SsoEnforcer).to receive(:update_session)
freeze_time do
post provider, params: { group_id: group }
post provider, params: { group_id: group }
expect(session[Gitlab::Auth::GroupSaml::SsoState::SESSION_STORE_KEY]).to include({
saml_provider.id => DateTime.current
})
end
end
end
......@@ -79,6 +82,12 @@ def stub_last_request_id(id)
post provider, params: { group_id: group }
end
it 'scopes the session authorization' do
post provider, params: { group_id: group }
expect(ActiveSession.scopes_for_user(user)).to eq ["namespace_#{group.id}"]
end
include_examples 'works with session enforcement'
end
......
......@@ -9,6 +9,7 @@ class SharedState < ::Gitlab::Redis::Wrapper
SESSION_NAMESPACE = 'session:gitlab'
USER_SESSIONS_NAMESPACE = 'session:user:gitlab'
USER_SESSIONS_LOOKUP_NAMESPACE = 'session:lookup:user:gitlab'
USER_SESSIONS_SCOPES_NAMESPACE = 'session:user:scope:gitlab'
IP_SESSIONS_LOOKUP_NAMESPACE = 'session:lookup:ip:gitlab2'
DEFAULT_REDIS_SHARED_STATE_URL = 'redis://localhost:6382'
REDIS_SHARED_STATE_CONFIG_ENV_VAR_NAME = 'GITLAB_REDIS_SHARED_STATE_CONFIG_FILE'
......
......@@ -158,6 +158,38 @@
end
end
describe '.scopes_for_user' do
let(:session_id_1) { '6919a6f1bb119dd7396fadc38fd18d0d' }
let(:session_id_2) { '59822c7d9fcdfa03725eff41782ad97d' }
before do
Gitlab::Redis::SharedState.with do |redis|
redis.set("session:user:gitlab:#{user.id}:#{session_id_2}", Marshal.dump({ session_id: 'a' }))
redis.set("session:user:gitlab:#{user.id}:#{session_id_2}", Marshal.dump({ session_id: 'a' }))
redis.sadd("session:lookup:user:gitlab:#{user.id}", [session_id_1, session_id_2])
redis.sadd("session:user:scope:gitlab:{#{user.id}}:#{session_id_1}", [1, 2, 3])
redis.sadd("session:user:scope:gitlab:{#{user.id}}:#{session_id_2}", [4, 5, 6])
end
end
it { expect(ActiveSession.scopes_for_user(user)).to contain_exactly(*%w[1 2 3 4 5 6]) }
end
describe '.scopes_for_session_id' do
let(:session_id) { '6919a6f1bb119dd7396fadc38fd18d0d' }
let(:scopes) { %w[scope1 scope2] }
before do
Gitlab::Redis::SharedState.with do |redis|
redis.sadd("session:user:scope:gitlab:{#{user.id}}:#{session_id}", scopes)
end
end
it { expect(ActiveSession.scopes_for_session_id(user, session_id)).to contain_exactly(*scopes) }
end
describe '.set' do
it 'sets a new redis entry for the user session and a lookup entry' do
ActiveSession.set(user, request)
......@@ -235,6 +267,21 @@
end
end
end
context 'with scope' do
let_it_be(:scope) { 'some_scope' }
before do
ActiveSession.set(user, request, scope: scope)
end
it 'sets a new redis entry for the user session scope' do
Gitlab::Redis::SharedState.with do |redis|
key = "session:user:scope:gitlab:{#{user.id}}:#{session.id.private_id}"
expect(redis.smembers(key)).to eq [scope]
end
end
end
end
describe '.destroy_with_rack_session_id' do
......@@ -274,6 +321,20 @@
end
end
it 'removes session scopes' do
key = "session:user:scope:gitlab:{#{user.id}}:#{request.session.id}"
Gitlab::Redis::SharedState.with do |redis|
redis.sadd(key, [1, 2, 3])
end
ActiveSession.destroy_with_rack_session_id(user, request.session.id)
Gitlab::Redis::SharedState.with do |redis|
expect(redis.scan_each(match: key).to_a).to be_empty
end
end
it 'removes the devise session' do
Gitlab::Redis::SharedState.with do |redis|
redis.set("session:user:gitlab:#{user.id}:#{rack_session.private_id}", '')
......@@ -446,12 +507,16 @@
redis.set("session:user:gitlab:#{user.id}:6919a6f1bb119dd7396fadc38fd18d0d", '')
redis.sadd("session:lookup:user:gitlab:#{user.id}", '6919a6f1bb119dd7396fadc38fd18d0d')
redis.sadd("session:lookup:user:gitlab:#{user.id}", '59822c7d9fcdfa03725eff41782ad97d')
redis.sadd("session:user:scope:gitlab:{#{user.id}}:6919a6f1bb119dd7396fadc38fd18d0d", %w[1 2 3])
redis.sadd("session:user:scope:gitlab:{#{user.id}}:59822c7d9fcdfa03725eff41782ad97d", %w[4 5 6])
end
ActiveSession.cleanup(user)
Gitlab::Redis::SharedState.with do |redis|
expect(redis.smembers("session:lookup:user:gitlab:#{user.id}")).to eq ['6919a6f1bb119dd7396fadc38fd18d0d']
expect(redis.smembers("session:user:scope:gitlab:{#{user.id}}:6919a6f1bb119dd7396fadc38fd18d0d")).to eq %w[1 2 3]
expect(redis.smembers("session:user:scope:gitlab:{#{user.id}}:59822c7d9fcdfa03725eff41782ad97d")).to be_empty
end
end
......
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