feat: enforce api_and_webhooks feature for token API and account webhooks (#14973)
This gates API-token access and outgoing account webhooks behind the `api_and_webhooks` account feature introduced in #14972. On Chatwoot Cloud, Hacker accounts lose token-authenticated account API access and account webhook delivery, while paid accounts retain them through the billing-plan feature reconcile. Community and self-hosted installations continue to work without any upgrade-time interruption. ## What changed - Added `Account#api_and_webhooks_enabled?` as the single backend kill switch. Core returns enabled; the Enterprise override consults the account flag on Chatwoot Cloud and remains enabled off-Cloud. - Account-scoped v1 and v2 requests authenticated with a user or agent-bot API token now return `403 Forbidden` when the feature is disabled. Invalid tokens still return 401, and dashboard session requests are unaffected. - Profile responses return an empty access token when none of the user's accounts has access. The stored token is preserved, and the profile UI disables its token controls with paid-plan copy on Cloud. - Account webhook delivery stops when the feature is disabled. Webhook CRUD remains available to session-authenticated dashboard requests, API-inbox webhooks continue to be delivered, and the Cloud dashboard shows a webhook paywall instead of the webhook list. - Removed the database backfill migration. Existing paid Cloud accounts should be enabled with the one-off script below before enforcement is deployed. ## Existing paid-account rollout Run this as an ad-hoc Rails runner script on Chatwoot Cloud. It intentionally targets only the Startups, Business, and Enterprise plans and does not add `api_and_webhooks` to `manually_managed_features`, so future billing reconciles remain authoritative. ```rb paid_plan_names = %w[Startups Business Enterprise] accounts = Account.where("custom_attributes ->> 'plan_name' IN (?)", paid_plan_names) total = accounts.count enabled = 0 skipped = 0 puts "Enabling api_and_webhooks for #{total} paid account(s)..." accounts.find_each(batch_size: 500).with_index(1) do |account, processed| if account.feature_enabled?('api_and_webhooks') skipped += 1 else account.enable_features!('api_and_webhooks') enabled += 1 end puts "Processed #{processed}/#{total}..." if (processed % 1000).zero? end puts "Done! Enabled: #{enabled}, Skipped: #{skipped}, Total: #{total}" ``` For example, save the snippet outside the repository as `enable_api_and_webhooks.rb`, then run: ```sh bundle exec rails runner /path/to/enable_api_and_webhooks.rb ``` ## How to test - On Cloud, use a Hacker account and confirm token-authenticated requests to account-scoped v1 and v2 endpoints return 403, while the same dashboard actions continue to work through session authentication. - Confirm profile access-token controls are disabled with paid-plan copy when all accounts are ineligible, and remain available when at least one account has the feature. - Confirm the Webhooks settings page shows the billing paywall for a Cloud account without the feature; admins get the billing action and agents get the existing ask-an-admin message. - Confirm outgoing account webhooks stop for an ineligible Cloud account while API-inbox webhooks still deliver. - Confirm community and self-hosted installations retain API and webhook behavior after upgrading, even when an existing account does not have the stored feature bit. ### Screenshots ## Cloud <img width="2590" height="642" alt="CleanShot 2026-07-15 at 15 13 14@2x" src="https://github.com/user-attachments/assets/431a7bd8-1742-4e7a-b312-d3ad92015f9b" /> <img width="2152" height="994" alt="CleanShot 2026-07-15 at 15 14 37@2x" src="https://github.com/user-attachments/assets/475dda48-d1c5-4be5-a3c3-7a96b9713724" /> --------- Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
co-authored by
Muhsin Keloth
parent
0a25a0ef66
commit
522e3c4d3f
@@ -2,5 +2,14 @@ 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
|
||||
return if Current.account.api_and_webhooks_enabled?
|
||||
|
||||
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,6 +8,7 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
before_action :ensure_account_name, only: [:create]
|
||||
before_action :validate_captcha, only: [:create]
|
||||
before_action :fetch_account, except: [:create]
|
||||
before_action :validate_token_api_access, if: :authenticate_by_access_token?, except: [:create]
|
||||
before_action :check_authorization, except: [:create]
|
||||
|
||||
rescue_from CustomExceptions::Account::InvalidEmail,
|
||||
@@ -105,6 +106,12 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
@current_account_user = @account.account_users.find_by(user_id: current_user.id)
|
||||
end
|
||||
|
||||
def validate_token_api_access
|
||||
return if @account.api_and_webhooks_enabled?
|
||||
|
||||
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
|
||||
end
|
||||
|
||||
def account_params
|
||||
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :user_full_name)
|
||||
end
|
||||
|
||||
@@ -14,6 +14,7 @@ const props = defineProps({
|
||||
icon: { type: [String, Object, Function], default: '' },
|
||||
trailingIcon: { type: Boolean, default: false },
|
||||
isLoading: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
@@ -61,6 +62,7 @@ const handleClick = () => {
|
||||
:icon="icon"
|
||||
:trailing-icon="trailingIcon"
|
||||
:is-loading="isLoading"
|
||||
:disabled="disabled"
|
||||
@click="handleClick"
|
||||
@blur="resetConfirmMode"
|
||||
>
|
||||
|
||||
@@ -12,6 +12,7 @@ export const FEATURE_FLAGS = {
|
||||
CRM: 'crm',
|
||||
CUSTOM_ATTRIBUTES: 'custom_attributes',
|
||||
DATA_IMPORT: 'data_import',
|
||||
API_AND_WEBHOOKS: 'api_and_webhooks',
|
||||
INBOX_MANAGEMENT: 'inbox_management',
|
||||
INTEGRATIONS: 'integrations',
|
||||
LABELS: 'labels',
|
||||
|
||||
@@ -31,6 +31,13 @@
|
||||
"WEBHOOK": {
|
||||
"SUBSCRIBED_EVENTS": "Subscribed Events",
|
||||
"LEARN_MORE": "Learn more about webhooks",
|
||||
"PAYWALL": {
|
||||
"TITLE": "Webhooks are available on paid plans",
|
||||
"AVAILABLE_ON": "Use webhooks to receive real-time events from your Chatwoot account.",
|
||||
"UPGRADE_PROMPT": "Upgrade to the Startups, Business, or Enterprise plan to use webhooks.",
|
||||
"UPGRADE_NOW": "Upgrade now",
|
||||
"CANCEL_ANYTIME": "Change or cancel your plan anytime."
|
||||
},
|
||||
"SECRET": {
|
||||
"LABEL": "Secret",
|
||||
"COPY": "Copy secret to clipboard",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"ACCESS_TOKEN": {
|
||||
"TITLE": "Access Token",
|
||||
"NOTE": "This token can be used if you are building an API based integration",
|
||||
"PAID_PLAN_NOTE": "API access tokens are available on paid plans.",
|
||||
"COPY": "Copy",
|
||||
"RESET": "Reset",
|
||||
"CONFIRM_RESET": "Are you sure?",
|
||||
|
||||
@@ -5,9 +5,11 @@ import { useBranding } from 'shared/composables/useBranding';
|
||||
import { picoSearch } from '@scmmishra/pico-search';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import { BaseTable } from 'dashboard/components-next/table';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import NewWebhook from './NewWebHook.vue';
|
||||
import EditWebhook from './EditWebHook.vue';
|
||||
import WebhookRow from './WebhookRow.vue';
|
||||
import WebhookPaywall from './WebhookPaywall.vue';
|
||||
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
|
||||
import SettingsLayout from '../../SettingsLayout.vue';
|
||||
|
||||
@@ -20,6 +22,7 @@ export default {
|
||||
NewWebhook,
|
||||
EditWebhook,
|
||||
WebhookRow,
|
||||
WebhookPaywall,
|
||||
},
|
||||
setup() {
|
||||
const { replaceInstallationName } = useBranding();
|
||||
@@ -39,7 +42,19 @@ export default {
|
||||
...mapGetters({
|
||||
records: 'webhooks/getWebhooks',
|
||||
uiFlags: 'webhooks/getUIFlags',
|
||||
accountId: 'getCurrentAccountId',
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
|
||||
}),
|
||||
apiAndWebhooksEnabled() {
|
||||
return (
|
||||
!this.isOnChatwootCloud ||
|
||||
this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.API_AND_WEBHOOKS
|
||||
)
|
||||
);
|
||||
},
|
||||
integration() {
|
||||
return this.$store.getters['integrations/getIntegration']('webhook');
|
||||
},
|
||||
@@ -57,9 +72,16 @@ export default {
|
||||
];
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
apiAndWebhooksEnabled: {
|
||||
immediate: true,
|
||||
handler(enabled) {
|
||||
if (enabled) this.$store.dispatch('webhooks/get');
|
||||
},
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('integrations/get', 'webhook');
|
||||
this.$store.dispatch('webhooks/get');
|
||||
},
|
||||
methods: {
|
||||
openAddPopup() {
|
||||
@@ -105,10 +127,10 @@ export default {
|
||||
|
||||
<template>
|
||||
<SettingsLayout
|
||||
:is-loading="uiFlags.fetchingList"
|
||||
:is-loading="apiAndWebhooksEnabled && uiFlags.fetchingList"
|
||||
:loading-message="$t('INTEGRATION_SETTINGS.WEBHOOK.LOADING')"
|
||||
:no-records-message="$t('INTEGRATION_SETTINGS.WEBHOOK.LIST.404')"
|
||||
:no-records-found="!records.length"
|
||||
:no-records-found="apiAndWebhooksEnabled && !records.length"
|
||||
>
|
||||
<template #header>
|
||||
<BaseSettingsHeader
|
||||
@@ -118,19 +140,21 @@ export default {
|
||||
:description="replaceInstallationName(integration.description)"
|
||||
:link-text="$t('INTEGRATION_SETTINGS.WEBHOOK.LEARN_MORE')"
|
||||
:search-placeholder="
|
||||
$t('INTEGRATION_SETTINGS.WEBHOOK.SEARCH_PLACEHOLDER')
|
||||
apiAndWebhooksEnabled
|
||||
? $t('INTEGRATION_SETTINGS.WEBHOOK.SEARCH_PLACEHOLDER')
|
||||
: ''
|
||||
"
|
||||
feature-name="webhook"
|
||||
:back-button-label="$t('INTEGRATION_SETTINGS.HEADER')"
|
||||
>
|
||||
<template v-if="records?.length" #count>
|
||||
<template v-if="apiAndWebhooksEnabled && records?.length" #count>
|
||||
<span class="text-body-main text-n-slate-11">
|
||||
{{
|
||||
$t('INTEGRATION_SETTINGS.WEBHOOK.COUNT', { n: records.length })
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<template v-if="apiAndWebhooksEnabled" #actions>
|
||||
<NextButton
|
||||
blue
|
||||
:label="$t('INTEGRATION_SETTINGS.WEBHOOK.HEADER_BTN_TXT')"
|
||||
@@ -141,7 +165,9 @@ export default {
|
||||
</BaseSettingsHeader>
|
||||
</template>
|
||||
<template #body>
|
||||
<WebhookPaywall v-if="!apiAndWebhooksEnabled" />
|
||||
<BaseTable
|
||||
v-else
|
||||
:headers="tableHeaders"
|
||||
:items="filteredRecords"
|
||||
:no-data-message="
|
||||
@@ -160,11 +186,19 @@ export default {
|
||||
</template>
|
||||
</BaseTable>
|
||||
</template>
|
||||
<woot-modal v-model:show="showAddPopup" :on-close="hideAddPopup">
|
||||
<woot-modal
|
||||
v-if="apiAndWebhooksEnabled"
|
||||
v-model:show="showAddPopup"
|
||||
:on-close="hideAddPopup"
|
||||
>
|
||||
<NewWebhook v-if="showAddPopup" :on-close="hideAddPopup" />
|
||||
</woot-modal>
|
||||
|
||||
<woot-modal v-model:show="showEditPopup" :on-close="hideEditPopup">
|
||||
<woot-modal
|
||||
v-if="apiAndWebhooksEnabled"
|
||||
v-model:show="showEditPopup"
|
||||
:on-close="hideEditPopup"
|
||||
>
|
||||
<EditWebhook
|
||||
v-if="showEditPopup"
|
||||
:id="selectedWebHook.id"
|
||||
@@ -173,6 +207,7 @@ export default {
|
||||
/>
|
||||
</woot-modal>
|
||||
<woot-delete-modal
|
||||
v-if="apiAndWebhooksEnabled"
|
||||
v-model:show="showDeleteConfirmationPopup"
|
||||
:on-close="closeDeletePopup"
|
||||
:on-confirm="confirmDeletion"
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
|
||||
const openBilling = () => {
|
||||
router.push({
|
||||
name: 'billing_settings_index',
|
||||
params: { accountId: accountId.value },
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid place-content-center w-full h-full max-h-[28rem] mx-auto">
|
||||
<BasePaywallModal
|
||||
class="mx-auto"
|
||||
feature-prefix="INTEGRATION_SETTINGS.WEBHOOK"
|
||||
i18n-key="PAYWALL"
|
||||
is-on-chatwoot-cloud
|
||||
@upgrade="openBilling"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -6,6 +6,7 @@ import ConfirmButton from 'dashboard/components-next/button/ConfirmButton.vue';
|
||||
const props = defineProps({
|
||||
value: { type: String, default: '' },
|
||||
showResetButton: { type: Boolean, default: true },
|
||||
disabled: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['onCopy', 'onReset']);
|
||||
@@ -41,12 +42,14 @@ const onReset = () => {
|
||||
}"
|
||||
:type="inputType"
|
||||
:model-value="value"
|
||||
:disabled="disabled"
|
||||
readonly
|
||||
>
|
||||
<template #masked>
|
||||
<button
|
||||
class="absolute top-0 bottom-0 ltr:right-0.5 rtl:left-0.5"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
@click="toggleMasked"
|
||||
>
|
||||
<fluent-icon :icon="maskIcon" :size="16" />
|
||||
@@ -61,6 +64,7 @@ const onReset = () => {
|
||||
type="button"
|
||||
icon="i-lucide-copy"
|
||||
class="rounded-xl"
|
||||
:disabled="disabled"
|
||||
@click="onClick"
|
||||
/>
|
||||
<ConfirmButton
|
||||
@@ -73,6 +77,7 @@ const onReset = () => {
|
||||
variant="outline"
|
||||
icon="i-lucide-key-round"
|
||||
class="rounded-xl"
|
||||
:disabled="disabled"
|
||||
@click="onReset"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -101,7 +101,24 @@ export default {
|
||||
currentUser: 'getCurrentUser',
|
||||
currentUserId: 'getCurrentUserID',
|
||||
globalConfig: 'globalConfig/get',
|
||||
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
|
||||
}),
|
||||
apiAndWebhooksEnabled() {
|
||||
if (!this.isOnChatwootCloud) return true;
|
||||
|
||||
return this.currentUser.accounts.some(
|
||||
account => account.api_and_webhooks
|
||||
);
|
||||
},
|
||||
accessTokenDescription() {
|
||||
if (!this.apiAndWebhooksEnabled) {
|
||||
return this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.PAID_PLAN_NOTE');
|
||||
}
|
||||
|
||||
return this.replaceInstallationName(
|
||||
this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.NOTE')
|
||||
);
|
||||
},
|
||||
isMfaEnabled() {
|
||||
return parseBoolean(window.chatwootConfig?.isMfaEnabled);
|
||||
},
|
||||
@@ -191,10 +208,14 @@ export default {
|
||||
useAlert(this.$t('PROFILE_SETTINGS.FORM.SEND_MESSAGE.UPDATE_SUCCESS'));
|
||||
},
|
||||
async onCopyToken(value) {
|
||||
if (!this.apiAndWebhooksEnabled) return;
|
||||
|
||||
await copyTextToClipboard(value);
|
||||
useAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
|
||||
},
|
||||
async resetAccessToken() {
|
||||
if (!this.apiAndWebhooksEnabled) return;
|
||||
|
||||
const success = await this.$store.dispatch('resetAccessToken');
|
||||
if (success) {
|
||||
useAlert(this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.RESET_SUCCESS'));
|
||||
@@ -339,12 +360,11 @@ export default {
|
||||
<SectionLayout
|
||||
with-border
|
||||
:title="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.TITLE')"
|
||||
:description="
|
||||
replaceInstallationName($t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.NOTE'))
|
||||
"
|
||||
:description="accessTokenDescription"
|
||||
>
|
||||
<AccessToken
|
||||
:value="currentUser.access_token"
|
||||
:disabled="!apiAndWebhooksEnabled"
|
||||
@on-copy="onCopyToken"
|
||||
@on-reset="resetAccessToken"
|
||||
/>
|
||||
|
||||
@@ -108,6 +108,8 @@ class WebhookListener < BaseListener
|
||||
end
|
||||
|
||||
def deliver_account_webhooks(payload, account)
|
||||
return unless account.api_and_webhooks_enabled?
|
||||
|
||||
account.webhooks.account_type.each do |webhook|
|
||||
next unless webhook.subscriptions.include?(payload[:event])
|
||||
|
||||
|
||||
@@ -154,6 +154,10 @@ class Account < ApplicationRecord
|
||||
}
|
||||
end
|
||||
|
||||
def api_and_webhooks_enabled?
|
||||
true
|
||||
end
|
||||
|
||||
def locale_english_name
|
||||
# the locale can also be something like pt_BR, en_US, fr_FR, etc.
|
||||
# the format is `<locale_code>_<country_code>`
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
json.access_token resource.access_token.token
|
||||
json.access_token resource.accounts.any?(&:api_and_webhooks_enabled?) ? resource.access_token.token : ''
|
||||
json.account_id resource.active_account_user&.account_id
|
||||
json.available_name resource.available_name
|
||||
json.avatar_url resource.avatar_url
|
||||
@@ -31,6 +31,7 @@ json.accounts do
|
||||
# availability derived from presence
|
||||
json.availability_status account_user.availability_status
|
||||
json.auto_offline account_user.auto_offline
|
||||
json.api_and_webhooks account_user.account.feature_enabled?('api_and_webhooks')
|
||||
json.partial! 'api/v1/models/account_user', account_user: account_user if ChatwootApp.enterprise?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class Enterprise::Api::V1::AccountsController < Api::BaseController
|
||||
include BillingHelper
|
||||
before_action :fetch_account
|
||||
before_action :validate_token_api_access, if: :authenticate_by_access_token?
|
||||
before_action :check_authorization
|
||||
before_action :check_cloud_env, only: [:limits, :toggle_deletion, :topup_options]
|
||||
|
||||
@@ -89,6 +90,12 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
|
||||
|
||||
private
|
||||
|
||||
def validate_token_api_access
|
||||
return if @account.api_and_webhooks_enabled?
|
||||
|
||||
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
|
||||
end
|
||||
|
||||
def check_cloud_env
|
||||
render json: { error: 'Not found' }, status: :not_found unless ChatwootApp.chatwoot_cloud?
|
||||
end
|
||||
|
||||
@@ -73,6 +73,12 @@ module Enterprise::Account
|
||||
saml_settings&.saml_enabled? || false
|
||||
end
|
||||
|
||||
def api_and_webhooks_enabled?
|
||||
return true unless ChatwootApp.chatwoot_cloud?
|
||||
|
||||
feature_enabled?('api_and_webhooks')
|
||||
end
|
||||
|
||||
def billing_currency
|
||||
# Feature off => everyone is billed in USD (legacy behaviour).
|
||||
return Enterprise::Billing::Currencies::DEFAULT unless Enterprise::Billing::Currencies.enabled?
|
||||
|
||||
@@ -23,6 +23,52 @@ RSpec.describe 'API Base', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API and webhook access is disabled for the account' do
|
||||
let!(:admin) { create(:user, :administrator, account: account) }
|
||||
let!(:conversation) { create(:conversation, account: account) }
|
||||
|
||||
before do
|
||||
allow(Account).to receive(:find).and_call_original
|
||||
allow(Account).to receive(:find).with(account.id.to_s).and_return(account)
|
||||
allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
|
||||
end
|
||||
|
||||
it 'returns forbidden 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(:forbidden)
|
||||
expect(response.parsed_body['error']).to eq('API access is not enabled for this account')
|
||||
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 a self-hosted account has the feature flag disabled' do
|
||||
let!(:admin) { create(:user, :administrator, account: account) }
|
||||
let!(:conversation) { create(:conversation, account: account) }
|
||||
|
||||
before do
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
|
||||
account.disable_features!('api_and_webhooks')
|
||||
end
|
||||
|
||||
it 'allows 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(:success)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an invalid api_access_token' do
|
||||
it 'returns unauthorized' do
|
||||
get '/api/v1/profile',
|
||||
@@ -94,6 +140,21 @@ RSpec.describe 'API Base', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API and webhook access is disabled for the account' do
|
||||
it 'returns forbidden for accessible bot endpoints' do
|
||||
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
|
||||
allow(Account).to receive(:find).and_call_original
|
||||
allow(Account).to receive(:find).with(account.id.to_s).and_return(account)
|
||||
allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
|
||||
|
||||
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(:forbidden)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the account is suspended' do
|
||||
it 'returns 401 unauthorized' do
|
||||
account.update!(status: :suspended)
|
||||
|
||||
@@ -26,6 +26,17 @@ 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 'allows session authenticated admins to manage webhooks' do
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
|
||||
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(:success)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/<account_id>/webhooks' do
|
||||
|
||||
@@ -199,6 +199,22 @@ RSpec.describe 'Accounts API', type: :request do
|
||||
expect(response.body).to include(account.locale)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API and webhook access is disabled for the account' do
|
||||
it 'returns forbidden for API token authentication' do
|
||||
account_scope = double
|
||||
allow(account_scope).to receive(:find).with(account.id.to_s).and_return(account)
|
||||
allow_any_instance_of(User).to receive(:accounts).and_return(account_scope) # rubocop:disable RSpec/AnyInstance
|
||||
allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}",
|
||||
headers: { api_access_token: admin.access_token.token },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
expect(response.parsed_body['error']).to eq('API access is not enabled for this account')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/cache_keys' do
|
||||
@@ -225,6 +241,21 @@ RSpec.describe 'Accounts API', type: :request do
|
||||
expect(response.headers['Cache-Control']).to include('private')
|
||||
expect(response.headers['Cache-Control']).to include('stale-while-revalidate=300')
|
||||
end
|
||||
|
||||
context 'when API and webhook access is disabled for the account' do
|
||||
it 'returns forbidden for API token authentication' do
|
||||
account_scope = double
|
||||
allow(account_scope).to receive(:find).with(account.id.to_s).and_return(account)
|
||||
allow_any_instance_of(User).to receive(:accounts).and_return(account_scope) # rubocop:disable RSpec/AnyInstance
|
||||
allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/cache_keys",
|
||||
headers: { api_access_token: admin.access_token.token },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH /api/v1/accounts/{account.id}' do
|
||||
@@ -324,6 +355,24 @@ RSpec.describe 'Accounts API', type: :request do
|
||||
expect(json_response['message']).to eq('Name is too long (maximum is 255 characters)')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API and webhook access is disabled for the account' do
|
||||
it 'returns forbidden without modifying the account for API token authentication' do
|
||||
account_scope = double
|
||||
allow(account_scope).to receive(:find).with(account.id.to_s).and_return(account)
|
||||
allow_any_instance_of(User).to receive(:accounts).and_return(account_scope) # rubocop:disable RSpec/AnyInstance
|
||||
allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
|
||||
|
||||
expect do
|
||||
patch "/api/v1/accounts/#{account.id}",
|
||||
params: { name: 'Updated through API' },
|
||||
headers: { api_access_token: admin.access_token.token },
|
||||
as: :json
|
||||
end.not_to(change { account.reload.name })
|
||||
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/update_active_at' do
|
||||
@@ -349,5 +398,22 @@ RSpec.describe 'Accounts API', type: :request do
|
||||
expect(agent.account_users.first.active_at).not_to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API and webhook access is disabled for the account' do
|
||||
it 'returns forbidden without updating active_at for API token authentication' do
|
||||
account_scope = double
|
||||
allow(account_scope).to receive(:find).with(account.id.to_s).and_return(account)
|
||||
allow_any_instance_of(User).to receive(:accounts).and_return(account_scope) # rubocop:disable RSpec/AnyInstance
|
||||
allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
|
||||
account_user = agent.account_users.first
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/update_active_at",
|
||||
headers: { api_access_token: agent.access_token.token },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
expect(account_user.reload.active_at).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -29,6 +29,49 @@ RSpec.describe 'Profile API', type: :request do
|
||||
expect(json_response['custom_attributes']['test']).to eq('test')
|
||||
expect(json_response['message_signature']).to be_nil
|
||||
end
|
||||
|
||||
it 'returns an empty access token when all accounts have API and webhook access disabled' do
|
||||
account.disable_features!('api_and_webhooks')
|
||||
allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
|
||||
allow_any_instance_of(User).to receive(:accounts).and_return([account]) # rubocop:disable RSpec/AnyInstance
|
||||
|
||||
get '/api/v1/profile',
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['access_token']).to eq('')
|
||||
expect(json_response['accounts'].first['api_and_webhooks']).to be false
|
||||
end
|
||||
|
||||
it 'returns the access token when any account has API and webhook access enabled' do
|
||||
account.disable_features!('api_and_webhooks')
|
||||
enabled_account = create(:account)
|
||||
enabled_account.enable_features!('api_and_webhooks')
|
||||
create(:account_user, account: enabled_account, user: agent)
|
||||
allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
|
||||
allow(enabled_account).to receive(:api_and_webhooks_enabled?).and_return(true)
|
||||
allow_any_instance_of(User).to receive(:accounts).and_return([account, enabled_account]) # rubocop:disable RSpec/AnyInstance
|
||||
|
||||
get '/api/v1/profile',
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['access_token']).to eq(agent.access_token.token)
|
||||
expect(json_response['accounts'].find { |item| item['id'] == enabled_account.id }['api_and_webhooks']).to be true
|
||||
end
|
||||
|
||||
it 'returns the access token for self-hosted accounts even when the stored feature flag is disabled' do
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
|
||||
account.disable_features!('api_and_webhooks')
|
||||
|
||||
get '/api/v1/profile',
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response.parsed_body['access_token']).to eq(agent.access_token.token)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -338,6 +381,21 @@ RSpec.describe 'Profile API', type: :request do
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['access_token']).to eq(agent.access_token.token)
|
||||
end
|
||||
|
||||
it 'regenerates the stored token but returns an empty token when no account has API and webhook access enabled' do
|
||||
account.disable_features!('api_and_webhooks')
|
||||
allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
|
||||
allow_any_instance_of(User).to receive(:accounts).and_return([account]) # rubocop:disable RSpec/AnyInstance
|
||||
old_token = agent.access_token.token
|
||||
|
||||
post '/api/v1/profile/reset_access_token',
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(agent.reload.access_token.token).not_to eq(old_token)
|
||||
expect(response.parsed_body['access_token']).to eq('')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,6 +5,30 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
|
||||
let!(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let!(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
describe 'API token access' do
|
||||
before do
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
|
||||
account.disable_features!('api_and_webhooks')
|
||||
end
|
||||
|
||||
it 'returns forbidden when API and webhook access is disabled for the account' do
|
||||
get "/enterprise/api/v1/accounts/#{account.id}/limits",
|
||||
headers: { api_access_token: admin.access_token.token },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
expect(response.parsed_body['error']).to eq('API access is not enabled for this account')
|
||||
end
|
||||
|
||||
it 'allows session-authenticated requests' do
|
||||
get "/enterprise/api/v1/accounts/#{account.id}/limits",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /enterprise/api/v1/accounts/{account.id}/subscription' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
|
||||
@@ -32,6 +32,28 @@ RSpec.describe Account, type: :model do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#api_and_webhooks_enabled?' do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
it 'is always enabled for self-hosted enterprise accounts' do
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
|
||||
account.disable_features!('api_and_webhooks')
|
||||
|
||||
expect(account.api_and_webhooks_enabled?).to be true
|
||||
end
|
||||
|
||||
it 'uses the account feature flag on Chatwoot Cloud' do
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
|
||||
account.disable_features!('api_and_webhooks')
|
||||
|
||||
expect(account.api_and_webhooks_enabled?).to be false
|
||||
|
||||
account.enable_features!('api_and_webhooks')
|
||||
|
||||
expect(account.api_and_webhooks_enabled?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'sla_policies' do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:sla_policy) { create(:sla_policy, account: account) }
|
||||
|
||||
@@ -44,6 +44,50 @@ describe WebhookListener do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API and webhook access is disabled for the account' do
|
||||
before do
|
||||
allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
|
||||
allow(message).to receive(:inbox).and_return(inbox)
|
||||
allow(inbox).to receive(:account).and_return(account)
|
||||
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_inbox = channel_api.inbox
|
||||
api_conversation = create(:conversation, account: account, inbox: api_inbox, assignee: user)
|
||||
api_message = create(:message, message_type: 'outgoing', account: account, inbox: api_inbox, conversation: api_conversation)
|
||||
api_event = Events::Base.new(event_name, Time.zone.now, message: api_message)
|
||||
allow(api_message).to receive(:inbox).and_return(api_inbox)
|
||||
allow(api_inbox).to receive(:account).and_return(account)
|
||||
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 api_and_webhooks feature is disabled on self-hosted' do
|
||||
it 'still triggers account webhooks' do
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
|
||||
account.disable_features!('api_and_webhooks')
|
||||
webhook = create(:webhook, inbox: inbox, account: account)
|
||||
|
||||
expect(WebhookJob).to receive(:perform_later).with(
|
||||
webhook.url, message.webhook_data.merge(event: 'message_created'), :account_webhook,
|
||||
secret: webhook.secret, delivery_id: instance_of(String)
|
||||
).once
|
||||
|
||||
listener.message_created(message_created_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)
|
||||
|
||||
@@ -50,6 +50,15 @@ RSpec.describe Account do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#api_and_webhooks_enabled?' do
|
||||
it 'is enabled for self-hosted accounts regardless of the stored feature flag' do
|
||||
account = create(:account)
|
||||
account.disable_features!('api_and_webhooks')
|
||||
|
||||
expect(account.api_and_webhooks_enabled?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'captain defaults for new accounts' do
|
||||
it 'does not store Captain model overrides or enable premium Captain features' do
|
||||
InstallationConfig.find_or_initialize_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS').update!(
|
||||
|
||||
Reference in New Issue
Block a user