feat: sign webhooks for API channel and agentbots (#13892)
Account webhooks sign outgoing payloads with HMAC-SHA256, but agent bot and API inbox webhooks were delivered unsigned. This PR adds the same signing to both. Each model gets a dedicated `secret` column rather than reusing the agent bot's `access_token` (for API auth back into Chatwoot) or the API inbox's `hmac_token` (for inbound contact identity verification). These serve different trust boundaries and shouldn't be coupled — rotating a signing secret shouldn't invalidate API access or contact verification. The existing `Webhooks::Trigger` already signs when a secret is present, so the backend change is just passing `secret:` through to the jobs. Shared token logic is extracted into a `WebhookSecretable` concern included by `Webhook`, `AgentBot`, and `Channel::Api`. The frontend reuses the existing `AccessToken` component for secret display. Secrets are admin-only and excluded from enterprise audit logs. ### How to test Point an agent bot or API inbox webhook URL at a request inspector. Send a message and verify `X-Chatwoot-Signature` and `X-Chatwoot-Timestamp` headers are present. Reset the secret from settings and confirm subsequent deliveries use the new value. --------- Co-authored-by: Sojan Jose <sojan@pepalo.com>
This commit is contained in:
parent
f4d66566d0
commit
95463230cb
@ -34,6 +34,10 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
|
|||||||
@agent_bot.reload
|
@agent_bot.reload
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def reset_secret
|
||||||
|
@agent_bot.reset_secret!
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def agent_bot
|
def agent_bot
|
||||||
|
|||||||
@ -66,6 +66,12 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
head :ok
|
head :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def reset_secret
|
||||||
|
return head :not_found unless @inbox.api?
|
||||||
|
|
||||||
|
@inbox.channel.reset_secret!
|
||||||
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
::DeleteObjectJob.perform_later(@inbox, Current.user, request.ip) if @inbox.present?
|
::DeleteObjectJob.perform_later(@inbox, Current.user, request.ip) if @inbox.present?
|
||||||
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
|
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
|
||||||
|
|||||||
@ -25,6 +25,10 @@ class AgentBotsAPI extends ApiClient {
|
|||||||
resetAccessToken(botId) {
|
resetAccessToken(botId) {
|
||||||
return axios.post(`${this.url}/${botId}/reset_access_token`);
|
return axios.post(`${this.url}/${botId}/reset_access_token`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resetSecret(botId) {
|
||||||
|
return axios.post(`${this.url}/${botId}/reset_secret`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new AgentBotsAPI();
|
export default new AgentBotsAPI();
|
||||||
|
|||||||
@ -48,6 +48,10 @@ class Inboxes extends CacheEnabledApiClient {
|
|||||||
template,
|
template,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resetSecret(inboxId) {
|
||||||
|
return axios.post(`${this.url}/${inboxId}/reset_secret`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new Inboxes();
|
export default new Inboxes();
|
||||||
|
|||||||
@ -63,6 +63,16 @@
|
|||||||
"ERROR_MESSAGE": "Could not update bot. Please try again."
|
"ERROR_MESSAGE": "Could not update bot. Please try again."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"SECRET": {
|
||||||
|
"LABEL": "Webhook Secret",
|
||||||
|
"COPY": "Copy secret to clipboard",
|
||||||
|
"COPY_SUCCESS": "Secret copied to clipboard",
|
||||||
|
"TOGGLE": "Toggle secret visibility",
|
||||||
|
"CREATED_DESC": "Use the secret below to verify webhook signatures. Please copy it now, you can also find it later in the bot settings.",
|
||||||
|
"DONE": "Done",
|
||||||
|
"RESET_SUCCESS": "Webhook secret regenerated successfully",
|
||||||
|
"RESET_ERROR": "Unable to regenerate webhook secret. Please try again"
|
||||||
|
},
|
||||||
"ACCESS_TOKEN": {
|
"ACCESS_TOKEN": {
|
||||||
"TITLE": "Access Token",
|
"TITLE": "Access Token",
|
||||||
"DESCRIPTION": "Copy the access token and save it securely",
|
"DESCRIPTION": "Copy the access token and save it securely",
|
||||||
|
|||||||
@ -86,6 +86,14 @@
|
|||||||
"PLACEHOLDER": "Please enter your Webhook URL",
|
"PLACEHOLDER": "Please enter your Webhook URL",
|
||||||
"ERROR": "Please enter a valid URL"
|
"ERROR": "Please enter a valid URL"
|
||||||
},
|
},
|
||||||
|
"CHANNEL_WEBHOOK_SECRET": {
|
||||||
|
"LABEL": "Webhook Secret",
|
||||||
|
"COPY": "Copy secret to clipboard",
|
||||||
|
"COPY_SUCCESS": "Secret copied to clipboard",
|
||||||
|
"TOGGLE": "Toggle secret visibility",
|
||||||
|
"RESET_SUCCESS": "Webhook secret regenerated successfully",
|
||||||
|
"RESET_ERROR": "Unable to regenerate webhook secret. Please try again"
|
||||||
|
},
|
||||||
"CHANNEL_DOMAIN": {
|
"CHANNEL_DOMAIN": {
|
||||||
"LABEL": "Website Domain",
|
"LABEL": "Website Domain",
|
||||||
"PLACEHOLDER": "Enter your website domain (eg: acme.com)"
|
"PLACEHOLDER": "Enter your website domain (eg: acme.com)"
|
||||||
|
|||||||
@ -47,6 +47,7 @@ const formState = reactive({
|
|||||||
|
|
||||||
const [showAccessToken, toggleAccessToken] = useToggle();
|
const [showAccessToken, toggleAccessToken] = useToggle();
|
||||||
const accessToken = ref('');
|
const accessToken = ref('');
|
||||||
|
const botSecret = ref('');
|
||||||
|
|
||||||
const v$ = useVuelidate(
|
const v$ = useVuelidate(
|
||||||
{
|
{
|
||||||
@ -179,15 +180,21 @@ const handleSubmit = async () => {
|
|||||||
: t('AGENT_BOTS.EDIT.API.SUCCESS_MESSAGE');
|
: t('AGENT_BOTS.EDIT.API.SUCCESS_MESSAGE');
|
||||||
useAlert(alertKey);
|
useAlert(alertKey);
|
||||||
|
|
||||||
// Show access token after creation
|
// Show access token and secret after creation
|
||||||
if (isCreate) {
|
if (isCreate) {
|
||||||
const { access_token: responseAccessToken, id } = response || {};
|
const {
|
||||||
|
access_token: responseAccessToken,
|
||||||
|
secret: responseSecret,
|
||||||
|
id,
|
||||||
|
} = response || {};
|
||||||
|
|
||||||
if (id && responseAccessToken) {
|
if (id && responseAccessToken) {
|
||||||
accessToken.value = responseAccessToken;
|
accessToken.value = responseAccessToken;
|
||||||
|
botSecret.value = responseSecret || '';
|
||||||
toggleAccessToken(true);
|
toggleAccessToken(true);
|
||||||
} else {
|
} else {
|
||||||
accessToken.value = '';
|
accessToken.value = '';
|
||||||
|
botSecret.value = '';
|
||||||
dialogRef.value.close();
|
dialogRef.value.close();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -212,14 +219,16 @@ const initializeForm = () => {
|
|||||||
thumbnail,
|
thumbnail,
|
||||||
bot_config: botConfig,
|
bot_config: botConfig,
|
||||||
access_token: botAccessToken,
|
access_token: botAccessToken,
|
||||||
|
secret: botSecretValue,
|
||||||
} = props.selectedBot;
|
} = props.selectedBot;
|
||||||
formState.botName = name || '';
|
formState.botName = name || '';
|
||||||
formState.botDescription = description || '';
|
formState.botDescription = description || '';
|
||||||
formState.botUrl = botUrl || botConfig?.webhook_url || '';
|
formState.botUrl = botUrl || botConfig?.webhook_url || '';
|
||||||
formState.botAvatarUrl = thumbnail || '';
|
formState.botAvatarUrl = thumbnail || '';
|
||||||
|
|
||||||
if (botAccessToken && props.type === MODAL_TYPES.EDIT) {
|
if (props.type === MODAL_TYPES.EDIT) {
|
||||||
accessToken.value = botAccessToken;
|
if (botAccessToken) accessToken.value = botAccessToken;
|
||||||
|
if (botSecretValue) botSecret.value = botSecretValue;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
resetForm();
|
resetForm();
|
||||||
@ -231,6 +240,24 @@ const onCopyToken = async value => {
|
|||||||
useAlert(t('AGENT_BOTS.ACCESS_TOKEN.COPY_SUCCESSFUL'));
|
useAlert(t('AGENT_BOTS.ACCESS_TOKEN.COPY_SUCCESSFUL'));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onCopySecret = async value => {
|
||||||
|
await copyTextToClipboard(value || botSecret.value);
|
||||||
|
useAlert(t('AGENT_BOTS.SECRET.COPY_SUCCESS'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onResetSecret = async () => {
|
||||||
|
const response = await store.dispatch(
|
||||||
|
'agentBots/resetSecret',
|
||||||
|
props.selectedBot.id
|
||||||
|
);
|
||||||
|
if (response) {
|
||||||
|
botSecret.value = response.secret;
|
||||||
|
useAlert(t('AGENT_BOTS.SECRET.RESET_SUCCESS'));
|
||||||
|
} else {
|
||||||
|
useAlert(t('AGENT_BOTS.SECRET.RESET_ERROR'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onResetToken = async () => {
|
const onResetToken = async () => {
|
||||||
const response = await store.dispatch(
|
const response = await store.dispatch(
|
||||||
'agentBots/resetAccessToken',
|
'agentBots/resetAccessToken',
|
||||||
@ -247,6 +274,7 @@ const onResetToken = async () => {
|
|||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
if (!showAccessToken.value) v$.value?.$reset();
|
if (!showAccessToken.value) v$.value?.$reset();
|
||||||
accessToken.value = '';
|
accessToken.value = '';
|
||||||
|
botSecret.value = '';
|
||||||
toggleAccessToken(false);
|
toggleAccessToken(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -318,6 +346,20 @@ defineExpose({ dialogRef });
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="botSecret && type === MODAL_TYPES.EDIT"
|
||||||
|
class="flex flex-col gap-1"
|
||||||
|
>
|
||||||
|
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||||
|
{{ $t('AGENT_BOTS.SECRET.LABEL') }}
|
||||||
|
</label>
|
||||||
|
<AccessToken
|
||||||
|
:value="botSecret"
|
||||||
|
@on-copy="onCopySecret"
|
||||||
|
@on-reset="onResetSecret"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="showAccessTokenInput" class="flex flex-col gap-1">
|
<div v-if="showAccessTokenInput" class="flex flex-col gap-1">
|
||||||
<label
|
<label
|
||||||
v-if="type === MODAL_TYPES.EDIT"
|
v-if="type === MODAL_TYPES.EDIT"
|
||||||
@ -339,6 +381,23 @@ defineExpose({ dialogRef });
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="botSecret && showAccessToken && type === MODAL_TYPES.CREATE"
|
||||||
|
class="flex flex-col gap-1"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-n-slate-11">
|
||||||
|
{{ $t('AGENT_BOTS.SECRET.CREATED_DESC') }}
|
||||||
|
</p>
|
||||||
|
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||||
|
{{ $t('AGENT_BOTS.SECRET.LABEL') }}
|
||||||
|
</label>
|
||||||
|
<AccessToken
|
||||||
|
:value="botSecret"
|
||||||
|
:show-reset-button="false"
|
||||||
|
@on-copy="onCopySecret"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-end w-full gap-2 px-0 py-2">
|
<div class="flex items-center justify-end w-full gap-2 px-0 py-2">
|
||||||
<NextButton
|
<NextButton
|
||||||
faded
|
faded
|
||||||
|
|||||||
@ -38,6 +38,8 @@ import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
|||||||
import ColorPicker from 'dashboard/components-next/colorpicker/ColorPicker.vue';
|
import ColorPicker from 'dashboard/components-next/colorpicker/ColorPicker.vue';
|
||||||
import SelectInput from 'dashboard/components-next/select/Select.vue';
|
import SelectInput from 'dashboard/components-next/select/Select.vue';
|
||||||
import Widget from 'dashboard/modules/widget-preview/components/Widget.vue';
|
import Widget from 'dashboard/modules/widget-preview/components/Widget.vue';
|
||||||
|
import AccessToken from 'dashboard/routes/dashboard/settings/profile/AccessToken.vue';
|
||||||
|
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
@ -69,6 +71,7 @@ export default {
|
|||||||
SelectInput,
|
SelectInput,
|
||||||
AccountHealth,
|
AccountHealth,
|
||||||
Widget,
|
Widget,
|
||||||
|
AccessToken,
|
||||||
},
|
},
|
||||||
mixins: [inboxMixin],
|
mixins: [inboxMixin],
|
||||||
setup() {
|
setup() {
|
||||||
@ -362,6 +365,33 @@ export default {
|
|||||||
this.fetchSharedData();
|
this.fetchSharedData();
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
async copyWebhookSecret(value) {
|
||||||
|
await copyTextToClipboard(value);
|
||||||
|
useAlert(
|
||||||
|
this.$t(
|
||||||
|
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WEBHOOK_SECRET.COPY_SUCCESS'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async resetWebhookSecret() {
|
||||||
|
const response = await this.$store.dispatch(
|
||||||
|
'inboxes/resetSecret',
|
||||||
|
this.inbox.id
|
||||||
|
);
|
||||||
|
if (response) {
|
||||||
|
useAlert(
|
||||||
|
this.$t(
|
||||||
|
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WEBHOOK_SECRET.RESET_SUCCESS'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
useAlert(
|
||||||
|
this.$t(
|
||||||
|
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WEBHOOK_SECRET.RESET_ERROR'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
fetchSharedData() {
|
fetchSharedData() {
|
||||||
this.$store.dispatch('agents/get');
|
this.$store.dispatch('agents/get');
|
||||||
this.$store.dispatch('teams/get');
|
this.$store.dispatch('teams/get');
|
||||||
@ -714,6 +744,21 @@ export default {
|
|||||||
/>
|
/>
|
||||||
</SettingsFieldSection>
|
</SettingsFieldSection>
|
||||||
|
|
||||||
|
<SettingsFieldSection
|
||||||
|
v-if="isAPIInbox && inbox.secret"
|
||||||
|
:label="
|
||||||
|
$t(
|
||||||
|
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WEBHOOK_SECRET.LABEL'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<AccessToken
|
||||||
|
:value="inbox.secret"
|
||||||
|
@on-copy="copyWebhookSecret"
|
||||||
|
@on-reset="resetWebhookSecret"
|
||||||
|
/>
|
||||||
|
</SettingsFieldSection>
|
||||||
|
|
||||||
<SettingsFieldSection
|
<SettingsFieldSection
|
||||||
v-if="isAWebWidgetInbox"
|
v-if="isAWebWidgetInbox"
|
||||||
:label="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_DOMAIN.LABEL')"
|
:label="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_DOMAIN.LABEL')"
|
||||||
|
|||||||
@ -183,6 +183,17 @@ export const actions = {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
resetSecret: async ({ commit }, botId) => {
|
||||||
|
try {
|
||||||
|
const response = await AgentBotsAPI.resetSecret(botId);
|
||||||
|
commit(types.EDIT_AGENT_BOT, response.data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throwErrorMessage(error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mutations = {
|
export const mutations = {
|
||||||
|
|||||||
@ -366,6 +366,16 @@ export const actions = {
|
|||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
resetSecret: async ({ commit }, inboxId) => {
|
||||||
|
try {
|
||||||
|
const response = await InboxesAPI.resetSecret(inboxId);
|
||||||
|
commit(types.default.EDIT_INBOXES, response.data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throwErrorMessage(error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mutations = {
|
export const mutations = {
|
||||||
|
|||||||
@ -2,11 +2,13 @@ class AgentBots::WebhookJob < WebhookJob
|
|||||||
queue_as :high
|
queue_as :high
|
||||||
retry_on RestClient::TooManyRequests, RestClient::InternalServerError, wait: 3.seconds, attempts: 3 do |job, error|
|
retry_on RestClient::TooManyRequests, RestClient::InternalServerError, wait: 3.seconds, attempts: 3 do |job, error|
|
||||||
url, payload, webhook_type = job.arguments
|
url, payload, webhook_type = job.arguments
|
||||||
Webhooks::Trigger.new(url, payload, webhook_type || :agent_bot_webhook).handle_failure(error)
|
kwargs = job.arguments.last.is_a?(Hash) ? job.arguments.last : {}
|
||||||
|
Webhooks::Trigger.new(url, payload, webhook_type || :agent_bot_webhook, secret: kwargs[:secret],
|
||||||
|
delivery_id: kwargs[:delivery_id]).handle_failure(error)
|
||||||
end
|
end
|
||||||
|
|
||||||
def perform(url, payload, webhook_type = :agent_bot_webhook)
|
def perform(url, payload, webhook_type = :agent_bot_webhook, secret: nil, delivery_id: nil)
|
||||||
super(url, payload, webhook_type)
|
super(url, payload, webhook_type, secret: secret, delivery_id: delivery_id)
|
||||||
rescue RestClient::TooManyRequests, RestClient::InternalServerError => e
|
rescue RestClient::TooManyRequests, RestClient::InternalServerError => e
|
||||||
Rails.logger.warn("[AgentBots::WebhookJob] attempt #{executions} failed #{e.class.name} payload=#{payload.to_json}")
|
Rails.logger.warn("[AgentBots::WebhookJob] attempt #{executions} failed #{e.class.name} payload=#{payload.to_json}")
|
||||||
raise
|
raise
|
||||||
|
|||||||
@ -76,6 +76,7 @@ class AgentBotListener < BaseListener
|
|||||||
def process_webhook_bot_event(agent_bot, payload)
|
def process_webhook_bot_event(agent_bot, payload)
|
||||||
return if agent_bot.outgoing_url.blank?
|
return if agent_bot.outgoing_url.blank?
|
||||||
|
|
||||||
AgentBots::WebhookJob.perform_later(agent_bot.outgoing_url, payload)
|
AgentBots::WebhookJob.perform_later(agent_bot.outgoing_url, payload, :agent_bot_webhook,
|
||||||
|
secret: agent_bot.secret, delivery_id: SecureRandom.uuid)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -122,7 +122,7 @@ class WebhookListener < BaseListener
|
|||||||
return if inbox.channel.webhook_url.blank?
|
return if inbox.channel.webhook_url.blank?
|
||||||
|
|
||||||
WebhookJob.perform_later(inbox.channel.webhook_url, payload, :api_inbox_webhook,
|
WebhookJob.perform_later(inbox.channel.webhook_url, payload, :api_inbox_webhook,
|
||||||
delivery_id: SecureRandom.uuid)
|
secret: inbox.channel.secret, delivery_id: SecureRandom.uuid)
|
||||||
end
|
end
|
||||||
|
|
||||||
def deliver_webhook_payloads(payload, inbox)
|
def deliver_webhook_payloads(payload, inbox)
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
# description :string
|
# description :string
|
||||||
# name :string
|
# name :string
|
||||||
# outgoing_url :string
|
# outgoing_url :string
|
||||||
|
# secret :string
|
||||||
# created_at :datetime not null
|
# created_at :datetime not null
|
||||||
# updated_at :datetime not null
|
# updated_at :datetime not null
|
||||||
# account_id :bigint
|
# account_id :bigint
|
||||||
@ -21,6 +22,8 @@ class AgentBot < ApplicationRecord
|
|||||||
include AccessTokenable
|
include AccessTokenable
|
||||||
include Avatarable
|
include Avatarable
|
||||||
|
|
||||||
|
include WebhookSecretable
|
||||||
|
|
||||||
scope :accessible_to, lambda { |account|
|
scope :accessible_to, lambda { |account|
|
||||||
account_id = account&.id
|
account_id = account&.id
|
||||||
where(account_id: [nil, account_id])
|
where(account_id: [nil, account_id])
|
||||||
@ -63,3 +66,5 @@ class AgentBot < ApplicationRecord
|
|||||||
account.nil?
|
account.nil?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
AgentBot.include_mod_with('Audit::AgentBot')
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
# hmac_mandatory :boolean default(FALSE)
|
# hmac_mandatory :boolean default(FALSE)
|
||||||
# hmac_token :string
|
# hmac_token :string
|
||||||
# identifier :string
|
# identifier :string
|
||||||
|
# secret :string
|
||||||
# webhook_url :string
|
# webhook_url :string
|
||||||
# created_at :datetime not null
|
# created_at :datetime not null
|
||||||
# updated_at :datetime not null
|
# updated_at :datetime not null
|
||||||
@ -26,6 +27,7 @@ class Channel::Api < ApplicationRecord
|
|||||||
|
|
||||||
has_secure_token :identifier
|
has_secure_token :identifier
|
||||||
has_secure_token :hmac_token
|
has_secure_token :hmac_token
|
||||||
|
include WebhookSecretable
|
||||||
validate :ensure_valid_agent_reply_time_window
|
validate :ensure_valid_agent_reply_time_window
|
||||||
validates :webhook_url, length: { maximum: Limits::URL_LENGTH_LIMIT }
|
validates :webhook_url, length: { maximum: Limits::URL_LENGTH_LIMIT }
|
||||||
|
|
||||||
|
|||||||
13
app/models/concerns/webhook_secretable.rb
Normal file
13
app/models/concerns/webhook_secretable.rb
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
module WebhookSecretable
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
included do
|
||||||
|
has_secure_token :secret
|
||||||
|
encrypts :secret if Chatwoot.encryption_configured?
|
||||||
|
end
|
||||||
|
|
||||||
|
def reset_secret!
|
||||||
|
regenerate_secret
|
||||||
|
reload
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -22,8 +22,7 @@ class Webhook < ApplicationRecord
|
|||||||
belongs_to :account
|
belongs_to :account
|
||||||
belongs_to :inbox, optional: true
|
belongs_to :inbox, optional: true
|
||||||
|
|
||||||
has_secure_token :secret
|
include WebhookSecretable
|
||||||
encrypts :secret if Chatwoot.encryption_configured?
|
|
||||||
|
|
||||||
validates :account_id, presence: true
|
validates :account_id, presence: true
|
||||||
validates :url, uniqueness: { scope: [:account_id] }, format: URI::DEFAULT_PARSER.make_regexp(%w[http https])
|
validates :url, uniqueness: { scope: [:account_id] }, format: URI::DEFAULT_PARSER.make_regexp(%w[http https])
|
||||||
|
|||||||
@ -26,4 +26,8 @@ class AgentBotPolicy < ApplicationPolicy
|
|||||||
def reset_access_token?
|
def reset_access_token?
|
||||||
@account_user.administrator?
|
@account_user.administrator?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def reset_secret?
|
||||||
|
@account_user.administrator?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -65,4 +65,8 @@ class InboxPolicy < ApplicationPolicy
|
|||||||
def health?
|
def health?
|
||||||
@account_user.administrator?
|
@account_user.administrator?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def reset_secret?
|
||||||
|
@account_user.administrator?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
json.partial! 'api/v1/models/agent_bot', formats: [:json], resource: AgentBotPresenter.new(@agent_bot)
|
||||||
@ -0,0 +1 @@
|
|||||||
|
json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox
|
||||||
@ -7,4 +7,5 @@ json.bot_type resource.bot_type
|
|||||||
json.bot_config resource.bot_config
|
json.bot_config resource.bot_config
|
||||||
json.account_id resource.account_id
|
json.account_id resource.account_id
|
||||||
json.access_token resource.access_token if resource.access_token.present?
|
json.access_token resource.access_token if resource.access_token.present?
|
||||||
|
json.secret resource.secret if !resource.system_bot? && Current.account_user&.administrator?
|
||||||
json.system_bot resource.system_bot?
|
json.system_bot resource.system_bot?
|
||||||
|
|||||||
@ -113,6 +113,7 @@ end
|
|||||||
## API Channel Attributes
|
## API Channel Attributes
|
||||||
if resource.api?
|
if resource.api?
|
||||||
json.hmac_token resource.channel.try(:hmac_token) if Current.account_user&.administrator?
|
json.hmac_token resource.channel.try(:hmac_token) if Current.account_user&.administrator?
|
||||||
|
json.secret resource.channel.try(:secret) if Current.account_user&.administrator?
|
||||||
json.webhook_url resource.channel.try(:webhook_url)
|
json.webhook_url resource.channel.try(:webhook_url)
|
||||||
json.inbox_identifier resource.channel.try(:identifier)
|
json.inbox_identifier resource.channel.try(:identifier)
|
||||||
json.additional_attributes resource.channel.try(:additional_attributes)
|
json.additional_attributes resource.channel.try(:additional_attributes)
|
||||||
|
|||||||
@ -88,6 +88,7 @@ Rails.application.routes.draw do
|
|||||||
resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do
|
resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do
|
||||||
delete :avatar, on: :member
|
delete :avatar, on: :member
|
||||||
post :reset_access_token, on: :member
|
post :reset_access_token, on: :member
|
||||||
|
post :reset_secret, on: :member
|
||||||
end
|
end
|
||||||
resources :contact_inboxes, only: [] do
|
resources :contact_inboxes, only: [] do
|
||||||
collection do
|
collection do
|
||||||
@ -221,6 +222,7 @@ Rails.application.routes.draw do
|
|||||||
post :sync_templates, on: :member
|
post :sync_templates, on: :member
|
||||||
get :health, on: :member
|
get :health, on: :member
|
||||||
post :register_webhook, on: :member
|
post :register_webhook, on: :member
|
||||||
|
post :reset_secret, on: :member
|
||||||
if ChatwootApp.enterprise?
|
if ChatwootApp.enterprise?
|
||||||
resource :conference, only: %i[create destroy], controller: 'conference' do
|
resource :conference, only: %i[create destroy], controller: 'conference' do
|
||||||
get :token, on: :member
|
get :token, on: :member
|
||||||
|
|||||||
5
db/migrate/20260324070820_add_secret_to_agent_bots.rb
Normal file
5
db/migrate/20260324070820_add_secret_to_agent_bots.rb
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
class AddSecretToAgentBots < ActiveRecord::Migration[7.1]
|
||||||
|
def change
|
||||||
|
add_column :agent_bots, :secret, :string
|
||||||
|
end
|
||||||
|
end
|
||||||
5
db/migrate/20260324070828_add_secret_to_channel_api.rb
Normal file
5
db/migrate/20260324070828_add_secret_to_channel_api.rb
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
class AddSecretToChannelApi < ActiveRecord::Migration[7.1]
|
||||||
|
def change
|
||||||
|
add_column :channel_api, :secret, :string
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
class BackfillAgentBotAndChannelApiSecrets < ActiveRecord::Migration[7.1]
|
||||||
|
def up
|
||||||
|
AgentBot.where(secret: nil).find_each do |agent_bot|
|
||||||
|
agent_bot.update!(secret: SecureRandom.urlsafe_base64(24))
|
||||||
|
end
|
||||||
|
|
||||||
|
Channel::Api.where(secret: nil).find_each do |channel|
|
||||||
|
channel.update!(secret: SecureRandom.urlsafe_base64(24))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
# no-op: removing the columns in the previous migrations handles cleanup
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -131,6 +131,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_24_102005) do
|
|||||||
t.bigint "account_id"
|
t.bigint "account_id"
|
||||||
t.integer "bot_type", default: 0
|
t.integer "bot_type", default: 0
|
||||||
t.jsonb "bot_config", default: {}
|
t.jsonb "bot_config", default: {}
|
||||||
|
t.string "secret"
|
||||||
t.index ["account_id"], name: "index_agent_bots_on_account_id"
|
t.index ["account_id"], name: "index_agent_bots_on_account_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -413,6 +414,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_24_102005) do
|
|||||||
t.string "hmac_token"
|
t.string "hmac_token"
|
||||||
t.boolean "hmac_mandatory", default: false
|
t.boolean "hmac_mandatory", default: false
|
||||||
t.jsonb "additional_attributes", default: {}
|
t.jsonb "additional_attributes", default: {}
|
||||||
|
t.string "secret"
|
||||||
t.index ["hmac_token"], name: "index_channel_api_on_hmac_token", unique: true
|
t.index ["hmac_token"], name: "index_channel_api_on_hmac_token", unique: true
|
||||||
t.index ["identifier"], name: "index_channel_api_on_identifier", unique: true
|
t.index ["identifier"], name: "index_channel_api_on_identifier", unique: true
|
||||||
end
|
end
|
||||||
|
|||||||
7
enterprise/app/models/enterprise/audit/agent_bot.rb
Normal file
7
enterprise/app/models/enterprise/audit/agent_bot.rb
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
module Enterprise::Audit::AgentBot
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
included do
|
||||||
|
audited associated_with: :account, except: [:secret]
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -17,7 +17,7 @@ module Enterprise::Channelable
|
|||||||
|
|
||||||
auditable_id = inbox.id
|
auditable_id = inbox.id
|
||||||
auditable_type = 'Inbox'
|
auditable_type = 'Inbox'
|
||||||
audited_changes = saved_changes.except('updated_at')
|
audited_changes = saved_changes.except('updated_at', 'secret')
|
||||||
|
|
||||||
return if audited_changes.blank?
|
return if audited_changes.blank?
|
||||||
|
|
||||||
|
|||||||
@ -25,8 +25,10 @@ describe AgentBotListener do
|
|||||||
context 'when agent bot is configured' do
|
context 'when agent bot is configured' do
|
||||||
it 'sends message to agent bot' do
|
it 'sends message to agent bot' do
|
||||||
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
|
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
|
||||||
expect(AgentBots::WebhookJob).to receive(:perform_later).with(agent_bot.outgoing_url,
|
expect(AgentBots::WebhookJob).to receive(:perform_later).with(
|
||||||
message.webhook_data.merge(event: 'message_created')).once
|
agent_bot.outgoing_url, message.webhook_data.merge(event: 'message_created'),
|
||||||
|
:agent_bot_webhook, secret: agent_bot.secret, delivery_id: instance_of(String)
|
||||||
|
).once
|
||||||
listener.message_created(event)
|
listener.message_created(event)
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -48,8 +50,14 @@ describe AgentBotListener do
|
|||||||
it 'sends message to both bots exactly once' do
|
it 'sends message to both bots exactly once' do
|
||||||
payload = message.webhook_data.merge(event: 'message_created')
|
payload = message.webhook_data.merge(event: 'message_created')
|
||||||
|
|
||||||
expect(AgentBots::WebhookJob).to receive(:perform_later).with(agent_bot.outgoing_url, payload).once
|
expect(AgentBots::WebhookJob).to receive(:perform_later).with(
|
||||||
expect(AgentBots::WebhookJob).to receive(:perform_later).with(conversation_bot.outgoing_url, payload).once
|
agent_bot.outgoing_url, payload, :agent_bot_webhook,
|
||||||
|
secret: agent_bot.secret, delivery_id: instance_of(String)
|
||||||
|
).once
|
||||||
|
expect(AgentBots::WebhookJob).to receive(:perform_later).with(
|
||||||
|
conversation_bot.outgoing_url, payload, :agent_bot_webhook,
|
||||||
|
secret: conversation_bot.secret, delivery_id: instance_of(String)
|
||||||
|
).once
|
||||||
|
|
||||||
listener.message_created(event)
|
listener.message_created(event)
|
||||||
end
|
end
|
||||||
@ -74,9 +82,11 @@ describe AgentBotListener do
|
|||||||
|
|
||||||
it 'sends webhook to the inbox agent bot with changed_attributes' do
|
it 'sends webhook to the inbox agent bot with changed_attributes' do
|
||||||
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
|
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
|
||||||
expect(AgentBots::WebhookJob).to receive(:perform_later).with(agent_bot.outgoing_url,
|
expect(AgentBots::WebhookJob).to receive(:perform_later).with(
|
||||||
conversation.webhook_data.merge(event: 'conversation_updated',
|
agent_bot.outgoing_url,
|
||||||
changed_attributes: nil)).once
|
conversation.webhook_data.merge(event: 'conversation_updated', changed_attributes: nil),
|
||||||
|
:agent_bot_webhook, secret: agent_bot.secret, delivery_id: instance_of(String)
|
||||||
|
).once
|
||||||
listener.conversation_updated(event)
|
listener.conversation_updated(event)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -93,11 +103,14 @@ describe AgentBotListener do
|
|||||||
|
|
||||||
it 'sends webhook with changed_attributes to the assigned agent bot' do
|
it 'sends webhook with changed_attributes to the assigned agent bot' do
|
||||||
expected_changed_attributes = [{ 'assignee_agent_bot_id' => { previous_value: nil, current_value: agent_bot.id } }]
|
expected_changed_attributes = [{ 'assignee_agent_bot_id' => { previous_value: nil, current_value: agent_bot.id } }]
|
||||||
expect(AgentBots::WebhookJob).to receive(:perform_later).with(agent_bot.outgoing_url,
|
expect(AgentBots::WebhookJob).to receive(:perform_later).with(
|
||||||
conversation.webhook_data.merge(
|
agent_bot.outgoing_url,
|
||||||
event: 'conversation_updated',
|
conversation.webhook_data.merge(
|
||||||
changed_attributes: expected_changed_attributes
|
event: 'conversation_updated',
|
||||||
)).once
|
changed_attributes: expected_changed_attributes
|
||||||
|
),
|
||||||
|
:agent_bot_webhook, secret: agent_bot.secret, delivery_id: instance_of(String)
|
||||||
|
).once
|
||||||
listener.conversation_updated(event)
|
listener.conversation_updated(event)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -121,7 +134,8 @@ describe AgentBotListener do
|
|||||||
expect(AgentBots::WebhookJob).to receive(:perform_later)
|
expect(AgentBots::WebhookJob).to receive(:perform_later)
|
||||||
.with(
|
.with(
|
||||||
agent_bot.outgoing_url,
|
agent_bot.outgoing_url,
|
||||||
conversation.contact_inbox.webhook_data.merge(event: 'webwidget_triggered', event_info: { country: 'US' })
|
conversation.contact_inbox.webhook_data.merge(event: 'webwidget_triggered', event_info: { country: 'US' }),
|
||||||
|
:agent_bot_webhook, secret: agent_bot.secret, delivery_id: instance_of(String)
|
||||||
).once
|
).once
|
||||||
|
|
||||||
listener.webwidget_triggered(event)
|
listener.webwidget_triggered(event)
|
||||||
|
|||||||
@ -59,7 +59,7 @@ describe WebhookListener do
|
|||||||
api_event = Events::Base.new(event_name, Time.zone.now, message: api_message)
|
api_event = Events::Base.new(event_name, Time.zone.now, message: api_message)
|
||||||
expect(WebhookJob).to receive(:perform_later).with(
|
expect(WebhookJob).to receive(:perform_later).with(
|
||||||
channel_api.webhook_url, api_message.webhook_data.merge(event: 'message_created'),
|
channel_api.webhook_url, api_message.webhook_data.merge(event: 'message_created'),
|
||||||
:api_inbox_webhook, delivery_id: instance_of(String)
|
:api_inbox_webhook, secret: channel_api.secret, delivery_id: instance_of(String)
|
||||||
).once
|
).once
|
||||||
listener.message_created(api_event)
|
listener.message_created(api_event)
|
||||||
end
|
end
|
||||||
@ -112,7 +112,7 @@ describe WebhookListener do
|
|||||||
expect(WebhookJob).to receive(:perform_later).with(
|
expect(WebhookJob).to receive(:perform_later).with(
|
||||||
channel_api.webhook_url,
|
channel_api.webhook_url,
|
||||||
api_conversation.webhook_data.merge(event: 'conversation_created'),
|
api_conversation.webhook_data.merge(event: 'conversation_created'),
|
||||||
:api_inbox_webhook, delivery_id: instance_of(String)
|
:api_inbox_webhook, secret: channel_api.secret, delivery_id: instance_of(String)
|
||||||
).once
|
).once
|
||||||
listener.conversation_created(api_event)
|
listener.conversation_created(api_event)
|
||||||
end
|
end
|
||||||
@ -348,7 +348,7 @@ describe WebhookListener do
|
|||||||
|
|
||||||
expect(WebhookJob).to receive(:perform_later).with(
|
expect(WebhookJob).to receive(:perform_later).with(
|
||||||
channel_api.webhook_url, payload, :api_inbox_webhook,
|
channel_api.webhook_url, payload, :api_inbox_webhook,
|
||||||
delivery_id: instance_of(String)
|
secret: channel_api.secret, delivery_id: instance_of(String)
|
||||||
).once
|
).once
|
||||||
listener.conversation_typing_on(api_event)
|
listener.conversation_typing_on(api_event)
|
||||||
end
|
end
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user