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
#
# 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 (submitter_id)
# 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]))
#
# Foreign Keys
#

@ -20,7 +20,7 @@
#
# index_email_events_on_account_id_and_event_datetime (account_id,event_datetime)
# 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_message_id (message_id)
#

@ -34,8 +34,8 @@
# Indexes
#
# index_users_on_account_id (account_id)
# index_users_on_email (email) UNIQUE
# index_users_on_external_user_id (external_user_id) UNIQUE
# index_users_on_account_id_and_email (account_id,email) 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_unlock_token (unlock_token) UNIQUE
# index_users_on_uuid (uuid) UNIQUE
@ -67,7 +67,8 @@ class User < ApplicationRecord
has_many :encrypted_configs, dependent: :destroy, class_name: 'EncryptedUserConfig'
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 :uuid, :string, default: -> { SecureRandom.uuid }
@ -76,8 +77,11 @@ class User < ApplicationRecord
scope :archived, -> { where.not(archived_at: nil) }
scope :admins, -> { where(role: ADMIN_ROLE) }
validates :email, format: { with: /\A[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\z/ }
validates :external_user_id, uniqueness: true, allow_nil: true
# Custom email validation scoped to account (instead of Devise's global uniqueness)
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 = {})
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)
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 exists and we have an account context, assign them to the account if they don't have one
user.update!(account: account) if account.present? && user.account_id.blank?
# Update user attributes if they've changed
user.update!(user_attributes) if user_attributes_changed?(user)
return user
end
@ -93,6 +102,12 @@ class ExternalAuthService
User.create!(create_attributes)
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
{
email: @params[:user][:email],

@ -23,10 +23,34 @@ class TokenRefreshService
external_user_id = @params.dig(:user, :external_id)&.to_i
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
# 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
Rails.logger.warn "Token refresh requested for non-existent user: external_id #{external_user_id}" unless user
unless user
Rails.logger.warn(
'Token refresh requested for non-existent user: ' \
"external_id #{external_user_id}, account_id #{account&.id}"
)
end
user
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

@ -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.
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
enable_extension "btree_gin"
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.datetime "created_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"
end
@ -181,7 +181,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_30_175543) do
t.datetime "created_at", null: false
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_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 ["message_id"], name: "index_email_events_on_message_id"
end
@ -290,10 +290,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_30_175543) do
t.tsvector "tsvector", null: false
t.datetime "created_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 ["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"], name: "index_search_entries_on_account_id"
t.index ["account_id", "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_submitter", where: "((record_type)::text = 'Submitter'::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 ["record_id", "record_type"], name: "index_search_entries_on_record_id_and_record_type", unique: true
end
@ -459,9 +458,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_30_175543) do
t.integer "consumed_timestep"
t.boolean "otp_required_for_login", default: false, null: false
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 ["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 ["unlock_token"], name: "index_users_on_unlock_token", unique: true
t.index ["uuid"], name: "index_users_on_uuid", unique: true

@ -34,8 +34,8 @@
# Indexes
#
# index_users_on_account_id (account_id)
# index_users_on_email (email) UNIQUE
# index_users_on_external_user_id (external_user_id) UNIQUE
# index_users_on_account_id_and_email (account_id,email) 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_unlock_token (unlock_token) UNIQUE
# index_users_on_uuid (uuid) UNIQUE
@ -58,12 +58,35 @@ RSpec.describe User do
expect(user).not_to be_valid
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)
create(:user, account: account, external_user_id: 123)
duplicate = build(:user, account: account, external_user_id: 123)
expect(duplicate).not_to be_valid
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
describe '.find_or_create_by_external_id' do

@ -39,6 +39,21 @@ RSpec.describe ExternalAuthService do
expect(token).to eq(user.access_token.token)
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
context 'with partnership params' do
@ -90,14 +105,17 @@ RSpec.describe ExternalAuthService do
expect(User.last.account_id).to eq(account.id)
end
it 'finds existing partnership user with account context' do
create(:account, external_account_id: 456)
user = create(:user, account: nil, external_user_id: 123)
it 'creates new user when partnership user exists but account context differs' do
account = create(:account, external_account_id: 456)
partnership_user = create(:user, account: nil, external_user_id: 123)
token = described_class.new(params).authenticate_user
expect(token).to eq(user.access_token.token)
expect(User.count).to eq(1)
# Should create a new user for the account context (account scoping)
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
it 'handles external_account_id for account-level operations' do

@ -16,7 +16,7 @@ RSpec.describe TokenRefreshService do
end
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
original_token = user.access_token.token
@ -47,6 +47,32 @@ RSpec.describe TokenRefreshService do
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
it 'returns nil' do
result = described_class.new(user_params).refresh_token

@ -54,6 +54,8 @@ RSpec.describe 'Profile Settings' do
end
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 'Confirm new password', with: 'newpassword1'

@ -72,8 +72,8 @@ RSpec.describe 'Team Settings' do
expect(page).to have_content('Email already exists')
end
it "doesn't create a new user if a user belongs to another account" do
user = create(:user, account: second_account)
it 'allows creating a user with an email that exists in another account' do
user = create(:user, account: second_account, email: 'same@example.com')
visit settings_users_path
click_link 'New User'
@ -86,10 +86,13 @@ RSpec.describe 'Team Settings' do
expect do
click_button 'Submit'
end.not_to change(User, :count)
expect(page).to have_content('Email has already been taken')
end.to change(User, :count).by(1)
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
it 'does not allow to create a new user with an invalid email' do

Loading…
Cancel
Save