Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8196936a62 | ||
|
|
b26dea7879 | ||
|
|
85776ec65c | ||
|
|
e408b5dbbd | ||
|
|
c633c35712 | ||
|
|
40432499c8 | ||
|
|
72d0e91d3c | ||
|
|
ca5ef95db7 | ||
|
|
eedffccf6d | ||
|
|
134eb157cc | ||
|
|
c7c4da35f6 | ||
|
|
57a5d7e64e | ||
|
|
eef3a6fefc | ||
|
|
cb2061f260 | ||
|
|
2e30a69a18 | ||
|
|
473d1a6c22 | ||
|
|
4efb421800 | ||
|
|
54b86939d2 | ||
|
|
49ac1557c1 | ||
|
|
45bd3f0fc7 | ||
|
|
5db4bb963b | ||
|
|
bd780b4cc6 | ||
|
|
2f607f67c4 | ||
|
|
ae676077ed | ||
|
|
a31fb0f906 | ||
|
|
c0c12f2fbd | ||
|
|
7333cdd79f | ||
|
|
b9e83ec2d4 | ||
|
|
f3b990997e | ||
|
|
f778057b18 | ||
|
|
1987abf9e2 | ||
|
|
a1c79f90b1 | ||
|
|
8f8cd7b929 | ||
|
|
48d8960539 | ||
|
|
c816e6b330 | ||
|
|
b0aa51e811 | ||
|
|
7c73981ddd | ||
|
|
fe944720a8 | ||
|
|
d2de7bed6c | ||
|
|
5d8fce92f7 | ||
|
|
e195f9842f | ||
|
|
b96c3df19f |
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
+1
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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' &&
|
||||
|
||||
@@ -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 @@
|
||||
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,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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
+8
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user