From 522e3c4d3ffdcb76854632d6d3975f24315a9500 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 16 Jul 2026 15:13:49 +0530 Subject: [PATCH] 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 CleanShot 2026-07-15 at 15 13 14@2x CleanShot 2026-07-15 at 15 14 37@2x --------- Co-authored-by: Muhsin Keloth --- .../api/v1/accounts/base_controller.rb | 9 +++ app/controllers/api/v1/accounts_controller.rb | 7 ++ .../components-next/button/ConfirmButton.vue | 2 + app/javascript/dashboard/featureFlags.js | 1 + .../i18n/locale/en/integrations.json | 7 ++ .../dashboard/i18n/locale/en/settings.json | 1 + .../settings/integrations/Webhooks/Index.vue | 51 +++++++++++--- .../integrations/Webhooks/WebhookPaywall.vue | 27 ++++++++ .../settings/profile/AccessToken.vue | 5 ++ .../dashboard/settings/profile/Index.vue | 26 +++++++- app/listeners/webhook_listener.rb | 2 + app/models/account.rb | 4 ++ app/views/api/v1/models/_user.json.jbuilder | 3 +- .../enterprise/api/v1/accounts_controller.rb | 7 ++ enterprise/app/models/enterprise/account.rb | 6 ++ spec/controllers/api/base_controller_spec.rb | 61 +++++++++++++++++ .../v1/accounts/webhook_controller_spec.rb | 11 ++++ .../api/v1/accounts_controller_spec.rb | 66 +++++++++++++++++++ .../api/v1/profiles_controller_spec.rb | 58 ++++++++++++++++ .../api/v1/accounts_controller_spec.rb | 24 +++++++ spec/enterprise/models/account_spec.rb | 22 +++++++ spec/listeners/webhook_listener_spec.rb | 44 +++++++++++++ spec/models/account_spec.rb | 9 +++ 23 files changed, 441 insertions(+), 12 deletions(-) create mode 100644 app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/WebhookPaywall.vue diff --git a/app/controllers/api/v1/accounts/base_controller.rb b/app/controllers/api/v1/accounts/base_controller.rb index e30effc59..f08b87e60 100644 --- a/app/controllers/api/v1/accounts/base_controller.rb +++ b/app/controllers/api/v1/accounts/base_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index fb991949a..9438a1660 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -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 diff --git a/app/javascript/dashboard/components-next/button/ConfirmButton.vue b/app/javascript/dashboard/components-next/button/ConfirmButton.vue index 854d5d452..00b9300c6 100644 --- a/app/javascript/dashboard/components-next/button/ConfirmButton.vue +++ b/app/javascript/dashboard/components-next/button/ConfirmButton.vue @@ -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" > diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js index 058921eea..e3c57e99d 100644 --- a/app/javascript/dashboard/featureFlags.js +++ b/app/javascript/dashboard/featureFlags.js @@ -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', diff --git a/app/javascript/dashboard/i18n/locale/en/integrations.json b/app/javascript/dashboard/i18n/locale/en/integrations.json index 629dbd27c..82782988f 100644 --- a/app/javascript/dashboard/i18n/locale/en/integrations.json +++ b/app/javascript/dashboard/i18n/locale/en/integrations.json @@ -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", diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index eaecd7b80..ceb0438b1 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -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?", diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/Index.vue index 7213c735a..75dc30812 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/Index.vue @@ -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 {