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)
@conversations = @conversations.where(id: conversation_ids)
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'
@conversations = @conversations.unattended
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 :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 :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 :validate_params
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
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
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 :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 :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 :set_assistant
before_action :set_scenario, only: [:show, :update, :destroy]
@@ -24,6 +25,13 @@ class Api::V1::Accounts::Captain::ScenariosController < Api::V1::Accounts::BaseC
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
@assistant = account_assistants.find(params[:assistant_id])
end
@@ -1,4 +1,5 @@
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 :check_authorization
@@ -28,4 +29,8 @@ class Api::V1::Accounts::CustomRolesController < Api::V1::Accounts::EnterpriseAc
def fetch_custom_role
@custom_role = Current.account.custom_roles.find_by(id: params[:id])
end
def ensure_custom_roles_feature_enabled
raise Pundit::NotAuthorizedError unless Current.account.feature_enabled?('custom_roles')
end
end
@@ -1,4 +1,5 @@
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 :check_authorization
@@ -29,4 +30,8 @@ class Api::V1::Accounts::SlaPoliciesController < Api::V1::Accounts::EnterpriseAc
def fetch_sla
@sla_policy = Current.account.sla_policies.find_by(id: params[:id])
end
def ensure_sla_feature_enabled
raise Pundit::NotAuthorizedError unless Current.account.feature_enabled?('sla')
end
end
@@ -14,6 +14,9 @@ module Enterprise::Api::V1::Accounts::AgentsController
private
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])
end
end
@@ -16,6 +16,9 @@ module Enterprise::Api::V1::Accounts::ConversationsController
end
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))
end
@@ -1,6 +1,8 @@
module Enterprise::ActionService
def add_sla(sla_policy_id)
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)
return if sla_policy.nil?
@@ -50,7 +50,14 @@ module Enterprise::MessageTemplates::HookExecutionService
end
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
def perform_handoff
@@ -75,7 +82,9 @@ module Enterprise::MessageTemplates::HookExecutionService
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
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?
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
@@ -9,6 +9,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::AssistantResponses', type: :request
let(:another_assistant) { create(:captain_assistant, account: account) }
let(:another_document) { create(:captain_document, account: account, assistant: assistant) }
before { account.enable_features!('captain_integration') }
def json_response
JSON.parse(response.body, symbolize_names: true)
end
@@ -5,6 +5,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
before { account.enable_features!('captain_integration') }
def json_response
JSON.parse(response.body, symbolize_names: true)
end
@@ -24,6 +24,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
)
end
before { account.enable_features!('captain_integration') }
def json_response
JSON.parse(response.body, symbolize_names: true)
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_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
context 'when it is an authenticated user' 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(:conversation) { create(:conversation, account: account) }
before { account.enable_features!('captain_integration') }
def json_response
JSON.parse(response.body, symbolize_names: true)
end
@@ -13,6 +13,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
}.with_indifferent_access
end
before { account.enable_features!('captain_integration') }
def json_response
JSON.parse(response.body, symbolize_names: true)
end
@@ -9,6 +9,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Inboxes', type: :request do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
before { account.enable_features!('captain_integration') }
def json_response
JSON.parse(response.body, symbolize_names: true)
end
@@ -6,6 +6,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Scenarios', type: :request do
let(:agent) { create(:user, account: account, role: :agent) }
let(:assistant) { create(:captain_assistant, account: account) }
before { account.enable_features!('captain_integration_v2') }
def json_response
JSON.parse(response.body, symbolize_names: true)
end
@@ -6,6 +6,8 @@ RSpec.describe 'Custom Roles API', type: :request do
let!(:agent) { create(:user, account: account, role: :agent) }
let!(:custom_role) { create(:custom_role, account: account, name: 'Manager') }
before { account.enable_features!('custom_roles') }
describe 'GET #index' do
context 'when it is an authenticated administrator' 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) }
before do
account.enable_features!('sla')
create(:sla_policy, account: account, name: 'SLA 1')
end
@@ -5,6 +5,8 @@ RSpec.describe 'Enterprise Agents API', type: :request do
let(:admin) { create(:user, account: account, role: :administrator) }
let!(:custom_role) { create(:custom_role, account: account) }
before { account.enable_features!('custom_roles') }
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 } }
@@ -13,6 +13,7 @@ RSpec.describe 'Enterprise Conversations API', type: :request do
let(:agent) { create(:user, account: account, role: :agent) }
before do
account.enable_features!('sla')
create(:inbox_member, user: agent, inbox: conversation.inbox)
end
@@ -8,6 +8,8 @@ describe ActionService do
let(:conversation) { create(:conversation, account: account) }
let(:action_service) { described_class.new(conversation) }
before { account.enable_features!('sla') }
context 'when sla_policy_id is present' do
it 'adds the sla policy to the conversation and create applied_sla entry' do
action_service.add_sla([sla_policy.id])
@@ -8,6 +8,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
let(:assistant) { create(:captain_assistant, account: account) }
before do
account.enable_features!('captain_integration')
create(:captain_inbox, captain_assistant: assistant, inbox: inbox)
end
@@ -202,6 +203,29 @@ RSpec.describe MessageTemplates::HookExecutionService do
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
before do
CaptainInbox.where(inbox: inbox).destroy_all