Compare commits

...
Author SHA1 Message Date
Shivam Mishra 8196936a62 fix: validate bot tokens 2026-06-11 21:35:37 +05:30
Shivam MishraandGitHub b26dea7879 Merge branch 'develop' into feat/read-only-token 2026-06-11 19:38:26 +05:30
Shivam Mishra 85776ec65c fix: inbox jbuilder 2026-06-11 19:37:53 +05:30
Shivam MishraandGitHub e408b5dbbd Merge branch 'develop' into feat/read-only-token 2026-06-11 14:15:39 +05:30
Shivam Mishra c633c35712 feat: hide integration app config when using read only token 2026-06-11 14:14:34 +05:30
Shivam MishraandGitHub 40432499c8 Merge branch 'develop' into feat/read-only-token 2026-06-10 16:39:19 +05:30
Shivam MishraandGitHub 72d0e91d3c Merge branch 'develop' into feat/read-only-token 2026-06-08 18:08:12 +05:30
Shivam MishraandGitHub ca5ef95db7 Merge branch 'develop' into feat/read-only-token 2026-06-03 12:05:54 +05:30
Shivam Mishra eedffccf6d feat: withold pubsub token for readonly token 2026-06-02 17:38:43 +05:30
Shivam MishraandGitHub 134eb157cc Merge branch 'develop' into feat/read-only-token 2026-06-02 15:07:53 +05:30
Shivam MishraandGitHub c7c4da35f6 Merge branch 'develop' into feat/read-only-token 2026-06-02 14:33:28 +05:30
Shivam MishraandGitHub 57a5d7e64e Merge branch 'develop' into feat/read-only-token 2026-06-01 13:36:27 +05:30
Shivam Mishra eef3a6fefc feat: block voice conference token from read only token 2026-06-01 13:36:14 +05:30
Shivam MishraandGitHub cb2061f260 Merge branch 'develop' into feat/read-only-token 2026-05-29 19:04:31 +05:30
Shivam Mishra 2e30a69a18 feat: block portal ssl status from read only token 2026-05-29 16:54:20 +05:30
Shivam Mishra 473d1a6c22 feat: hide captain custom tool auth config when using read only token 2026-05-29 16:22:14 +05:30
Shivam Mishra 4efb421800 feat: hide integration hook settings when using read only token 2026-05-29 16:22:13 +05:30
Shivam Mishra 54b86939d2 feat: hide webhook secret when using read only token 2026-05-29 16:18:55 +05:30
Shivam Mishra 49ac1557c1 feat: hide inbox channel provider credentials when using read only token 2026-05-29 16:18:54 +05:30
Shivam Mishra 45bd3f0fc7 feat: hide inbox channel credentials when using read only token 2026-05-29 16:10:18 +05:30
Shivam MishraandGitHub 5db4bb963b Merge branch 'develop' into feat/read-only-token 2026-05-29 14:45:23 +05:30
Shivam Mishra bd780b4cc6 feat: hide agent bot access_token when using read only token 2026-05-27 17:21:53 +05:30
Shivam Mishra 2f607f67c4 refactor: align conversation direct uploads with api token auth chain 2026-05-27 17:18:33 +05:30
Shivam Mishra ae676077ed test: readonly token can access context inbox filter 2026-05-27 14:53:18 +05:30
Shivam MishraandGitHub a31fb0f906 Merge branch 'develop' into feat/read-only-token 2026-05-27 14:52:49 +05:30
Shivam Mishra c0c12f2fbd chore: allow contact_inboxes filter 2026-05-27 14:52:27 +05:30
Shivam Mishra 7333cdd79f fix: scope user access token serialization 2026-05-27 13:50:41 +05:30
Shivam MishraandGitHub b9e83ec2d4 Merge branch 'develop' into feat/read-only-token 2026-05-26 18:55:25 +05:30
Shivam Mishra f3b990997e refactor: make access token generation race-safe 2026-05-26 12:34:34 +05:30
Shivam Mishra f778057b18 refactor: reuse access token scope helper for direct uploads 2026-05-26 12:32:27 +05:30
Shivam Mishra 1987abf9e2 chore: reset access token changes 2026-05-25 16:41:44 +05:30
Shivam Mishra a1c79f90b1 fix: width 2026-05-25 16:31:34 +05:30
Shivam Mishra 8f8cd7b929 fix: width 2026-05-25 16:28:33 +05:30
Shivam Mishra 48d8960539 feat: allow creating fresh token 2026-05-25 16:25:30 +05:30
Shivam Mishra c816e6b330 feat: add token list 2026-05-25 16:24:58 +05:30
Shivam Mishra b0aa51e811 chore: update migration 2026-05-20 17:47:26 +05:30
Shivam Mishra 7c73981ddd test: profile token serialization across auth modes 2026-05-20 13:44:51 +05:30
Shivam Mishra fe944720a8 refactor: tighten read-only access token scope enforcement 2026-05-20 13:38:33 +05:30
Shivam Mishra d2de7bed6c test: token creation 2026-05-20 13:22:08 +05:30
Shivam Mishra 5d8fce92f7 feat: expose read-only access token in profile settings 2026-05-20 13:19:58 +05:30
Shivam Mishra e195f9842f feat: gate write operations behind read-only access token scope 2026-05-20 13:19:49 +05:30
Shivam Mishra b96c3df19f feat: add scoped access token model for read-only API access 2026-05-20 13:19:37 +05:30
39 changed files with 565 additions and 52 deletions
+38
View File
@@ -1,9 +1,28 @@
class Api::BaseController < ApplicationController
include AccessTokenAuthHelper
# POST-shaped endpoints that are semantically reads and should be reachable
# with a read-only token. Keep this list minimal; expand only when a clear
# read-with-complex-body use case appears.
READ_ONLY_POST_ALLOWLIST = %w[
api/v1/accounts/conversations#filter
api/v1/accounts/contacts#filter
api/v1/accounts/contact_inboxes#filter
].freeze
# GET-shaped endpoints that mutate state and must be blocked for read-only
# tokens despite the verb being "safe". Add any new write-on-GET endpoint
# here when it ships.
READ_ONLY_BLOCKED_GET_ACTIONS = %w[
api/v1/accounts/callbacks#register_facebook_page
api/v1/accounts/portals#ssl_status
api/v1/accounts/conference#token
].freeze
respond_to :json
before_action :authenticate_access_token!, if: :authenticate_by_access_token?
before_action :validate_bot_access_token!, if: :authenticate_by_access_token?
before_action :authenticate_user!, unless: :authenticate_by_access_token?
before_action :enforce_read_only_token_scope
private
@@ -11,6 +30,25 @@ class Api::BaseController < ApplicationController
request.headers[:api_access_token].present? || request.headers[:HTTP_API_ACCESS_TOKEN].present?
end
def enforce_read_only_token_scope
return unless @access_token&.scope == 'read_only'
return if read_only_token_action_allowed?
render json: { error: 'This access token is read-only and cannot perform write operations.' },
status: :forbidden
end
def read_only_token_action_allowed?
action_key = "#{params[:controller]}##{params[:action]}"
return READ_ONLY_POST_ALLOWLIST.include?(action_key) unless safe_http_method?
READ_ONLY_BLOCKED_GET_ACTIONS.exclude?(action_key)
end
def safe_http_method?
request.get? || request.head? || request.options?
end
def check_authorization(model = nil)
model ||= controller_name.classify.constantize
@@ -1,5 +1,10 @@
class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage::DirectUploadsController
include EnsureCurrentAccountHelper
include AccessTokenAuthHelper
include RequestExceptionHandler
before_action :authenticate_access_token!
before_action :validate_bot_access_token!
before_action :prevent_read_only_access_token!
before_action :current_account
before_action :conversation
@@ -39,7 +39,14 @@ class Api::V1::ProfilesController < Api::BaseController
end
def reset_access_token
@user.access_token.regenerate_token
token = AccessToken.create_or_find_by!(owner: @user, scope: 'full')
token.regenerate_token
@user.reload
end
def reset_read_only_access_token
token = AccessToken.create_or_find_by!(owner: @user, scope: 'read_only')
token.regenerate_token
@user.reload
end
@@ -37,4 +37,15 @@ module AccessTokenAuthHelper
def agent_bot_accessible?
BOT_ACCESSIBLE_ENDPOINTS.fetch(params[:controller], []).include?(params[:action])
end
# Blocks read-only access tokens from reaching write endpoints that live outside
# Api::BaseController (e.g. controllers inheriting from ActiveStorage). Resolves
# the token directly since these controllers skip the Api::BaseController chain.
def prevent_read_only_access_token!
ensure_access_token
return unless @access_token&.scope == 'read_only'
render json: { error: 'This access token is read-only and cannot perform write operations.' },
status: :forbidden
end
end
+4
View File
@@ -106,4 +106,8 @@ export default {
const urlData = endPoints('resetAccessToken');
return axios.post(urlData.url);
},
resetReadOnlyAccessToken() {
const urlData = endPoints('resetReadOnlyAccessToken');
return axios.post(urlData.url);
},
};
@@ -55,6 +55,10 @@ const endPoints = {
resetAccessToken: {
url: '/api/v1/profile/reset_access_token',
},
resetReadOnlyAccessToken: {
url: '/api/v1/profile/reset_read_only_access_token',
},
};
export default page => {
@@ -20,6 +20,9 @@ const FEATURE_HELP_URLS = {
billing: 'https://chwt.app/pricing',
saml: 'https://chwt.app/hc/saml',
captain_billing: 'https://chwt.app/hc/captain_billing',
access_token_api:
'https://developers.chatwoot.com/api-reference/introduction',
chatwoot_cli: 'https://developers.chatwoot.com/cli',
};
export function getHelpUrlForFeature(featureName) {
@@ -91,10 +91,33 @@
"NOTE": "This token can be used if you are building an API based integration",
"COPY": "Copy",
"RESET": "Reset",
"GENERATE": "Generate",
"CREATE": "Create token",
"CONFIRM_RESET": "Are you sure?",
"CONFIRM_HINT": "Click again to confirm",
"TABLE": {
"TOKEN": "Token",
"PERMISSION": "Permission"
},
"EMPTY": {
"TITLE": "No tokens yet",
"DESCRIPTION": "Create your first access token to start building integrations against the API."
},
"SCOPES": {
"FULL_LABEL": "Full access",
"READ_ONLY_LABEL": "Read only"
},
"LINKS": {
"API_REFERENCE": "API Reference",
"CLI": "Chatwoot CLI"
},
"RESET_SUCCESS": "Access token regenerated successfully",
"RESET_ERROR": "Unable to regenerate access token. Please try again"
"RESET_ERROR": "Unable to regenerate access token. Please try again",
"READ_ONLY_TITLE": "Read-only Access Token",
"READ_ONLY_NOTE": "Use this token for integrations that only need to read data. It cannot create, update, or delete anything.",
"READ_ONLY_GENERATE_SUCCESS": "Read-only access token generated successfully",
"READ_ONLY_RESET_SUCCESS": "Read-only access token regenerated successfully",
"READ_ONLY_RESET_ERROR": "Unable to regenerate read-only access token. Please try again"
},
"AUDIO_NOTIFICATIONS_SECTION": {
"TITLE": "Audio Alerts",
@@ -45,6 +45,7 @@ const { t } = useI18n();
>
<slot name="description">{{ description }}</slot>
</p>
<slot name="links" />
</div>
<div class="col-span-1">
<slot name="headerActions" />
@@ -8,6 +8,10 @@ import { clearCookiesOnLogout } from 'dashboard/store/utils/api.js';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import { parseBoolean } from '@chatwoot/utils';
import { OnClickOutside } from '@vueuse/components';
import { getHelpUrlForFeature } from 'dashboard/helper/featureHelper';
import NextButton from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import UserProfilePicture from './UserProfilePicture.vue';
import UserBasicDetails from './UserBasicDetails.vue';
import MessageSignature from './MessageSignature.vue';
@@ -18,7 +22,7 @@ import NotificationPreferences from './NotificationPreferences.vue';
import AudioNotifications from './AudioNotifications.vue';
import SectionLayout from '../account/components/SectionLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import AccessToken from './AccessToken.vue';
import TokenList from './TokenList.vue';
import MfaSettingsCard from './MfaSettingsCard.vue';
import Policy from 'dashboard/components/policy.vue';
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
@@ -40,9 +44,12 @@ export default {
ChangePassword,
NotificationPreferences,
AudioNotifications,
AccessToken,
TokenList,
MfaSettingsCard,
BaseSettingsHeader,
OnClickOutside,
NextButton,
DropdownMenu,
},
setup() {
const { isEditorHotKeyEnabled, updateUISettings } = useUISettings();
@@ -92,6 +99,7 @@ export default {
],
notificationPermissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
audioNotificationPermissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
isCreateTokenMenuOpen: false,
};
},
computed: {
@@ -103,6 +111,41 @@ export default {
isMfaEnabled() {
return parseBoolean(window.chatwootConfig?.isMfaEnabled);
},
accessTokens() {
return [
{ scope: 'full', value: this.currentUser.access_token },
{ scope: 'read_only', value: this.currentUser.read_only_access_token },
].filter(token => token.value);
},
apiReferenceUrl() {
return getHelpUrlForFeature('access_token_api');
},
cliUrl() {
return getHelpUrlForFeature('chatwoot_cli');
},
createTokenMenuItems() {
// Each user holds at most one token per scope, so only offer scopes
// that don't have a token yet.
const presentScopes = this.accessTokens.map(token => token.scope);
return [
{
label: this.$t(
'PROFILE_SETTINGS.FORM.ACCESS_TOKEN.SCOPES.FULL_LABEL'
),
icon: 'i-lucide-shield-check',
value: 'full',
},
{
label: this.$t(
'PROFILE_SETTINGS.FORM.ACCESS_TOKEN.SCOPES.READ_ONLY_LABEL'
),
icon: 'i-lucide-eye',
value: 'read_only',
},
]
.filter(item => !presentScopes.includes(item.value))
.map(item => ({ ...item, action: 'create' }));
},
},
mounted() {
if (this.currentUserId) {
@@ -192,6 +235,17 @@ export default {
await copyTextToClipboard(value);
useAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
},
onCreateToken({ value }) {
this.isCreateTokenMenuOpen = false;
this.resetToken(value);
},
async resetToken(scope) {
if (scope === 'read_only') {
await this.resetReadOnlyAccessToken();
} else {
await this.resetAccessToken();
}
},
async resetAccessToken() {
const success = await this.$store.dispatch('resetAccessToken');
if (success) {
@@ -200,6 +254,25 @@ export default {
useAlert(this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.RESET_ERROR'));
}
},
async resetReadOnlyAccessToken() {
const hadToken = Boolean(this.currentUser.read_only_access_token);
const success = await this.$store.dispatch('resetReadOnlyAccessToken');
if (success && hadToken) {
useAlert(
this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.READ_ONLY_RESET_SUCCESS')
);
} else if (success) {
useAlert(
this.$t(
'PROFILE_SETTINGS.FORM.ACCESS_TOKEN.READ_ONLY_GENERATE_SUCCESS'
)
);
} else {
useAlert(
this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.READ_ONLY_RESET_ERROR')
);
}
},
},
};
</script>
@@ -334,10 +407,52 @@ export default {
replaceInstallationName($t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.NOTE'))
"
>
<AccessToken
:value="currentUser.access_token"
@on-copy="onCopyToken"
@on-reset="resetAccessToken"
<template #links>
<div class="flex items-center gap-2 mt-2 text-xs">
<a
:href="apiReferenceUrl"
target="_blank"
rel="noopener noreferrer"
class="text-body-main text-n-blue-11 hover:underline"
>
{{ $t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.LINKS.API_REFERENCE') }}
</a>
<span class="w-px h-3 bg-n-slate-6" aria-hidden="true" />
<a
:href="cliUrl"
target="_blank"
rel="noopener noreferrer"
class="text-body-main text-n-blue-11 hover:underline"
>
{{ $t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.LINKS.CLI') }}
</a>
</div>
</template>
<template v-if="createTokenMenuItems.length" #headerActions>
<OnClickOutside @trigger="isCreateTokenMenuOpen = false">
<div class="relative flex justify-end">
<NextButton
:label="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.CREATE')"
icon="i-lucide-plus"
size="sm"
blue
solid
class="rounded-lg"
@click="isCreateTokenMenuOpen = !isCreateTokenMenuOpen"
/>
<DropdownMenu
v-if="isCreateTokenMenuOpen"
:menu-items="createTokenMenuItems"
class="ltr:right-0 rtl:left-0 top-10"
@action="onCreateToken"
/>
</div>
</OnClickOutside>
</template>
<TokenList
:tokens="accessTokens"
@copy="onCopyToken"
@reset="resetToken"
/>
</SectionLayout>
</div>
@@ -0,0 +1,141 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import NextButton from 'dashboard/components-next/button/Button.vue';
import ConfirmButton from 'dashboard/components-next/button/ConfirmButton.vue';
defineProps({
tokens: {
// [{ scope: 'full' | 'read_only', value: String }]
type: Array,
default: () => [],
},
});
const emit = defineEmits(['copy', 'reset']);
const { t } = useI18n();
const tk = key => t(`PROFILE_SETTINGS.FORM.ACCESS_TOKEN.${key}`);
const SCOPE_META = {
full: {
label: () => tk('SCOPES.FULL_LABEL'),
icon: 'i-lucide-shield-check',
badgeClass: 'bg-n-teal-3 text-n-teal-11',
},
read_only: {
label: () => tk('SCOPES.READ_ONLY_LABEL'),
icon: 'i-lucide-eye',
badgeClass: 'bg-n-alpha-2 text-n-slate-11 outline outline-1 outline-n-weak',
},
};
// track reveal state per scope
const revealedScopes = ref([]);
const isRevealed = scope => revealedScopes.value.includes(scope);
const toggleReveal = scope => {
revealedScopes.value = isRevealed(scope)
? revealedScopes.value.filter(s => s !== scope)
: [...revealedScopes.value, scope];
};
const maskToken = value =>
`${value.slice(0, 4)}${'•'.repeat(20)}${value.slice(-4)}`;
</script>
<template>
<div class="overflow-hidden rounded-xl outline outline-1 outline-n-weak">
<div
v-if="!tokens.length"
class="flex flex-col items-center justify-center gap-1 px-4 py-12 text-center"
>
<span
class="grid mb-2 rounded-lg size-10 place-items-center bg-n-alpha-1 text-n-slate-11"
>
<span class="i-lucide-key-round size-5" />
</span>
<p class="text-sm font-medium text-n-slate-12">
{{ tk('EMPTY.TITLE') }}
</p>
<p class="max-w-xs text-sm text-n-slate-11">
{{ tk('EMPTY.DESCRIPTION') }}
</p>
</div>
<table v-else class="min-w-full text-sm border-collapse">
<thead>
<tr class="bg-n-alpha-1 text-n-slate-11">
<th
class="px-4 py-2.5 font-medium text-start text-xs uppercase tracking-wide"
>
{{ tk('TABLE.TOKEN') }}
</th>
<th
class="px-4 py-2.5 font-medium text-start text-xs uppercase tracking-wide"
>
{{ tk('TABLE.PERMISSION') }}
</th>
<th class="px-4 py-2.5 w-40" />
</tr>
</thead>
<tbody class="divide-y divide-n-weak">
<tr
v-for="token in tokens"
:key="token.scope"
class="border-t border-n-weak"
>
<td class="px-4 py-3">
<div class="flex items-center gap-1.5 min-w-0">
<code
class="inline-block min-w-56 px-2 py-1 font-mono text-xs rounded-md bg-n-alpha-1 text-n-slate-12 outline outline-1 outline-n-weak truncate align-middle"
>
{{
isRevealed(token.scope) ? token.value : maskToken(token.value)
}}
</code>
<NextButton
:icon="
isRevealed(token.scope) ? 'i-lucide-eye-off' : 'i-lucide-eye'
"
size="xs"
slate
ghost
class="shrink-0"
@click="toggleReveal(token.scope)"
/>
<NextButton
icon="i-lucide-copy"
size="xs"
slate
ghost
class="shrink-0"
@click="emit('copy', token.value)"
/>
</div>
</td>
<td class="px-4 py-3">
<span
class="inline-flex items-center gap-1.5 px-2 py-0.5 text-xs font-medium rounded-full"
:class="SCOPE_META[token.scope].badgeClass"
>
<span :class="SCOPE_META[token.scope].icon" class="size-3" />
{{ SCOPE_META[token.scope].label() }}
</span>
</td>
<td class="px-4 py-3 text-end">
<ConfirmButton
:label="tk('RESET')"
:confirm-label="tk('CONFIRM_RESET')"
color="slate"
confirm-color="ruby"
variant="outline"
size="xs"
icon="i-lucide-key-round"
@click="emit('reset', token.scope)"
/>
</td>
</tr>
</tbody>
</table>
</div>
</template>
@@ -233,6 +233,16 @@ export const actions = {
}
},
resetReadOnlyAccessToken: async ({ commit }) => {
try {
const response = await authAPI.resetReadOnlyAccessToken();
commit(types.SET_CURRENT_USER, response.data);
return true;
} catch (error) {
return false;
}
},
resendConfirmation: async () => {
try {
await authAPI.resendConfirmation();
+9
View File
@@ -4,6 +4,7 @@
#
# id :bigint not null, primary key
# owner_type :string
# scope :string default("full"), not null
# token :string
# created_at :datetime not null
# updated_at :datetime not null
@@ -11,11 +12,19 @@
#
# Indexes
#
# index_access_tokens_on_owner_and_scope (owner_type,owner_id,scope) UNIQUE
# index_access_tokens_on_owner_type_and_owner_id (owner_type,owner_id)
# index_access_tokens_on_token (token) UNIQUE
#
class AccessToken < ApplicationRecord
# Schema-wise this table can hold any number of tokens per owner, but we
# intentionally cap user-facing tokens at two: one `full` and one `read_only`.
# The User model's has_one associations + auto-create callbacks enforce the
# 1+1 contract; this validation only constrains the allowed scope values.
SCOPES = %w[full read_only].freeze
has_secure_token :token
belongs_to :owner, polymorphic: true
validates :scope, inclusion: { in: SCOPES }
end
+7 -2
View File
@@ -1,11 +1,16 @@
module AccessTokenable
extend ActiveSupport::Concern
# AccessToken is polymorphic and the table allows many rows per owner. By
# convention we expose at most two per User (one `full`, one `read_only`);
# AgentBot and PlatformApp keep their single `full` token. The scoped
# has_one below is what makes `owner.access_token` deterministic.
included do
has_one :access_token, as: :owner, dependent: :destroy_async
has_one :access_token, -> { where(scope: 'full') },
as: :owner, class_name: 'AccessToken', inverse_of: :owner, dependent: :destroy_async
after_create :create_access_token
end
def create_access_token
AccessToken.create!(owner: self)
AccessToken.create!(owner: self, scope: 'full')
end
end
+12
View File
@@ -84,6 +84,13 @@ class User < ApplicationRecord
encrypts :otp_secret, deterministic: true
encrypts :otp_backup_codes
# A user has exactly two AccessTokens by product contract: the `full` one
# from AccessTokenable, plus the `read_only` one declared here. The DB does
# not enforce this cap (polymorphic owner_id allows many rows); we rely on
# these scoped has_one + after_create pairs to keep the 1+1 invariant.
has_one :read_only_access_token, -> { where(scope: 'read_only') },
as: :owner, class_name: 'AccessToken', inverse_of: :owner, dependent: :destroy_async
has_many :account_users, dependent: :destroy_async
has_many :accounts, through: :account_users
accepts_nested_attributes_for :account_users
@@ -117,6 +124,7 @@ class User < ApplicationRecord
# rubocop:enable Rails/HasManyOrHasOneDependent
before_validation :set_password_and_uid, on: :create
after_create :create_read_only_access_token
after_destroy :remove_macros
scope :order_by_full_name, -> { order('lower(name) ASC') }
@@ -133,6 +141,10 @@ class User < ApplicationRecord
self.uid = email
end
def create_read_only_access_token
AccessToken.create!(owner: self, scope: 'read_only')
end
def assigned_inboxes
administrator? ? Current.account.inboxes : inboxes.where(account_id: Current.account.id)
end
@@ -3,7 +3,7 @@ json.name webhook.name
json.url webhook.url
json.account_id webhook.account_id
json.subscriptions webhook.subscriptions
json.secret webhook.secret
json.secret webhook.secret if @access_token&.scope != 'read_only'
if webhook.inbox
json.inbox do
json.id webhook.inbox.id
@@ -6,6 +6,6 @@ json.outgoing_url resource.outgoing_url unless resource.system_bot?
json.bot_type resource.bot_type
json.bot_config resource.bot_config
json.account_id resource.account_id
json.access_token resource.access_token if resource.access_token.present?
json.secret resource.secret if !resource.system_bot? && Current.account_user&.administrator?
json.access_token resource.access_token if resource.access_token.present? && @access_token&.scope != 'read_only'
json.secret resource.secret if !resource.system_bot? && Current.account_user&.administrator? && @access_token&.scope != 'read_only'
json.system_bot resource.system_bot?
+1 -1
View File
@@ -4,7 +4,7 @@ json.description resource.description
json.short_description resource.short_description.presence
json.enabled resource.enabled?(@current_account)
if Current.account_user&.administrator?
if Current.account_user&.administrator? && @access_token&.scope != 'read_only'
json.call(resource.params, *resource.params.keys)
json.action resource.action
json.button resource.action
+1 -1
View File
@@ -5,5 +5,5 @@ json.inbox resource.inbox&.slice(:id, :name)
json.account_id resource.account_id
json.hook_type resource.hook_type
json.settings resource.settings if Current.account_user&.administrator?
json.settings resource.settings if Current.account_user&.administrator? && @access_token&.scope != 'read_only'
json.reference_id resource.reference_id if Current.account_user&.administrator?
+7 -7
View File
@@ -44,7 +44,7 @@ json.website_token resource.channel.try(:website_token)
json.selected_feature_flags resource.channel.try(:selected_feature_flags)
json.reply_time resource.channel.try(:reply_time)
if resource.web_widget?
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? && @access_token&.scope != 'read_only'
json.pre_chat_form_enabled resource.channel.try(:pre_chat_form_enabled)
json.pre_chat_form_options resource.channel.try(:pre_chat_form_options)
json.continuity_via_email resource.channel.try(:continuity_via_email)
@@ -69,7 +69,7 @@ json.phone_number resource.channel.try(:phone_number)
json.medium resource.channel.try(:medium) if resource.twilio?
if resource.twilio?
json.content_templates resource.channel.try(:content_templates)
if Current.account_user&.administrator?
if Current.account_user&.administrator? && @access_token&.scope != 'read_only'
json.auth_token resource.channel.try(:auth_token)
json.account_sid resource.channel.try(:account_sid)
json.api_key_sid resource.channel.try(:api_key_sid)
@@ -85,7 +85,7 @@ if resource.email?
## IMAP
if Current.account_user&.administrator?
json.imap_login resource.channel.try(:imap_login)
json.imap_password resource.channel.try(:imap_password)
json.imap_password resource.channel.try(:imap_password) if @access_token&.scope != 'read_only'
json.imap_address resource.channel.try(:imap_address)
json.imap_port resource.channel.try(:imap_port)
json.imap_enabled resource.channel.try(:imap_enabled)
@@ -100,7 +100,7 @@ if resource.email?
## SMTP
if Current.account_user&.administrator?
json.smtp_login resource.channel.try(:smtp_login)
json.smtp_password resource.channel.try(:smtp_password)
json.smtp_password resource.channel.try(:smtp_password) if @access_token&.scope != 'read_only'
json.smtp_address resource.channel.try(:smtp_address)
json.smtp_port resource.channel.try(:smtp_port)
json.smtp_enabled resource.channel.try(:smtp_enabled)
@@ -114,8 +114,8 @@ end
## API Channel Attributes
if resource.api?
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.hmac_token resource.channel.try(:hmac_token) if Current.account_user&.administrator? && @access_token&.scope != 'read_only'
json.secret resource.channel.try(:secret) if Current.account_user&.administrator? && @access_token&.scope != 'read_only'
json.webhook_url resource.channel.try(:webhook_url)
json.inbox_identifier resource.channel.try(:identifier)
json.additional_attributes resource.channel.try(:additional_attributes)
@@ -129,7 +129,7 @@ json.bot_name resource.channel.try(:bot_name) if resource.telegram?
### WhatsApp Channel
if resource.whatsapp?
json.message_templates resource.channel.try(:message_templates)
json.provider_config resource.channel.try(:provider_config) if Current.account_user&.administrator?
json.provider_config resource.channel.try(:provider_config) if Current.account_user&.administrator? && @access_token&.scope != 'read_only'
# Only show reauthorization for embedded signup; manual flow uses API keys, not OAuth
json.reauthorization_required(
(resource.channel.try(:provider_config) || {}).to_h['source'] == 'embedded_signup' &&
+11 -2
View File
@@ -1,4 +1,10 @@
json.access_token resource.access_token.token
if local_assigns[:include_access_tokens]
# Withhold the full-scope token from callers who authenticated with a
# read-only token — otherwise GET /api/v1/profile would let a read-only
# holder lift the full token and bypass the scope gate entirely.
json.access_token resource.access_token&.token unless @access_token&.scope == 'read_only'
json.read_only_access_token resource.read_only_access_token&.token
end
json.account_id resource.active_account_user&.account_id
json.available_name resource.available_name
json.avatar_url resource.avatar_url
@@ -11,7 +17,10 @@ json.id resource.id
json.inviter_id resource.active_account_user&.inviter_id
json.name resource.name
json.provider resource.provider
json.pubsub_token resource.pubsub_token
# Withhold the pubsub token from read-only token holders — it authenticates
# RoomChannel without any API-token scope check, letting a caller write presence
# and stream live account events (a write the read-only scope must not grant).
json.pubsub_token resource.pubsub_token unless @access_token&.scope == 'read_only'
json.custom_attributes resource.custom_attributes if resource.custom_attributes.present?
json.role resource.active_account_user&.role
json.ui_settings resource.ui_settings
@@ -1 +1 @@
json.partial! 'api/v1/models/user', formats: [:json], resource: @user
json.partial! 'api/v1/models/user', formats: [:json], resource: @user, include_access_tokens: true
@@ -1 +1 @@
json.partial! 'api/v1/models/user', formats: [:json], resource: @user
json.partial! 'api/v1/models/user', formats: [:json], resource: @user, include_access_tokens: true
@@ -1 +1 @@
json.partial! 'api/v1/models/user', formats: [:json], resource: @user
json.partial! 'api/v1/models/user', formats: [:json], resource: @user, include_access_tokens: true
@@ -1 +1 @@
json.partial! 'api/v1/models/user', formats: [:json], resource: @user
json.partial! 'api/v1/models/user', formats: [:json], resource: @user, include_access_tokens: true
@@ -0,0 +1 @@
json.partial! 'api/v1/models/user', formats: [:json], resource: @user, include_access_tokens: true
+1 -1
View File
@@ -1 +1 @@
json.partial! 'api/v1/models/user', formats: [:json], resource: @user
json.partial! 'api/v1/models/user', formats: [:json], resource: @user, include_access_tokens: true
@@ -1 +1 @@
json.partial! 'api/v1/models/user', formats: [:json], resource: @user
json.partial! 'api/v1/models/user', formats: [:json], resource: @user, include_access_tokens: true
+1 -1
View File
@@ -1,3 +1,3 @@
json.data do
json.partial! 'api/v1/models/user', formats: [:json], resource: resource
json.partial! 'api/v1/models/user', formats: [:json], resource: resource, include_access_tokens: true
end
+1
View File
@@ -429,6 +429,7 @@ Rails.application.routes.draw do
put :set_active_account
post :resend_confirmation
post :reset_access_token
post :reset_read_only_access_token
end
# MFA routes
@@ -0,0 +1,8 @@
class AddScopeToAccessTokens < ActiveRecord::Migration[7.1]
def change
add_column :access_tokens, :scope, :string, default: 'full', null: false
add_index :access_tokens, [:owner_type, :owner_id, :scope],
unique: true,
name: 'index_access_tokens_on_owner_and_scope'
end
end
+2
View File
@@ -24,6 +24,8 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_04_000000) do
t.string "token"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "scope", default: "full", null: false
t.index ["owner_type", "owner_id", "scope"], name: "index_access_tokens_on_owner_and_scope", unique: true
t.index ["owner_type", "owner_id"], name: "index_access_tokens_on_owner_type_and_owner_id"
t.index ["token"], name: "index_access_tokens_on_token", unique: true
end
@@ -7,7 +7,7 @@ json.http_method custom_tool.http_method
json.request_template custom_tool.request_template
json.response_template custom_tool.response_template
json.auth_type custom_tool.auth_type
json.auth_config custom_tool.auth_config if Current.user&.administrator?
json.auth_config custom_tool.auth_config if Current.user&.administrator? && @access_token&.scope != 'read_only'
json.param_schema custom_tool.param_schema
json.enabled custom_tool.enabled
json.account_id custom_tool.account_id
@@ -54,6 +54,19 @@ RSpec.describe 'Agent Bot API', type: :request do
expect(account_bot_response).to include('thumbnail')
end
end
context 'when authenticated via a read-only api_access_token' do
it 'lists the bots but redacts their access tokens to prevent write escalation' do
get "/api/v1/accounts/#{account.id}/agent_bots",
headers: { api_access_token: admin.read_only_access_token.token },
as: :json
expect(response).to have_http_status(:success)
expect(response.body).to include(agent_bot.name)
expect(response.body).not_to include(agent_bot.access_token.token)
expect(response.parsed_body.first).not_to have_key('access_token')
end
end
end
describe 'GET /api/v1/accounts/{account.id}/agent_bots/:id' do
@@ -67,5 +67,17 @@ RSpec.describe 'Contact Inboxes API', type: :request do
expect(response).to have_http_status(:success)
end
end
context 'when authenticated via a read-only api_access_token' do
it 'returns the contact since the filter lookup is read-only' do
post "/api/v1/accounts/#{account.id}/contact_inboxes/filter",
headers: { api_access_token: admin.read_only_access_token.token },
params: { inbox_id: inbox.id, source_id: contact_inbox.source_id },
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['id']).to eq(contact.id)
end
end
end
end
@@ -7,27 +7,58 @@ RSpec.describe '/api/v1/accounts/:account_id/conversations/:conversation_id/dire
let(:contact) { create(:contact, account: account, email: nil) }
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: web_widget.inbox) }
let(:conversation) { create(:conversation, contact: contact, account: account, inbox: web_widget.inbox, contact_inbox: contact_inbox) }
let(:blob_params) do
{
blob: {
filename: 'avatar.png',
byte_size: '1234',
checksum: 'dsjbsdhbfif3874823mnsdbf',
content_type: 'image/png'
}
}
end
def upload(headers: {})
post api_v1_account_conversation_direct_uploads_path(account_id: account.id, conversation_id: conversation.display_id),
params: blob_params,
headers: headers,
as: :json
end
describe 'POST /api/v1/accounts/:account_id/conversations/:conversation_id/direct_uploads' do
context 'when post request is made' do
it 'creates attachment message in conversation' do
contact
post api_v1_account_conversation_direct_uploads_path(account_id: account.id, conversation_id: conversation.display_id),
params: {
blob: {
filename: 'avatar.png',
byte_size: '1234',
checksum: 'dsjbsdhbfif3874823mnsdbf',
content_type: 'image/png'
}
},
headers: { api_access_token: agent.access_token.token },
as: :json
context 'with a valid full-scope access token' do
it 'creates the direct upload blob' do
expect { upload(headers: { api_access_token: agent.access_token.token }) }
.to change(ActiveStorage::Blob, :count).by(1)
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['content_type']).to eq('image/png')
expect(response.parsed_body['content_type']).to eq('image/png')
end
end
context 'without an access token' do
it 'returns unauthorized without creating a blob' do
expect { upload }.not_to change(ActiveStorage::Blob, :count)
expect(response).to have_http_status(:unauthorized)
end
end
context 'with an invalid access token' do
it 'returns unauthorized without creating a blob' do
expect { upload(headers: { api_access_token: 'invalid-token' }) }
.not_to change(ActiveStorage::Blob, :count)
expect(response).to have_http_status(:unauthorized)
end
end
context 'with a read-only access token' do
it 'returns forbidden without creating a blob' do
expect { upload(headers: { api_access_token: agent.read_only_access_token.token }) }
.not_to change(ActiveStorage::Blob, :count)
expect(response).to have_http_status(:forbidden)
end
end
end
@@ -30,6 +30,46 @@ RSpec.describe 'Profile API', type: :request do
expect(json_response['message_signature']).to be_nil
end
end
context 'when authenticated via a full-scope api_access_token' do
let(:agent) { create(:user, account: account, role: :agent) }
it 'returns both tokens in the response' do
get '/api/v1/profile',
headers: { api_access_token: agent.access_token.token },
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['access_token']).to eq(agent.access_token.token)
expect(json_response['read_only_access_token']).to eq(agent.read_only_access_token.token)
end
end
context 'when authenticated via a read-only api_access_token' do
let(:agent) { create(:user, account: account, role: :agent) }
it 'withholds the full-scope token to prevent privilege escalation' do
get '/api/v1/profile',
headers: { api_access_token: agent.read_only_access_token.token },
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response).not_to have_key('access_token')
expect(json_response['read_only_access_token']).to eq(agent.read_only_access_token.token)
end
it 'never echoes the full token under any property name' do
full_token = agent.access_token.token
get '/api/v1/profile',
headers: { api_access_token: agent.read_only_access_token.token },
as: :json
expect(response.body).not_to include(full_token)
end
end
end
describe 'PUT /api/v1/profile' do
@@ -18,6 +18,10 @@ RSpec.describe 'Agent Capacity Policy Users API', type: :request do
expect(response).to have_http_status(:success)
expect(response.parsed_body.first['id']).to eq(user.id)
expect(response.parsed_body.first).not_to have_key('access_token')
expect(response.parsed_body.first).not_to have_key('read_only_access_token')
expect(response.body).not_to include(user.access_token.token)
expect(response.body).not_to include(user.read_only_access_token.token)
end
it 'returns each user only once without duplicates' do
@@ -63,6 +67,10 @@ RSpec.describe 'Agent Capacity Policy Users API', type: :request do
expect(response).to have_http_status(:success)
expect(user.account_users.first.reload.agent_capacity_policy).to eq(agent_capacity_policy)
expect(response.parsed_body).not_to have_key('access_token')
expect(response.parsed_body).not_to have_key('read_only_access_token')
expect(response.body).not_to include(user.access_token.token)
expect(response.body).not_to include(user.read_only_access_token.token)
end
end
end
+3 -3
View File
@@ -90,10 +90,10 @@ RSpec.describe User do
end
describe 'access token' do
it 'creates a single access token upon user creation' do
it 'creates a full and a read-only access token upon user creation' do
new_user = create(:user)
token_count = AccessToken.where(owner: new_user).count
expect(token_count).to eq(1)
scopes = AccessToken.where(owner: new_user).pluck(:scope)
expect(scopes).to contain_exactly('full', 'read_only')
end
end