feat(core): Add scopes to API Keys (#14176)

Co-authored-by: Charlie Kolb <charlie@n8n.io>
Co-authored-by: Danny Martini <danny@n8n.io>
This commit is contained in:
Ricardo Espinoza
2025-04-16 09:03:16 -04:00
committed by GitHub
parent bc12f662e7
commit e1b9407fe9
65 changed files with 3216 additions and 125 deletions

View File

@@ -8,6 +8,8 @@ import { fireEvent } from '@testing-library/vue';
import { useApiKeysStore } from '@/stores/apiKeys.store';
import { DateTime } from 'luxon';
import type { ApiKeyWithRawValue } from '@n8n/api-types';
import { useSettingsStore } from '@/stores/settings.store';
import { createMockEnterpriseSettings } from '@/__tests__/mocks';
const renderComponent = createComponentRenderer(ApiKeyEditModal, {
pinia: createTestingPinia({
@@ -29,13 +31,17 @@ const testApiKey: ApiKeyWithRawValue = {
updatedAt: new Date().toString(),
rawApiKey: '123456',
expiresAt: 0,
scopes: ['user:create', 'user:list'],
};
const apiKeysStore = mockedStore(useApiKeysStore);
const settingsStore = mockedStore(useSettingsStore);
describe('ApiKeyCreateOrEditModal', () => {
beforeEach(() => {
createAppModals();
apiKeysStore.availableScopes = ['user:create', 'user:list'];
settingsStore.settings.enterprise = createMockEnterpriseSettings({ apiKeyScopes: false });
});
afterEach(() => {
@@ -87,6 +93,7 @@ describe('ApiKeyCreateOrEditModal', () => {
updatedAt: new Date().toString(),
rawApiKey: '***456',
expiresAt: 0,
scopes: ['user:create', 'user:list'],
});
const { getByText, getByPlaceholderText, getByTestId } = renderComponent({
@@ -184,6 +191,116 @@ describe('ApiKeyCreateOrEditModal', () => {
expect(getByText('new api key')).toBeInTheDocument();
});
test('should allow creating API key with scopes when feat:apiKeyScopes is enabled', async () => {
settingsStore.settings.enterprise = createMockEnterpriseSettings({ apiKeyScopes: true });
apiKeysStore.createApiKey.mockResolvedValue(testApiKey);
const { getByText, getByPlaceholderText, getByTestId, getAllByText } = renderComponent({
props: {
mode: 'new',
},
});
await retry(() => expect(getByText('Create API Key')).toBeInTheDocument());
expect(getByText('Label')).toBeInTheDocument();
const inputLabel = getByPlaceholderText('e.g Internal Project');
const saveButton = getByText('Save');
const scopesSelect = getByTestId('scopes-select');
expect(inputLabel).toBeInTheDocument();
expect(scopesSelect).toBeInTheDocument();
expect(saveButton).toBeInTheDocument();
await fireEvent.update(inputLabel, 'new label');
await fireEvent.click(scopesSelect);
const userCreateScope = getByText('user:create');
expect(userCreateScope).toBeInTheDocument();
await fireEvent.click(userCreateScope);
const [userCreateTag, userCreateSelectOption] = getAllByText('user:create');
expect(userCreateTag).toBeInTheDocument();
expect(userCreateSelectOption).toBeInTheDocument();
await fireEvent.click(saveButton);
expect(getByText('API Key Created')).toBeInTheDocument();
expect(getByText('Done')).toBeInTheDocument();
expect(
getByText('Make sure to copy your API key now as you will not be able to see this again.'),
).toBeInTheDocument();
expect(getByText('Click to copy')).toBeInTheDocument();
expect(getByText('new api key')).toBeInTheDocument();
});
test('should not let the user select scopes and show upgrade banner when feat:apiKeyScopes is disabled', async () => {
settingsStore.settings.enterprise = createMockEnterpriseSettings({ apiKeyScopes: false });
apiKeysStore.createApiKey.mockResolvedValue(testApiKey);
const { getByText, getByPlaceholderText, getByTestId, getAllByText } = renderComponent({
props: {
mode: 'new',
},
});
await retry(() => expect(getByText('Create API Key')).toBeInTheDocument());
expect(getByText('Label')).toBeInTheDocument();
const inputLabel = getByPlaceholderText('e.g Internal Project');
const saveButton = getByText('Save');
expect(getByText('Upgrade')).toBeInTheDocument();
expect(getByText('to unlock the ability to modify API key scopes')).toBeInTheDocument();
const scopesSelect = getByTestId('scopes-select');
expect(inputLabel).toBeInTheDocument();
expect(scopesSelect).toBeInTheDocument();
expect(saveButton).toBeInTheDocument();
await fireEvent.update(inputLabel, 'new label');
await fireEvent.click(scopesSelect);
const userCreateScope = getAllByText('user:create');
const [userCreateTag, userCreateSelectOption] = userCreateScope;
expect(userCreateTag).toBeInTheDocument();
expect(userCreateSelectOption).toBeInTheDocument();
expect(userCreateSelectOption).toBeInTheDocument();
expect(userCreateSelectOption.parentNode).toHaveClass('is-disabled');
await fireEvent.click(userCreateSelectOption);
await fireEvent.click(saveButton);
expect(getByText('API Key Created')).toBeInTheDocument();
expect(getByText('Done')).toBeInTheDocument();
expect(
getByText('Make sure to copy your API key now as you will not be able to see this again.'),
).toBeInTheDocument();
expect(getByText('Click to copy')).toBeInTheDocument();
expect(getByText('new api key')).toBeInTheDocument();
});
test('should allow editing API key label', async () => {
apiKeysStore.apiKeys = [testApiKey];
@@ -218,6 +335,9 @@ describe('ApiKeyCreateOrEditModal', () => {
await fireEvent.click(editButton);
expect(apiKeysStore.updateApiKey).toHaveBeenCalledWith('123', { label: 'updated api key' });
expect(apiKeysStore.updateApiKey).toHaveBeenCalledWith('123', {
label: 'updated api key',
scopes: ['user:create', 'user:list'],
});
});
});

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import Modal from '@/components/Modal.vue';
import { API_KEY_CREATE_OR_EDIT_MODAL_KEY } from '@/constants';
import { API_KEY_CREATE_OR_EDIT_MODAL_KEY, EnterpriseEditionFeature } from '@/constants';
import { computed, onMounted, ref } from 'vue';
import { useUIStore } from '@/stores/ui.store';
import { createEventBus } from '@n8n/utils/event-bus';
@@ -13,6 +13,9 @@ import type { BaseTextKey } from '@/plugins/i18n';
import { N8nText } from '@n8n/design-system';
import { DateTime } from 'luxon';
import type { ApiKey, ApiKeyWithRawValue, CreateApiKeyRequestDto } from '@n8n/api-types';
import ApiKeyScopes from '@/components/ApiKeyScopes.vue';
import type { ApiKeyScope } from '@n8n/permissions';
import { useSettingsStore } from '@/stores/settings.store';
const EXPIRATION_OPTIONS = {
'7_DAYS': 7,
@@ -28,7 +31,7 @@ const { showError, showMessage } = useToast();
const uiStore = useUIStore();
const rootStore = useRootStore();
const { createApiKey, updateApiKey, apiKeysById } = useApiKeysStore();
const { createApiKey, updateApiKey, apiKeysById, availableScopes } = useApiKeysStore();
const documentTitle = useDocumentTitle();
const label = ref('');
@@ -40,6 +43,14 @@ const rawApiKey = ref('');
const customExpirationDate = ref('');
const showExpirationDateSelector = ref(false);
const apiKeyCreationDate = ref('');
const selectedScopes = ref<ApiKeyScope[]>([]);
const settingsStore = useSettingsStore();
const apiKeyStore = useApiKeysStore();
const apiKeyScopesEnabled = computed(
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.ApiKeyScopes],
);
const calculateExpirationDate = (daysFromNow: number) => {
const date = DateTime.now()
@@ -88,7 +99,11 @@ const allFormFieldsAreSet = computed(() => {
(expirationDaysFromNow.value === EXPIRATION_OPTIONS.CUSTOM && customExpirationDate.value) ||
expirationDate.value;
return label.value && (props.mode === 'edit' ? true : isExpirationDateSet);
return (
label.value &&
(!apiKeyScopesEnabled.value ? true : selectedScopes.value.length) &&
(props.mode === 'edit' ? true : isExpirationDateSet)
);
});
const isCustomDateInThePast = (date: Date) => Date.now() > date.getTime();
@@ -104,6 +119,11 @@ onMounted(() => {
const apiKey = apiKeysById[props.activeId];
label.value = apiKey.label ?? '';
apiKeyCreationDate.value = getApiKeyCreationTime(apiKey);
selectedScopes.value = !apiKeyScopesEnabled.value ? apiKeyStore.availableScopes : apiKey.scopes;
}
if (props.mode === 'new' && !apiKeyScopesEnabled.value) {
selectedScopes.value = availableScopes;
}
});
@@ -111,6 +131,10 @@ function onInput(value: string): void {
label.value = value;
}
function onScopeSelectionChanged(scopes: ApiKeyScope[]) {
selectedScopes.value = scopes;
}
const getApiKeyCreationTime = (apiKey: ApiKey): string => {
const time = DateTime.fromMillis(Date.parse(apiKey.createdAt)).toFormat('ccc, MMM d yyyy');
return i18n.baseText('settings.api.creationTime', { interpolate: { time } });
@@ -119,7 +143,7 @@ const getApiKeyCreationTime = (apiKey: ApiKey): string => {
async function onEdit() {
try {
loading.value = true;
await updateApiKey(props.activeId, { label: label.value });
await updateApiKey(props.activeId, { label: label.value, scopes: selectedScopes.value });
showMessage({
type: 'success',
title: i18n.baseText('settings.api.update.toast'),
@@ -152,6 +176,7 @@ const onSave = async () => {
const payload: CreateApiKeyRequestDto = {
label: label.value,
expiresAt: expirationUnixTimestamp,
scopes: selectedScopes.value,
};
try {
@@ -218,7 +243,7 @@ async function handleEnterKey(event: KeyboardEvent) {
width="600px"
:lock-scroll="false"
:close-on-esc="true"
:close-on-click-outside="true"
:close-on-click-modal="false"
:show-close="true"
>
<template #content>
@@ -260,6 +285,7 @@ async function handleEnterKey(event: KeyboardEvent) {
v-model="expirationDaysFromNow"
size="large"
filterable
readonly
data-test-id="expiration-select"
@update:model-value="onSelect"
>
@@ -291,6 +317,12 @@ async function handleEnterKey(event: KeyboardEvent) {
:disabled-date="isCustomDateInThePast"
/>
</div>
<ApiKeyScopes
v-model="selectedScopes"
:available-scopes="availableScopes"
:enabled="apiKeyScopesEnabled"
@update:model-value="onScopeSelectionChanged"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,186 @@
<script setup>
import { ref, computed, watch } from 'vue';
// eslint-disable-next-line import/no-extraneous-dependencies
import { ElSelect, ElOption, ElOptionGroup } from 'element-plus';
import { capitalCase } from 'change-case';
import { useI18n } from '@/composables/useI18n';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
// Define props
const props = defineProps({
modelValue: {
type: Array,
default: () => [],
},
availableScopes: {
type: Array,
default: () => [],
},
enabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:modelValue']);
const selectedScopes = ref(props.modelValue);
const i18n = useI18n();
const { goToUpgrade } = usePageRedirectionHelper();
const checkAll = ref(false);
const indeterminate = ref(false);
const groupedScopes = computed(() => {
const groups = {};
props.availableScopes.forEach((scope) => {
const [resource, action] = scope.split(':');
if (!groups[resource]) {
groups[resource] = [];
}
if (action) {
groups[resource].push(action);
}
});
return groups;
});
watch(selectedScopes, (newValue) => {
if (newValue.length === props.availableScopes.length) {
indeterminate.value = false;
checkAll.value = true;
} else if (newValue.length > 0) {
indeterminate.value = true;
} else if (newValue.length === 0) {
indeterminate.value = false;
checkAll.value = false;
}
emit('update:modelValue', newValue);
});
watch(checkAll, (newValue) => {
if (newValue) {
selectedScopes.value = props.availableScopes;
} else {
selectedScopes.value = [];
}
});
function goToUpgradeApiKeyScopes() {
void goToUpgrade('api-key-scopes', 'upgrade-api-key-scopes');
}
</script>
<template>
<div :class="$style['api-key-scopes']">
<div ref="popperContainer"></div>
<N8nInputLabel :label="i18n.baseText('settings.api.scopes.label')" color="text-dark">
<ElSelect
v-model="selectedScopes"
data-test-id="scopes-select"
:popper-class="$style['scopes-dropdown-container']"
:teleported="true"
multiple
collapse-tags
:max-collapse-tags="10"
placement="top"
:reserve-keyword="false"
:placeholder="i18n.baseText('settings.api.scopes.placeholder')"
:append-to="popperContainer"
>
<template #header>
<el-checkbox
v-model="checkAll"
:disabled="!enabled"
:class="$style['scopes-checkbox']"
:indeterminate="indeterminate"
>
{{ i18n.baseText('settings.api.scopes.selectAll') }}
</el-checkbox>
</template>
<template v-for="(actions, resource) in groupedScopes" :key="resource">
<ElOptionGroup :disabled="!enabled" :label="capitalCase(resource).toUpperCase()">
<ElOption
v-for="action in actions"
:key="`${resource}:${action}`"
:label="`${resource}:${action}`"
:value="`${resource}:${action}`"
/>
</ElOptionGroup>
</template>
</ElSelect>
</N8nInputLabel>
<N8nNotice v-if="!enabled">
<i18n-t keypath="settings.api.scopes.upgrade">
<template #link>
<n8n-link size="small" @click="goToUpgradeApiKeyScopes">
{{ i18n.baseText('settings.api.scopes.upgrade.link') }}
</n8n-link>
</template>
</i18n-t>
</N8nNotice>
</div>
</template>
<style module>
.api-key-scopes :global(.el-tag) {
padding: var(--spacing-3xs);
}
.api-key-scopes :global(.el-tag__close) {
color: white;
margin-left: var(--spacing-3xs);
background-color: var(--color-text-base);
}
.api-key-scopes :global(.el-checkbox) {
margin-left: var(--spacing-xs);
}
.scopes-dropdown-container :global(.el-select-group__title) {
font-size: var(--font-size-2xs);
color: var(--color-text-dark);
font-weight: var(--font-weight-bold);
border-bottom: var(--spacing-5xs) solid var(--color-text-lighter);
padding-left: var(--spacing-xs);
}
.scopes-dropdown-container :global(.el-select-dropdown__item) {
color: var(--color-text-base);
font-weight: var(--font-weight-regular);
padding-left: var(--spacing-xs);
}
.scopes-dropdown-container
:global(.el-select-dropdown.is-multiple .el-select-dropdown__item.selected) {
font-weight: var(--font-weight-bold);
}
.scopes-dropdown-container :global(.el-select-group__wrap:not(:last-of-type)) {
padding: 0px;
margin-bottom: var(--spacing-xs);
}
.scopes-dropdown-container :global(.el-checkbox) {
margin-left: var(--spacing-2xs);
}
.scopes-dropdown-container :global(.el-select-dropdown__header) {
margin-top: var(--spacing-xs);
padding-bottom: var(--spacing-xs);
border-bottom: var(--spacing-5xs) solid var(--color-text-lighter);
}
.scopes-checkbox {
display: flex;
}
.scopes-dropdown-container :global(.el-select-group__wrap::after) {
display: none;
}
</style>