Files
522e3c4d3f 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>
2026-07-16 13:43:49 +04:00

230 lines
8.8 KiB
Ruby

# == Schema Information
#
# Table name: accounts
#
# id :integer not null, primary key
# auto_resolve_duration :integer
# custom_attributes :jsonb
# domain :string(100)
# feature_flags :bigint default(0), not null
# feature_flags_ext_1 :bigint default(0), not null
# internal_attributes :jsonb not null
# limits :jsonb
# locale :integer default("en")
# name :string not null
# settings :jsonb
# status :integer default("active")
# support_email :string(100)
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_accounts_on_status (status)
#
class Account < ApplicationRecord
# used for multi-flag bitset columns
include FlagShihTzu
include Reportable
include Featurable
include CacheKeys
include CaptainFeaturable
include AccountEmailRateLimitable
include AccountSettingsSchema
DEFAULT_QUERY_SETTING = {
flag_query_mode: :bit_operator,
check_for_column: false
}.freeze
validates :name, presence: true
# `domain` is the inbound email domain used to construct reply addresses
# (see `inbound_email_domain`). Do not repurpose it for a website or any
# non-mail-related domain.
validates :domain, length: { maximum: 100 }
validates_with JsonSchemaValidator,
schema: SETTINGS_PARAMS_SCHEMA,
attribute_resolver: ->(record) { record.settings }
validate :validate_reporting_timezone
validate :validate_support_email_format, if: :will_save_change_to_support_email?
store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting
store_accessor :settings, :audio_transcriptions, :auto_resolve_label
store_accessor :settings, :captain_models, :captain_features
store_accessor :settings, :reporting_timezone
store_accessor :settings, :keep_pending_on_bot_failure
store_accessor :settings, :captain_auto_resolve_mode, :captain_false_promise_harness_enabled
include AccountCaptainAutoResolve
has_many :account_users, dependent: :destroy_async
has_many :agent_bot_inboxes, dependent: :destroy_async
has_many :agent_bots, dependent: :destroy_async
has_many :api_channels, dependent: :destroy_async, class_name: '::Channel::Api'
has_many :articles, dependent: :destroy_async, class_name: '::Article'
has_many :assignment_policies, dependent: :destroy_async
has_many :automation_rules, dependent: :destroy_async
has_many :macros, dependent: :destroy_async
has_many :campaigns, dependent: :destroy_async
has_many :canned_responses, dependent: :destroy_async
has_many :categories, dependent: :destroy_async, class_name: '::Category'
has_many :contacts, dependent: :destroy_async
has_many :conversations, dependent: :destroy_async
has_many :csat_survey_responses, dependent: :destroy_async
has_many :custom_attribute_definitions, dependent: :destroy_async
has_many :custom_filters, dependent: :destroy_async
has_many :dashboard_apps, dependent: :destroy_async
has_many :data_imports, dependent: :destroy_async
has_many :email_channels, dependent: :destroy_async, class_name: '::Channel::Email'
has_many :facebook_pages, dependent: :destroy_async, class_name: '::Channel::FacebookPage'
has_many :instagram_channels, dependent: :destroy_async, class_name: '::Channel::Instagram'
has_many :tiktok_channels, dependent: :destroy_async, class_name: '::Channel::Tiktok'
has_many :hooks, dependent: :destroy_async, class_name: 'Integrations::Hook'
has_many :inboxes, dependent: :destroy_async
has_many :labels, dependent: :destroy_async
has_many :line_channels, dependent: :destroy_async, class_name: '::Channel::Line'
has_many :mentions, dependent: :destroy_async
has_many :messages, dependent: :destroy_async
has_many :notes, dependent: :destroy_async
has_many :notification_settings, dependent: :destroy_async
has_many :notifications, dependent: :destroy_async
has_many :portals, dependent: :destroy_async, class_name: '::Portal'
has_many :sms_channels, dependent: :destroy_async, class_name: '::Channel::Sms'
has_many :teams, dependent: :destroy_async
has_many :telegram_channels, dependent: :destroy_async, class_name: '::Channel::Telegram'
has_many :twilio_sms, dependent: :destroy_async, class_name: '::Channel::TwilioSms'
has_many :twitter_profiles, dependent: :destroy_async, class_name: '::Channel::TwitterProfile'
has_many :users, through: :account_users
has_many :web_widgets, dependent: :destroy_async, class_name: '::Channel::WebWidget'
has_many :webhooks, dependent: :destroy_async
has_many :whatsapp_channels, dependent: :destroy_async, class_name: '::Channel::Whatsapp'
has_many :working_hours, dependent: :destroy_async
has_one_attached :contacts_export
enum :locale, LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h, prefix: true
enum :status, { active: 0, suspended: 1 }
scope :with_auto_resolve, -> { where("(settings ->> 'auto_resolve_after')::int IS NOT NULL") }
before_validation :validate_limit_keys
after_create_commit :notify_creation
after_update_commit :clear_unread_conversation_counts_cache, if: :saved_change_to_feature_conversation_unread_counts?
after_destroy :remove_account_sequences
def agents
users.where(account_users: { role: :agent })
end
def administrators
users.where(account_users: { role: :administrator })
end
def all_conversation_tags
# returns array of tags
conversation_ids = conversations.pluck(:id)
ActsAsTaggableOn::Tagging.includes(:tag)
.where(context: 'labels',
taggable_type: 'Conversation',
taggable_id: conversation_ids)
.map { |tagging| tagging.tag.name }
end
def webhook_data
{
id: id,
name: name
}
end
def inbound_email_domain
domain.presence || GlobalConfig.get('MAILER_INBOUND_EMAIL_DOMAIN')['MAILER_INBOUND_EMAIL_DOMAIN'] || ENV.fetch('MAILER_INBOUND_EMAIL_DOMAIN',
false)
end
def support_email
super.presence || ENV.fetch('MAILER_SENDER_EMAIL') { GlobalConfig.get('MAILER_SUPPORT_EMAIL')['MAILER_SUPPORT_EMAIL'] }
end
def usage_limits
{
agents: ChatwootApp.max_limit.to_i,
inboxes: ChatwootApp.max_limit.to_i
}
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>`
# we need to extract the language code from the locale
account_locale = locale&.split('_')&.first
ISO_639.find(account_locale)&.english_name&.downcase || 'english'
end
def onboarding_step
step = custom_attributes['onboarding_step']
return nil if step.blank?
enrichment_key = format(Redis::Alfred::ACCOUNT_ONBOARDING_ENRICHMENT, account_id: id)
Redis::Alfred.exists?(enrichment_key) ? 'enrichment' : step
end
def reset_cache_keys
super
clear_unread_conversation_counts_cache
end
private
def notify_creation
Rails.configuration.dispatcher.dispatch(ACCOUNT_CREATED, Time.zone.now, account: self)
end
def clear_unread_conversation_counts_cache
::Conversations::UnreadCounts::Store.clear_account!(id)
end
trigger.after(:insert).for_each(:row) do
"execute format('create sequence IF NOT EXISTS conv_dpid_seq_%s', NEW.id);"
end
trigger.name('camp_dpid_before_insert').after(:insert).for_each(:row) do
"execute format('create sequence IF NOT EXISTS camp_dpid_seq_%s', NEW.id);"
end
def validate_limit_keys
# method overridden in enterprise module
end
def validate_reporting_timezone
return if reporting_timezone.blank? || ActiveSupport::TimeZone[reporting_timezone].present?
errors.add(:reporting_timezone, I18n.t('errors.account.reporting_timezone.invalid'))
end
def validate_support_email_format
value = attributes['support_email']
return if value.blank?
parsed = Mail::Address.new(value).address
errors.add(:support_email, I18n.t('errors.account.support_email.invalid')) if parsed.blank?
rescue Mail::Field::ParseError, Mail::Field::IncompleteParseError
errors.add(:support_email, I18n.t('errors.account.support_email.invalid'))
end
def remove_account_sequences
ActiveRecord::Base.connection.exec_query("drop sequence IF EXISTS camp_dpid_seq_#{id}")
ActiveRecord::Base.connection.exec_query("drop sequence IF EXISTS conv_dpid_seq_#{id}")
end
end
Account.prepend_mod_with('Account')
Account.prepend_mod_with('Account::PlanUsageAndLimits')
Account.include_mod_with('Concerns::Account')
Account.include_mod_with('Audit::Account')