From d3b294fe9cd70d9eff39f9153a397a9f2dc2ce15 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 9 Jul 2026 17:51:05 +0530 Subject: [PATCH] feat: gate token API access and account webhooks behind api_and_webhooks flag --- .../api/v1/accounts/base_controller.rb | 7 ++++ .../api/v1/accounts/webhooks_controller.rb | 5 +++ app/listeners/webhook_listener.rb | 2 + config/features.yml | 4 ++ spec/controllers/api/base_controller_spec.rb | 41 ++++++++++++++++++- .../assignments_controller_spec.rb | 2 +- .../conversations/messages_controller_spec.rb | 2 +- .../accounts/conversations_controller_spec.rb | 2 +- .../v1/accounts/webhook_controller_spec.rb | 12 +++++- spec/listeners/webhook_listener_spec.rb | 26 +++++++++++- spec/models/account_spec.rb | 2 +- .../api/v1/accounts/base_controller_spec.rb | 2 +- 12 files changed, 99 insertions(+), 8 deletions(-) diff --git a/app/controllers/api/v1/accounts/base_controller.rb b/app/controllers/api/v1/accounts/base_controller.rb index e30effc59..c17420676 100644 --- a/app/controllers/api/v1/accounts/base_controller.rb +++ b/app/controllers/api/v1/accounts/base_controller.rb @@ -2,5 +2,12 @@ class Api::V1::Accounts::BaseController < Api::BaseController include SwitchLocale include EnsureCurrentAccountHelper before_action :current_account + before_action :validate_token_api_access, if: :authenticate_by_access_token? around_action :switch_locale_using_account_locale + + private + + def validate_token_api_access + render_unauthorized('Invalid Access Token') unless Current.account.feature_enabled?('api_and_webhooks') + end end diff --git a/app/controllers/api/v1/accounts/webhooks_controller.rb b/app/controllers/api/v1/accounts/webhooks_controller.rb index 9f8e94821..01c7ea8d0 100644 --- a/app/controllers/api/v1/accounts/webhooks_controller.rb +++ b/app/controllers/api/v1/accounts/webhooks_controller.rb @@ -1,4 +1,5 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController + before_action :ensure_api_and_webhooks_enabled before_action :check_authorization before_action :fetch_webhook, only: [:update, :destroy] @@ -22,6 +23,10 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController private + def ensure_api_and_webhooks_enabled + render_unauthorized('You are not authorized to do this action') unless Current.account.feature_enabled?('api_and_webhooks') + end + def webhook_params params.require(:webhook).permit(:inbox_id, :name, :url, subscriptions: []) end diff --git a/app/listeners/webhook_listener.rb b/app/listeners/webhook_listener.rb index c64b36ca0..71fc806b0 100644 --- a/app/listeners/webhook_listener.rb +++ b/app/listeners/webhook_listener.rb @@ -108,6 +108,8 @@ class WebhookListener < BaseListener end def deliver_account_webhooks(payload, account) + return unless account.feature_enabled?('api_and_webhooks') + account.webhooks.account_type.each do |webhook| next unless webhook.subscriptions.include?(payload[:event]) diff --git a/config/features.yml b/config/features.yml index fe2ef2122..7918f7c05 100644 --- a/config/features.yml +++ b/config/features.yml @@ -249,3 +249,7 @@ display_name: Advanced Assignment enabled: false premium: true +- name: api_and_webhooks + display_name: API and Webhooks + enabled: true + column: feature_flags_ext_1 diff --git a/spec/controllers/api/base_controller_spec.rb b/spec/controllers/api/base_controller_spec.rb index 0034715eb..f97481702 100644 --- a/spec/controllers/api/base_controller_spec.rb +++ b/spec/controllers/api/base_controller_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' RSpec.describe 'API Base', type: :request do - let!(:account) { create(:account) } + let!(:account) { create(:account).tap { |account| account.enable_features!('api_and_webhooks') } } let!(:user) { create(:user, account: account) } describe 'request with api_access_token for user' do @@ -23,6 +23,32 @@ RSpec.describe 'API Base', type: :request do end end + context 'when the account does not have the api_and_webhooks feature' do + let!(:admin) { create(:user, :administrator, account: account) } + let!(:conversation) { create(:conversation, account: account) } + + before do + account.disable_features!('api_and_webhooks') + end + + it 'returns unauthorized for token authenticated requests' do + get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", + headers: { api_access_token: admin.access_token.token }, + as: :json + + expect(response).to have_http_status(:unauthorized) + expect(response.parsed_body['error']).to eq('Invalid Access Token') + end + + it 'allows session authenticated requests' do + get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + end + end + context 'when it is an invalid api_access_token' do it 'returns unauthorized' do get '/api/v1/profile', @@ -94,6 +120,19 @@ RSpec.describe 'API Base', type: :request do end end + context 'when the account does not have the api_and_webhooks feature' do + it 'returns unauthorized for accessible bot endpoints' do + create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot) + account.disable_features!('api_and_webhooks') + + post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_status", + headers: { api_access_token: agent_bot.access_token.token }, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + context 'when the account is suspended' do it 'returns 401 unauthorized' do account.update!(status: :suspended) diff --git a/spec/controllers/api/v1/accounts/conversations/assignments_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations/assignments_controller_spec.rb index 18c652c0a..379601e29 100644 --- a/spec/controllers/api/v1/accounts/conversations/assignments_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/conversations/assignments_controller_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' RSpec.describe 'Conversation Assignment API', type: :request do - let(:account) { create(:account) } + let(:account) { create(:account).tap { |account| account.enable_features!('api_and_webhooks') } } describe 'POST /api/v1/accounts/{account.id}/conversations//assignments' do let(:conversation) { create(:conversation, account: account) } diff --git a/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb index 766fd3b6b..88a21771c 100644 --- a/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' RSpec.describe 'Conversation Messages API', type: :request do - let!(:account) { create(:account) } + let!(:account) { create(:account).tap { |account| account.enable_features!('api_and_webhooks') } } describe 'POST /api/v1/accounts/{account.id}/conversations//messages' do let!(:inbox) { create(:inbox, account: account) } diff --git a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb index b0eddd639..deccb4c19 100644 --- a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' RSpec.describe 'Conversations API', type: :request do - let(:account) { create(:account) } + let(:account) { create(:account).tap { |account| account.enable_features!('api_and_webhooks') } } describe 'GET /api/v1/accounts/{account.id}/conversations' do context 'when it is an unauthenticated user' do diff --git a/spec/controllers/api/v1/accounts/webhook_controller_spec.rb b/spec/controllers/api/v1/accounts/webhook_controller_spec.rb index 86f4d4e7e..471df7a8c 100644 --- a/spec/controllers/api/v1/accounts/webhook_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/webhook_controller_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' RSpec.describe 'Webhooks API', type: :request do - let(:account) { create(:account) } + let(:account) { create(:account).tap { |account| account.enable_features!('api_and_webhooks') } } let(:inbox) { create(:inbox, account: account) } let(:webhook) { create(:webhook, account: account, inbox: inbox, url: 'https://hello.com', name: 'My Webhook') } let(:administrator) { create(:user, account: account, role: :administrator) } @@ -26,6 +26,16 @@ RSpec.describe 'Webhooks API', type: :request do expect(response.parsed_body['payload']['webhooks'].count).to eql account.webhooks.count end end + + context 'when api_and_webhooks feature is disabled' do + it 'returns unauthorized even for an admin' do + account.disable_features!('api_and_webhooks') + get "/api/v1/accounts/#{account.id}/webhooks", + headers: administrator.create_new_auth_token, + as: :json + expect(response).to have_http_status(:unauthorized) + end + end end describe 'POST /api/v1/accounts//webhooks' do diff --git a/spec/listeners/webhook_listener_spec.rb b/spec/listeners/webhook_listener_spec.rb index b63f43c2f..5fc1b31f4 100644 --- a/spec/listeners/webhook_listener_spec.rb +++ b/spec/listeners/webhook_listener_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' describe WebhookListener do let(:listener) { described_class.instance } - let!(:account) { create(:account) } + let!(:account) { create(:account).tap { |account| account.enable_features!('api_and_webhooks') } } let(:report_identity) { Reports::UpdateAccountIdentity.new(account, Time.zone.now) } let!(:user) { create(:user, account: account) } let!(:inbox) { create(:inbox, account: account) } @@ -44,6 +44,30 @@ describe WebhookListener do end end + context 'when api_and_webhooks feature is disabled' do + before do + account.disable_features!('api_and_webhooks') + end + + it 'does not trigger account webhooks' do + create(:webhook, inbox: inbox, account: account) + expect(WebhookJob).not_to receive(:perform_later) + listener.message_created(message_created_event) + end + + it 'still triggers API inbox webhooks' do + channel_api = create(:channel_api, account: account) + api_conversation = create(:conversation, account: account, inbox: channel_api.inbox, assignee: user) + api_message = create(:message, message_type: 'outgoing', account: account, inbox: channel_api.inbox, conversation: api_conversation) + api_event = Events::Base.new(event_name, Time.zone.now, message: api_message) + expect(WebhookJob).to receive(:perform_later).with( + channel_api.webhook_url, api_message.webhook_data.merge(event: 'message_created'), + :api_inbox_webhook, secret: channel_api.secret, delivery_id: instance_of(String) + ).once + listener.message_created(api_event) + end + end + context 'when inbox is an API Channel' do it 'triggers webhook if webhook_url is present' do channel_api = create(:channel_api, account: account) diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index 9adc98cc0..dacd147f9 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -108,7 +108,7 @@ RSpec.describe Account do it 'configures the account feature flag extension column' do expect(described_class.flag_columns).to include('feature_flags', 'feature_flags_ext_1') - expect(described_class.flag_mapping['feature_flags_ext_1']).to eq({}) + expect(described_class.flag_mapping['feature_flags_ext_1']).to eq(feature_api_and_webhooks: 1) end it 'keeps existing feature flags on the original column' do diff --git a/spec/requests/api/v1/accounts/base_controller_spec.rb b/spec/requests/api/v1/accounts/base_controller_spec.rb index 6493bc986..f4c907971 100644 --- a/spec/requests/api/v1/accounts/base_controller_spec.rb +++ b/spec/requests/api/v1/accounts/base_controller_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' RSpec.describe 'Api::V1::Accounts::BaseController', type: :request do - let(:account) { create(:account) } + let(:account) { create(:account).tap { |account| account.enable_features!('api_and_webhooks') } } let(:inbox) { create(:inbox, account: account) } let!(:conversation) { create(:conversation, account: account, inbox: inbox) } let(:agent) { create(:user, account: account, role: :agent) }