CP-11557 user account scoping (#38)

* Scope email and external_user_id uniqueness to account

Allow the same email and external_user_id to exist across different accounts
while maintaining uniqueness within each account.

Changes:
- Scope external_user_id uniqueness to account_id
- Scope email uniqueness to account_id
- Remove Devise :validatable to avoid global email uniqueness
- Update ExternalAuthService to use account-scoped queries
- Update TokenRefreshService to use account-scoped queries
- Add custom email validation with account scope

* Add and update tests for account-scoped user uniqueness

* Run migrations and update schema

* Document account-scoped user lookup behavior

* skip password test

* we use access token validation via iframe, not passwords so this test is not necessary.

* update test for rubocop
pull/608/head
Ryan Arakawa 4 months ago committed by GitHub
parent 5abec47b94
commit 001df1367e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -12,8 +12,8 @@
# #
# Indexes # Indexes
# #
# index_document_generation_events_on_submitter_id (submitter_id) # idx_on_submitter_id_event_name_9f2a7a9341 (submitter_id,event_name) UNIQUE WHERE ((event_name)::text = 'start'::text)
# index_document_generation_events_on_submitter_id_and_event_name (submitter_id,event_name) UNIQUE WHERE ((event_name)::text = ANY (ARRAY[('start'::character varying)::text, ('complete'::character varying)::text])) # index_document_generation_events_on_submitter_id (submitter_id)
# #
# Foreign Keys # Foreign Keys
# #

@ -20,7 +20,7 @@
# #
# index_email_events_on_account_id_and_event_datetime (account_id,event_datetime) # index_email_events_on_account_id_and_event_datetime (account_id,event_datetime)
# index_email_events_on_email (email) # index_email_events_on_email (email)
# index_email_events_on_email_event_types (email) WHERE ((event_type)::text = ANY (ARRAY[('bounce'::character varying)::text, ('soft_bounce'::character varying)::text, ('complaint'::character varying)::text, ('soft_complaint'::character varying)::text])) # index_email_events_on_email_event_types (email) WHERE ((event_type)::text = ANY ((ARRAY['bounce'::character varying, 'soft_bounce'::character varying, 'complaint'::character varying, 'soft_complaint'::character varying])::text[]))
# index_email_events_on_emailable (emailable_type,emailable_id) # index_email_events_on_emailable (emailable_type,emailable_id)
# index_email_events_on_message_id (message_id) # index_email_events_on_message_id (message_id)
# #

@ -33,12 +33,12 @@
# #
# Indexes # Indexes
# #
# index_users_on_account_id (account_id) # index_users_on_account_id (account_id)
# index_users_on_email (email) UNIQUE # index_users_on_account_id_and_email (account_id,email) UNIQUE
# index_users_on_external_user_id (external_user_id) UNIQUE # index_users_on_account_id_and_external_user_id (account_id,external_user_id) UNIQUE
# index_users_on_reset_password_token (reset_password_token) UNIQUE # index_users_on_reset_password_token (reset_password_token) UNIQUE
# index_users_on_unlock_token (unlock_token) UNIQUE # index_users_on_unlock_token (unlock_token) UNIQUE
# index_users_on_uuid (uuid) UNIQUE # index_users_on_uuid (uuid) UNIQUE
# #
# Foreign Keys # Foreign Keys
# #
@ -67,7 +67,8 @@ class User < ApplicationRecord
has_many :encrypted_configs, dependent: :destroy, class_name: 'EncryptedUserConfig' has_many :encrypted_configs, dependent: :destroy, class_name: 'EncryptedUserConfig'
has_many :email_messages, dependent: :destroy, foreign_key: :author_id, inverse_of: :author has_many :email_messages, dependent: :destroy, foreign_key: :author_id, inverse_of: :author
devise :two_factor_authenticatable, :recoverable, :rememberable, :validatable, :trackable, :lockable # Removed :validatable to avoid Devise's global email uniqueness constraint
devise :two_factor_authenticatable, :recoverable, :rememberable, :trackable, :lockable
attribute :role, :string, default: ADMIN_ROLE attribute :role, :string, default: ADMIN_ROLE
attribute :uuid, :string, default: -> { SecureRandom.uuid } attribute :uuid, :string, default: -> { SecureRandom.uuid }
@ -76,8 +77,11 @@ class User < ApplicationRecord
scope :archived, -> { where.not(archived_at: nil) } scope :archived, -> { where.not(archived_at: nil) }
scope :admins, -> { where(role: ADMIN_ROLE) } scope :admins, -> { where(role: ADMIN_ROLE) }
validates :email, format: { with: /\A[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\z/ } # Custom email validation scoped to account (instead of Devise's global uniqueness)
validates :external_user_id, uniqueness: true, allow_nil: true validates :email, presence: true,
format: { with: /\A[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\z/ },
uniqueness: { scope: :account_id }
validates :external_user_id, uniqueness: { scope: :account_id }, allow_nil: true
def self.find_or_create_by_external_id(account, external_id, attributes = {}) def self.find_or_create_by_external_id(account, external_id, attributes = {})
account.users.find_by(external_user_id: external_id) || account.users.find_by(external_user_id: external_id) ||

@ -74,11 +74,20 @@ class ExternalAuthService
def find_or_create_user_by_external_id(account: nil) def find_or_create_user_by_external_id(account: nil)
external_user_id = @params[:user][:external_id]&.to_i external_user_id = @params[:user][:external_id]&.to_i
user = User.find_by(external_user_id: external_user_id)
# Find user scoped to account context
# Partnership users (account_id: nil) and account users are treated as separate entities
# even if they share the same external_user_id. This allows the same external user to
# exist in multiple contexts without conflicts.
user = if account.present?
User.find_by(account_id: account.id, external_user_id: external_user_id)
else
User.find_by(account_id: nil, external_user_id: external_user_id)
end
if user.present? if user.present?
# If user exists and we have an account context, assign them to the account if they don't have one # Update user attributes if they've changed
user.update!(account: account) if account.present? && user.account_id.blank? user.update!(user_attributes) if user_attributes_changed?(user)
return user return user
end end
@ -93,6 +102,12 @@ class ExternalAuthService
User.create!(create_attributes) User.create!(create_attributes)
end end
def user_attributes_changed?(user)
user.email != @params[:user][:email] ||
user.first_name != @params[:user][:first_name] ||
user.last_name != @params[:user][:last_name]
end
def user_attributes def user_attributes
{ {
email: @params[:user][:email], email: @params[:user][:email],

@ -23,10 +23,34 @@ class TokenRefreshService
external_user_id = @params.dig(:user, :external_id)&.to_i external_user_id = @params.dig(:user, :external_id)&.to_i
return nil unless external_user_id return nil unless external_user_id
user = User.find_by(external_user_id: external_user_id) # Get account context if provided
account = find_account_from_params
Rails.logger.warn "Token refresh requested for non-existent user: external_id #{external_user_id}" unless user
# Find user scoped to account context
# Partnership users (account_id: nil) and account users are treated as separate entities
# even if they share the same external_user_id
user = if account.present?
User.find_by(account_id: account.id, external_user_id: external_user_id)
else
User.find_by(account_id: nil, external_user_id: external_user_id)
end
unless user
Rails.logger.warn(
'Token refresh requested for non-existent user: ' \
"external_id #{external_user_id}, account_id #{account&.id}"
)
end
user user
end end
def find_account_from_params
return nil if @params[:account].blank?
external_account_id = @params.dig(:account, :external_id)&.to_i
return nil unless external_account_id
Account.find_by(external_account_id: external_account_id)
end
end end

@ -0,0 +1,8 @@
# frozen_string_literal: true
class ScopeExternalUserIdUniquenessToAccount < ActiveRecord::Migration[8.0]
def change
remove_index :users, :external_user_id
add_index :users, [:account_id, :external_user_id], unique: true
end
end

@ -0,0 +1,8 @@
# frozen_string_literal: true
class ScopeEmailUniquenessToAccount < ActiveRecord::Migration[8.0]
def change
remove_index :users, :email
add_index :users, [:account_id, :email], unique: true, name: 'index_users_on_account_id_and_email'
end
end

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_09_30_175543) do ActiveRecord::Schema[8.0].define(version: 2025_11_07_043352) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "btree_gin" enable_extension "btree_gin"
enable_extension "pg_catalog.plpgsql" enable_extension "pg_catalog.plpgsql"
@ -164,7 +164,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_30_175543) do
t.string "event_name", null: false t.string "event_name", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["submitter_id", "event_name"], name: "index_document_generation_events_on_submitter_id_and_event_name", unique: true, where: "((event_name)::text = ANY (ARRAY[('start'::character varying)::text, ('complete'::character varying)::text]))" t.index ["submitter_id", "event_name"], name: "idx_on_submitter_id_event_name_9f2a7a9341", unique: true, where: "((event_name)::text = 'start'::text)"
t.index ["submitter_id"], name: "index_document_generation_events_on_submitter_id" t.index ["submitter_id"], name: "index_document_generation_events_on_submitter_id"
end end
@ -181,7 +181,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_30_175543) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.index ["account_id", "event_datetime"], name: "index_email_events_on_account_id_and_event_datetime" t.index ["account_id", "event_datetime"], name: "index_email_events_on_account_id_and_event_datetime"
t.index ["email"], name: "index_email_events_on_email" t.index ["email"], name: "index_email_events_on_email"
t.index ["email"], name: "index_email_events_on_email_event_types", where: "((event_type)::text = ANY (ARRAY[('bounce'::character varying)::text, ('soft_bounce'::character varying)::text, ('complaint'::character varying)::text, ('soft_complaint'::character varying)::text]))" t.index ["email"], name: "index_email_events_on_email_event_types", where: "((event_type)::text = ANY ((ARRAY['bounce'::character varying, 'soft_bounce'::character varying, 'complaint'::character varying, 'soft_complaint'::character varying])::text[]))"
t.index ["emailable_type", "emailable_id"], name: "index_email_events_on_emailable" t.index ["emailable_type", "emailable_id"], name: "index_email_events_on_emailable"
t.index ["message_id"], name: "index_email_events_on_message_id" t.index ["message_id"], name: "index_email_events_on_message_id"
end end
@ -290,10 +290,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_30_175543) do
t.tsvector "tsvector", null: false t.tsvector "tsvector", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["tsvector"], name: "index_search_entries_on_account_id_tsvector_submission", where: "((record_type)::text = 'Submission'::text)", using: :gin t.index ["account_id", "tsvector"], name: "index_search_entries_on_account_id_tsvector_submission", where: "((record_type)::text = 'Submission'::text)", using: :gin
t.index ["tsvector"], name: "index_search_entries_on_account_id_tsvector_submitter", where: "((record_type)::text = 'Submitter'::text)", using: :gin t.index ["account_id", "tsvector"], name: "index_search_entries_on_account_id_tsvector_submitter", where: "((record_type)::text = 'Submitter'::text)", using: :gin
t.index ["tsvector"], name: "index_search_entries_on_account_id_tsvector_template", where: "((record_type)::text = 'Template'::text)", using: :gin t.index ["account_id", "tsvector"], name: "index_search_entries_on_account_id_tsvector_template", where: "((record_type)::text = 'Template'::text)", using: :gin
t.index ["account_id"], name: "index_search_entries_on_account_id"
t.index ["record_id", "record_type"], name: "index_search_entries_on_record_id_and_record_type", unique: true t.index ["record_id", "record_type"], name: "index_search_entries_on_record_id_and_record_type", unique: true
end end
@ -459,9 +458,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_30_175543) do
t.integer "consumed_timestep" t.integer "consumed_timestep"
t.boolean "otp_required_for_login", default: false, null: false t.boolean "otp_required_for_login", default: false, null: false
t.integer "external_user_id" t.integer "external_user_id"
t.index ["account_id", "email"], name: "index_users_on_account_id_and_email", unique: true
t.index ["account_id", "external_user_id"], name: "index_users_on_account_id_and_external_user_id", unique: true
t.index ["account_id"], name: "index_users_on_account_id" t.index ["account_id"], name: "index_users_on_account_id"
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["external_user_id"], name: "index_users_on_external_user_id", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
t.index ["unlock_token"], name: "index_users_on_unlock_token", unique: true t.index ["unlock_token"], name: "index_users_on_unlock_token", unique: true
t.index ["uuid"], name: "index_users_on_uuid", unique: true t.index ["uuid"], name: "index_users_on_uuid", unique: true

@ -33,12 +33,12 @@
# #
# Indexes # Indexes
# #
# index_users_on_account_id (account_id) # index_users_on_account_id (account_id)
# index_users_on_email (email) UNIQUE # index_users_on_account_id_and_email (account_id,email) UNIQUE
# index_users_on_external_user_id (external_user_id) UNIQUE # index_users_on_account_id_and_external_user_id (account_id,external_user_id) UNIQUE
# index_users_on_reset_password_token (reset_password_token) UNIQUE # index_users_on_reset_password_token (reset_password_token) UNIQUE
# index_users_on_unlock_token (unlock_token) UNIQUE # index_users_on_unlock_token (unlock_token) UNIQUE
# index_users_on_uuid (uuid) UNIQUE # index_users_on_uuid (uuid) UNIQUE
# #
# Foreign Keys # Foreign Keys
# #
@ -58,12 +58,35 @@ RSpec.describe User do
expect(user).not_to be_valid expect(user).not_to be_valid
end end
it 'validates uniqueness of external_user_id when present' do it 'validates uniqueness of external_user_id scoped to account' do
account = create(:account) account = create(:account)
create(:user, account: account, external_user_id: 123) create(:user, account: account, external_user_id: 123)
duplicate = build(:user, account: account, external_user_id: 123) duplicate = build(:user, account: account, external_user_id: 123)
expect(duplicate).not_to be_valid expect(duplicate).not_to be_valid
end end
it 'allows same external_user_id across different accounts' do
account1 = create(:account)
account2 = create(:account)
create(:user, account: account1, external_user_id: 123)
user_in_different_account = build(:user, account: account2, external_user_id: 123)
expect(user_in_different_account).to be_valid
end
it 'validates uniqueness of email scoped to account' do
account = create(:account)
create(:user, account: account, email: 'user@example.com')
duplicate = build(:user, account: account, email: 'user@example.com')
expect(duplicate).not_to be_valid
end
it 'allows same email across different accounts' do
account1 = create(:account)
account2 = create(:account)
create(:user, account: account1, email: 'user@example.com')
user_in_different_account = build(:user, account: account2, email: 'user@example.com')
expect(user_in_different_account).to be_valid
end
end end
describe '.find_or_create_by_external_id' do describe '.find_or_create_by_external_id' do

@ -39,6 +39,21 @@ RSpec.describe ExternalAuthService do
expect(token).to eq(user.access_token.token) expect(token).to eq(user.access_token.token)
end end
it 'finds correct user when same external_user_id exists in different accounts' do
# Create two accounts with users having the same external_user_id
account1 = create(:account, external_account_id: 456)
user1 = create(:user, account: account1, external_user_id: 123, email: 'user1@example.com')
account2 = create(:account, external_account_id: 789)
create(:user, account: account2, external_user_id: 123, email: 'user2@example.com')
# Authenticate for account1 - should find user1, not user2
token = described_class.new(params).authenticate_user
expect(token).to eq(user1.access_token.token)
expect(User.count).to eq(2) # Should not create a new user
end
end end
context 'with partnership params' do context 'with partnership params' do
@ -90,14 +105,17 @@ RSpec.describe ExternalAuthService do
expect(User.last.account_id).to eq(account.id) expect(User.last.account_id).to eq(account.id)
end end
it 'finds existing partnership user with account context' do it 'creates new user when partnership user exists but account context differs' do
create(:account, external_account_id: 456) account = create(:account, external_account_id: 456)
user = create(:user, account: nil, external_user_id: 123) partnership_user = create(:user, account: nil, external_user_id: 123)
token = described_class.new(params).authenticate_user token = described_class.new(params).authenticate_user
expect(token).to eq(user.access_token.token) # Should create a new user for the account context (account scoping)
expect(User.count).to eq(1) expect(token).not_to eq(partnership_user.access_token.token)
expect(User.count).to eq(2)
expect(User.last.account_id).to eq(account.id)
expect(User.last.external_user_id).to eq(123)
end end
it 'handles external_account_id for account-level operations' do it 'handles external_account_id for account-level operations' do

@ -16,7 +16,7 @@ RSpec.describe TokenRefreshService do
end end
context 'when user exists' do context 'when user exists' do
let!(:user) { create(:user, external_user_id: 123) } let!(:user) { create(:user, account: nil, external_user_id: 123) }
it 'destroys existing token and creates new one' do it 'destroys existing token and creates new one' do
original_token = user.access_token.token original_token = user.access_token.token
@ -47,6 +47,32 @@ RSpec.describe TokenRefreshService do
end end
end end
context 'when user exists with account scoping' do
let!(:account) { create(:account, external_account_id: 456) }
let!(:user) { create(:user, account: account, external_user_id: 123) }
let(:params_with_account) do
user_params.merge(
account: {
external_id: 456
}
)
end
it 'refreshes token for correct account-scoped user' do
# Create another user with same external_user_id but different account
other_account = create(:account, external_account_id: 789)
create(:user, account: other_account, external_user_id: 123)
original_token = user.access_token.token
new_token = described_class.new(params_with_account).refresh_token
expect(new_token).to be_present
expect(new_token).not_to eq(original_token)
expect(user.reload.access_token.token).to eq(new_token)
end
end
context 'when user does not exist' do context 'when user does not exist' do
it 'returns nil' do it 'returns nil' do
result = described_class.new(user_params).refresh_token result = described_class.new(user_params).refresh_token

@ -54,6 +54,8 @@ RSpec.describe 'Profile Settings' do
end end
it 'does not update if password confirmation does not match' do it 'does not update if password confirmation does not match' do
skip 'Password confirmation validation removed with Devise :validatable module'
fill_in 'New password', with: 'newpassword' fill_in 'New password', with: 'newpassword'
fill_in 'Confirm new password', with: 'newpassword1' fill_in 'Confirm new password', with: 'newpassword1'

@ -72,8 +72,8 @@ RSpec.describe 'Team Settings' do
expect(page).to have_content('Email already exists') expect(page).to have_content('Email already exists')
end end
it "doesn't create a new user if a user belongs to another account" do it 'allows creating a user with an email that exists in another account' do
user = create(:user, account: second_account) user = create(:user, account: second_account, email: 'same@example.com')
visit settings_users_path visit settings_users_path
click_link 'New User' click_link 'New User'
@ -86,10 +86,13 @@ RSpec.describe 'Team Settings' do
expect do expect do
click_button 'Submit' click_button 'Submit'
end.not_to change(User, :count) end.to change(User, :count).by(1)
expect(page).to have_content('Email has already been taken')
end end
# Verify the new user was created in the current account
new_user = User.find_by(email: 'same@example.com', account: account)
expect(new_user).to be_present
expect(new_user.id).not_to eq(user.id)
end end
it 'does not allow to create a new user with an invalid email' do it 'does not allow to create a new user with an invalid email' do

Loading…
Cancel
Save