Compare commits

...
Author SHA1 Message Date
Tanmay Deep Sharma bc8ad35f22 fix(security): keep fallback templates running when captain feature is revoked 2026-07-23 13:29:21 +05:30
Tanmay Deep Sharma c86f7539a2 fix(security): enable sla feature in enterprise conversations spec 2026-07-23 13:24:20 +05:30
Tanmay Deep Sharma 0d4fd30eaa Merge remote-tracking branch 'origin/develop' into fix/premium-feature-and-agent-guards
# Conflicts:
#	app/controllers/api/v1/accounts/agents_controller.rb
2026-07-23 13:09:39 +05:30
Tanmay Deep SharmaandGitHub 6dc66cac0a Merge branch 'develop' into fix/premium-feature-and-agent-guards 2026-07-22 18:00:41 +05:30
Tanmay Deep Sharma 5bf6c0ddc3 fix(security): enforce premium flags on SLA automation, Captain runtime, and v2-only scenarios 2026-07-22 17:59:05 +05:30
Tanmay Deep Sharma 3b6e07c737 fix(security): gate custom_role and SLA assignment on their premium feature flags 2026-07-22 17:45:59 +05:30
Tanmay Deep Sharma 33913d9585 fix(security): gate copilot on Captain feature and guard enterprise agent seat race 2026-07-22 17:36:08 +05:30
Tanmay Deep Sharma 746abd8b28 Merge remote-tracking branch 'origin/develop' into fix/premium-feature-and-agent-guards
# Conflicts:
#	enterprise/app/controllers/api/v1/accounts/captain/assistant_responses_controller.rb
#	enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
#	enterprise/app/controllers/api/v1/accounts/captain/bulk_actions_controller.rb
#	enterprise/app/controllers/api/v1/accounts/captain/documents_controller.rb
#	enterprise/app/controllers/api/v1/accounts/captain/inboxes_controller.rb
#	enterprise/app/controllers/api/v1/accounts/captain/scenarios_controller.rb
2026-07-22 17:25:29 +05:30
Tanmay Deep Sharma 3b7a2c2b0e fix(security): restrict participating scope, agent seat race, and premium feature access 2026-07-22 17:21:33 +05:30
30 changed files with 108 additions and 11 deletions
+2 -1
View File
@@ -142,7 +142,8 @@ class ConversationFinder
conversation_ids = current_account.mentions.where(user: current_user).pluck(:conversation_id) conversation_ids = current_account.mentions.where(user: current_user).pluck(:conversation_id)
@conversations = @conversations.where(id: conversation_ids) @conversations = @conversations.where(id: conversation_ids)
when 'participating' when 'participating'
@conversations = current_user.participating_conversations.where(account_id: current_account.id) participating_ids = current_user.participating_conversations.where(account_id: current_account.id).select(:id)
@conversations = @conversations.where(id: participating_ids)
when 'unattended' when 'unattended'
@conversations = @conversations.unattended @conversations = @conversations.unattended
end end
@@ -1,4 +1,4 @@
class Api::V1::Accounts::Captain::AssistantResponsesController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Captain::AssistantResponsesController < Api::V1::Accounts::Captain::BaseController
before_action -> { check_authorization(Captain::Assistant) } before_action -> { check_authorization(Captain::Assistant) }
before_action :set_current_page, only: [:index] before_action :set_current_page, only: [:index]
@@ -1,4 +1,4 @@
class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Captain::BaseController
before_action -> { check_authorization(Captain::Assistant) } before_action -> { check_authorization(Captain::Assistant) }
before_action :set_assistant, only: [:show, :update, :destroy, :playground, :metrics, :faq_stats, :summary, :drilldown] before_action :set_assistant, only: [:show, :update, :destroy, :playground, :metrics, :faq_stats, :summary, :drilldown]
@@ -0,0 +1,13 @@
class Api::V1::Accounts::Captain::BaseController < Api::V1::Accounts::BaseController
before_action :ensure_captain_enabled
private
# Captain is a premium feature; block API access when neither the v1 nor v2 flag is enabled for the account.
# `current_account` resolves and memoizes the account, so this does not depend on before_action ordering.
def ensure_captain_enabled
return if current_account.feature_enabled?('captain_integration') || current_account.feature_enabled?('captain_integration_v2')
render json: { error: 'Captain is not enabled for this account' }, status: :forbidden
end
end
@@ -1,4 +1,4 @@
class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Captain::BaseController
before_action -> { check_authorization(Captain::Assistant) } before_action -> { check_authorization(Captain::Assistant) }
before_action :validate_params before_action :validate_params
before_action :type_matches? before_action :type_matches?
@@ -1,4 +1,4 @@
class Api::V1::Accounts::Captain::CopilotMessagesController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Captain::CopilotMessagesController < Api::V1::Accounts::Captain::BaseController
before_action :set_copilot_thread before_action :set_copilot_thread
def index def index
@@ -1,4 +1,4 @@
class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts::Captain::BaseController
before_action :ensure_message, only: :create before_action :ensure_message, only: :create
def index def index
@@ -1,4 +1,4 @@
class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::Captain::BaseController
before_action -> { check_authorization(Captain::Assistant) } before_action -> { check_authorization(Captain::Assistant) }
before_action :set_current_page, only: [:index] before_action :set_current_page, only: [:index]
@@ -1,4 +1,4 @@
class Api::V1::Accounts::Captain::InboxesController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Captain::InboxesController < Api::V1::Accounts::Captain::BaseController
before_action -> { check_authorization(Captain::Assistant) } before_action -> { check_authorization(Captain::Assistant) }
before_action :set_assistant before_action :set_assistant
@@ -1,4 +1,5 @@
class Api::V1::Accounts::Captain::ScenariosController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Captain::ScenariosController < Api::V1::Accounts::Captain::BaseController
before_action :ensure_captain_v2_enabled
before_action -> { check_authorization(Captain::Scenario) } before_action -> { check_authorization(Captain::Scenario) }
before_action :set_assistant before_action :set_assistant
before_action :set_scenario, only: [:show, :update, :destroy] before_action :set_scenario, only: [:show, :update, :destroy]
@@ -24,6 +25,13 @@ class Api::V1::Accounts::Captain::ScenariosController < Api::V1::Accounts::BaseC
private private
# Scenarios are a Captain v2-only feature; the shared base guard allows either flag, so require v2 here.
def ensure_captain_v2_enabled
return if current_account.feature_enabled?('captain_integration_v2')
render json: { error: 'Captain is not enabled for this account' }, status: :forbidden
end
def set_assistant def set_assistant
@assistant = account_assistants.find(params[:assistant_id]) @assistant = account_assistants.find(params[:assistant_id])
end end
@@ -1,4 +1,5 @@
class Api::V1::Accounts::CustomRolesController < Api::V1::Accounts::EnterpriseAccountsController class Api::V1::Accounts::CustomRolesController < Api::V1::Accounts::EnterpriseAccountsController
before_action :ensure_custom_roles_feature_enabled
before_action :fetch_custom_role, only: [:show, :update, :destroy] before_action :fetch_custom_role, only: [:show, :update, :destroy]
before_action :check_authorization before_action :check_authorization
@@ -28,4 +29,8 @@ class Api::V1::Accounts::CustomRolesController < Api::V1::Accounts::EnterpriseAc
def fetch_custom_role def fetch_custom_role
@custom_role = Current.account.custom_roles.find_by(id: params[:id]) @custom_role = Current.account.custom_roles.find_by(id: params[:id])
end end
def ensure_custom_roles_feature_enabled
raise Pundit::NotAuthorizedError unless Current.account.feature_enabled?('custom_roles')
end
end end
@@ -1,4 +1,5 @@
class Api::V1::Accounts::SlaPoliciesController < Api::V1::Accounts::EnterpriseAccountsController class Api::V1::Accounts::SlaPoliciesController < Api::V1::Accounts::EnterpriseAccountsController
before_action :ensure_sla_feature_enabled
before_action :fetch_sla, only: [:show, :update, :destroy] before_action :fetch_sla, only: [:show, :update, :destroy]
before_action :check_authorization before_action :check_authorization
@@ -29,4 +30,8 @@ class Api::V1::Accounts::SlaPoliciesController < Api::V1::Accounts::EnterpriseAc
def fetch_sla def fetch_sla
@sla_policy = Current.account.sla_policies.find_by(id: params[:id]) @sla_policy = Current.account.sla_policies.find_by(id: params[:id])
end end
def ensure_sla_feature_enabled
raise Pundit::NotAuthorizedError unless Current.account.feature_enabled?('sla')
end
end end
@@ -14,6 +14,9 @@ module Enterprise::Api::V1::Accounts::AgentsController
private private
def associate_agent_with_custom_role def associate_agent_with_custom_role
# Custom roles are a premium feature; ignore custom_role_id assignment when the feature is disabled.
return unless Current.account.feature_enabled?('custom_roles')
@agent.current_account_user.update!(custom_role_id: params[:custom_role_id]) @agent.current_account_user.update!(custom_role_id: params[:custom_role_id])
end end
end end
@@ -16,6 +16,9 @@ module Enterprise::Api::V1::Accounts::ConversationsController
end end
def permitted_update_params def permitted_update_params
# SLA is a premium feature; only accept sla_policy_id assignment when it is enabled for the account.
return super unless Current.account.feature_enabled?('sla')
super.merge(params.permit(:sla_policy_id)) super.merge(params.permit(:sla_policy_id))
end end
@@ -1,6 +1,8 @@
module Enterprise::ActionService module Enterprise::ActionService
def add_sla(sla_policy_id) def add_sla(sla_policy_id)
return if sla_policy_id.blank? return if sla_policy_id.blank?
# SLA is a premium feature; automation rules must not keep applying SLAs once it is disabled.
return unless @account.feature_enabled?('sla')
sla_policy = @account.sla_policies.find_by(id: sla_policy_id.first) sla_policy = @account.sla_policies.find_by(id: sla_policy_id.first)
return if sla_policy.nil? return if sla_policy.nil?
@@ -50,7 +50,14 @@ module Enterprise::MessageTemplates::HookExecutionService
end end
def should_process_captain_response? def should_process_captain_response?
conversation.pending? && message.incoming? && inbox.captain_assistant.present? conversation.pending? && message.incoming? && inbox.captain_assistant.present? && captain_feature_enabled?
end
# Captain is a premium feature; stop the runtime responder when it has been revoked, even if an
# assistant is still linked to the inbox.
def captain_feature_enabled?
account = conversation.account
account.feature_enabled?('captain_integration') || account.feature_enabled?('captain_integration_v2')
end end
def perform_handoff def perform_handoff
@@ -75,7 +82,9 @@ module Enterprise::MessageTemplates::HookExecutionService
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation) ::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
end end
# Only let Captain suppress the fallback templates when it can actually reply; otherwise a revoked
# feature would leave the contact with neither a Captain response nor a greeting/OOO/email-collect.
def captain_handling_conversation? def captain_handling_conversation?
conversation.pending? && inbox.respond_to?(:captain_assistant) && inbox.captain_assistant.present? conversation.pending? && inbox.respond_to?(:captain_assistant) && inbox.captain_assistant.present? && captain_feature_enabled?
end end
end end
@@ -9,6 +9,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::AssistantResponses', type: :request
let(:another_assistant) { create(:captain_assistant, account: account) } let(:another_assistant) { create(:captain_assistant, account: account) }
let(:another_document) { create(:captain_document, account: account, assistant: assistant) } let(:another_document) { create(:captain_document, account: account, assistant: assistant) }
before { account.enable_features!('captain_integration') }
def json_response def json_response
JSON.parse(response.body, symbolize_names: true) JSON.parse(response.body, symbolize_names: true)
end end
@@ -5,6 +5,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
let(:admin) { create(:user, account: account, role: :administrator) } let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) } let(:agent) { create(:user, account: account, role: :agent) }
before { account.enable_features!('captain_integration') }
def json_response def json_response
JSON.parse(response.body, symbolize_names: true) JSON.parse(response.body, symbolize_names: true)
end end
@@ -24,6 +24,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
) )
end end
before { account.enable_features!('captain_integration') }
def json_response def json_response
JSON.parse(response.body, symbolize_names: true) JSON.parse(response.body, symbolize_names: true)
end end
@@ -6,6 +6,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotMessagesController', type: :r
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user) } let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user) }
let!(:copilot_message) { create(:captain_copilot_message, copilot_thread: copilot_thread, account: account) } let!(:copilot_message) { create(:captain_copilot_message, copilot_thread: copilot_thread, account: account) }
before { account.enable_features!('captain_integration') }
describe 'GET /api/v1/accounts/{account.id}/captain/copilot_threads/{thread.id}/copilot_messages' do describe 'GET /api/v1/accounts/{account.id}/captain/copilot_threads/{thread.id}/copilot_messages' do
context 'when it is an authenticated user' do context 'when it is an authenticated user' do
it 'returns all messages' do it 'returns all messages' do
@@ -6,6 +6,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do
let(:agent) { create(:user, account: account, role: :agent) } let(:agent) { create(:user, account: account, role: :agent) }
let(:conversation) { create(:conversation, account: account) } let(:conversation) { create(:conversation, account: account) }
before { account.enable_features!('captain_integration') }
def json_response def json_response
JSON.parse(response.body, symbolize_names: true) JSON.parse(response.body, symbolize_names: true)
end end
@@ -13,6 +13,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
}.with_indifferent_access }.with_indifferent_access
end end
before { account.enable_features!('captain_integration') }
def json_response def json_response
JSON.parse(response.body, symbolize_names: true) JSON.parse(response.body, symbolize_names: true)
end end
@@ -9,6 +9,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Inboxes', type: :request do
let(:admin) { create(:user, account: account, role: :administrator) } let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) } let(:agent) { create(:user, account: account, role: :agent) }
before { account.enable_features!('captain_integration') }
def json_response def json_response
JSON.parse(response.body, symbolize_names: true) JSON.parse(response.body, symbolize_names: true)
end end
@@ -6,6 +6,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Scenarios', type: :request do
let(:agent) { create(:user, account: account, role: :agent) } let(:agent) { create(:user, account: account, role: :agent) }
let(:assistant) { create(:captain_assistant, account: account) } let(:assistant) { create(:captain_assistant, account: account) }
before { account.enable_features!('captain_integration_v2') }
def json_response def json_response
JSON.parse(response.body, symbolize_names: true) JSON.parse(response.body, symbolize_names: true)
end end
@@ -6,6 +6,8 @@ RSpec.describe 'Custom Roles API', type: :request do
let!(:agent) { create(:user, account: account, role: :agent) } let!(:agent) { create(:user, account: account, role: :agent) }
let!(:custom_role) { create(:custom_role, account: account, name: 'Manager') } let!(:custom_role) { create(:custom_role, account: account, name: 'Manager') }
before { account.enable_features!('custom_roles') }
describe 'GET #index' do describe 'GET #index' do
context 'when it is an authenticated administrator' do context 'when it is an authenticated administrator' do
it 'returns all custom roles in the account' do it 'returns all custom roles in the account' do
@@ -6,6 +6,7 @@ RSpec.describe 'Enterprise SLA API', type: :request do
let(:agent) { create(:user, account: account, role: :agent) } let(:agent) { create(:user, account: account, role: :agent) }
before do before do
account.enable_features!('sla')
create(:sla_policy, account: account, name: 'SLA 1') create(:sla_policy, account: account, name: 'SLA 1')
end end
@@ -5,6 +5,8 @@ RSpec.describe 'Enterprise Agents API', type: :request do
let(:admin) { create(:user, account: account, role: :administrator) } let(:admin) { create(:user, account: account, role: :administrator) }
let!(:custom_role) { create(:custom_role, account: account) } let!(:custom_role) { create(:custom_role, account: account) }
before { account.enable_features!('custom_roles') }
describe 'POST /api/v1/accounts/{account.id}/agents' do describe 'POST /api/v1/accounts/{account.id}/agents' do
let(:params) { { email: 'test@example.com', name: 'Test User', role: 'agent', custom_role_id: custom_role.id } } let(:params) { { email: 'test@example.com', name: 'Test User', role: 'agent', custom_role_id: custom_role.id } }
@@ -13,6 +13,7 @@ RSpec.describe 'Enterprise Conversations API', type: :request do
let(:agent) { create(:user, account: account, role: :agent) } let(:agent) { create(:user, account: account, role: :agent) }
before do before do
account.enable_features!('sla')
create(:inbox_member, user: agent, inbox: conversation.inbox) create(:inbox_member, user: agent, inbox: conversation.inbox)
end end
@@ -8,6 +8,8 @@ describe ActionService do
let(:conversation) { create(:conversation, account: account) } let(:conversation) { create(:conversation, account: account) }
let(:action_service) { described_class.new(conversation) } let(:action_service) { described_class.new(conversation) }
before { account.enable_features!('sla') }
context 'when sla_policy_id is present' do context 'when sla_policy_id is present' do
it 'adds the sla policy to the conversation and create applied_sla entry' do it 'adds the sla policy to the conversation and create applied_sla entry' do
action_service.add_sla([sla_policy.id]) action_service.add_sla([sla_policy.id])
@@ -8,6 +8,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
let(:assistant) { create(:captain_assistant, account: account) } let(:assistant) { create(:captain_assistant, account: account) }
before do before do
account.enable_features!('captain_integration')
create(:captain_inbox, captain_assistant: assistant, inbox: inbox) create(:captain_inbox, captain_assistant: assistant, inbox: inbox)
end end
@@ -202,6 +203,29 @@ RSpec.describe MessageTemplates::HookExecutionService do
end end
end end
context 'when the Captain feature is disabled but an assistant is still linked' do
before do
account.disable_features!('captain_integration', 'captain_integration_v2')
conversation.update!(status: :pending)
end
it 'does not schedule captain response job' do
expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later)
create(:message, conversation: conversation, message_type: :incoming, account: account)
end
it 'falls back to the greeting message instead of leaving the contact without a reply' do
inbox.update!(greeting_enabled: true, greeting_message: 'Hello! How can we help you?', enable_email_collect: false)
expect do
create(:message, conversation: conversation, message_type: :incoming, account: account)
end.to change { conversation.reload.messages.template.count }.by(1)
expect(conversation.reload.messages.template.last.content).to eq('Hello! How can we help you?')
end
end
context 'when Captain is not configured' do context 'when Captain is not configured' do
before do before do
CaptainInbox.where(inbox: inbox).destroy_all CaptainInbox.where(inbox: inbox).destroy_all