fix(editor): Replace isInstanceOwner checks with scopes where applicable (#7858)

Co-authored-by: Alex Grozav <alex@grozav.com>
This commit is contained in:
Csaba Tuncsik 2023-12-04 10:02:54 +01:00 committed by GitHub
parent 39fa8d21bb
commit 132d691cbf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 111 additions and 67 deletions

View file

@ -7,25 +7,25 @@ const CONTACT_EMAIL_SUBMISSION_ENDPOINT = '/accounts/onboarding';
export async function fetchNextOnboardingPrompt( export async function fetchNextOnboardingPrompt(
instanceId: string, instanceId: string,
currentUer: IUser, currentUser: IUser,
): Promise<IOnboardingCallPrompt> { ): Promise<IOnboardingCallPrompt> {
return get(N8N_API_BASE_URL, ONBOARDING_PROMPTS_ENDPOINT, { return get(N8N_API_BASE_URL, ONBOARDING_PROMPTS_ENDPOINT, {
instance_id: instanceId, instance_id: instanceId,
user_id: `${instanceId}#${currentUer.id}`, user_id: `${instanceId}#${currentUser.id}`,
is_owner: currentUer.isOwner, is_owner: currentUser.isOwner,
survey_results: currentUer.personalizationAnswers, survey_results: currentUser.personalizationAnswers,
}); });
} }
export async function applyForOnboardingCall( export async function applyForOnboardingCall(
instanceId: string, instanceId: string,
currentUer: IUser, currentUser: IUser,
email: string, email: string,
): Promise<string> { ): Promise<string> {
try { try {
const response = await post(N8N_API_BASE_URL, ONBOARDING_PROMPTS_ENDPOINT, { const response = await post(N8N_API_BASE_URL, ONBOARDING_PROMPTS_ENDPOINT, {
instance_id: instanceId, instance_id: instanceId,
user_id: `${instanceId}#${currentUer.id}`, user_id: `${instanceId}#${currentUser.id}`,
email, email,
}); });
return response; return response;
@ -36,13 +36,13 @@ export async function applyForOnboardingCall(
export async function submitEmailOnSignup( export async function submitEmailOnSignup(
instanceId: string, instanceId: string,
currentUer: IUser, currentUser: IUser,
email: string | undefined, email: string | undefined,
agree: boolean, agree: boolean,
): Promise<string> { ): Promise<string> {
return post(N8N_API_BASE_URL, CONTACT_EMAIL_SUBMISSION_ENDPOINT, { return post(N8N_API_BASE_URL, CONTACT_EMAIL_SUBMISSION_ENDPOINT, {
instance_id: instanceId, instance_id: instanceId,
user_id: `${instanceId}#${currentUer.id}`, user_id: `${instanceId}#${currentUser.id}`,
email, email,
agree, agree,
}); });

View file

@ -184,6 +184,7 @@ import { getWorkflowPermissions } from '@/permissions';
import { createEventBus } from 'n8n-design-system/utils'; import { createEventBus } from 'n8n-design-system/utils';
import { nodeViewEventBus } from '@/event-bus'; import { nodeViewEventBus } from '@/event-bus';
import { genericHelpers } from '@/mixins/genericHelpers'; import { genericHelpers } from '@/mixins/genericHelpers';
import { hasPermission } from '@/rbac/permissions';
const hasChanged = (prev: string[], curr: string[]) => { const hasChanged = (prev: string[], curr: string[]) => {
if (prev.length !== curr.length) { if (prev.length !== curr.length) {
@ -247,10 +248,7 @@ export default defineComponent({
currentUser(): IUser | null { currentUser(): IUser | null {
return this.usersStore.currentUser; return this.usersStore.currentUser;
}, },
currentUserIsOwner(): boolean { contextBasedTranslationKeys() {
return this.usersStore.currentUser?.isOwner ?? false;
},
contextBasedTranslationKeys(): NestedRecord<string> {
return this.uiStore.contextBasedTranslationKeys; return this.uiStore.contextBasedTranslationKeys;
}, },
isWorkflowActive(): boolean { isWorkflowActive(): boolean {
@ -298,7 +296,7 @@ export default defineComponent({
].includes(this.$route.name || ''); ].includes(this.$route.name || '');
}, },
workflowPermissions(): IPermissions { workflowPermissions(): IPermissions {
return getWorkflowPermissions(this.usersStore.currentUser, this.workflow); return getWorkflowPermissions(this.currentUser, this.workflow);
}, },
workflowMenuItems(): Array<{}> { workflowMenuItems(): Array<{}> {
const actions = [ const actions = [
@ -330,7 +328,7 @@ export default defineComponent({
); );
} }
if (this.currentUserIsOwner) { if (hasPermission(['rbac'], { rbac: { scope: 'sourceControl:push' } })) {
actions.push({ actions.push({
id: WORKFLOW_MENU_ACTIONS.PUSH, id: WORKFLOW_MENU_ACTIONS.PUSH,
label: this.$locale.baseText('menuActions.push'), label: this.$locale.baseText('menuActions.push'),
@ -338,8 +336,7 @@ export default defineComponent({
!this.sourceControlStore.isEnterpriseSourceControlEnabled || !this.sourceControlStore.isEnterpriseSourceControlEnabled ||
!this.onWorkflowPage || !this.onWorkflowPage ||
this.onExecutionsTab || this.onExecutionsTab ||
this.readOnlyEnv || this.readOnlyEnv,
!this.currentUserIsOwner,
}); });
} }

View file

@ -266,7 +266,7 @@ export default defineComponent({
position: 'bottom', position: 'bottom',
label: 'Admin Panel', label: 'Admin Panel',
icon: 'home', icon: 'home',
available: this.settingsStore.isCloudDeployment && this.usersStore.isInstanceOwner, available: this.settingsStore.isCloudDeployment && hasPermission(['instanceOwner']),
}, },
{ {
id: 'settings', id: 'settings',

View file

@ -3,12 +3,11 @@ import { computed, nextTick, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { createEventBus } from 'n8n-design-system/utils'; import { createEventBus } from 'n8n-design-system/utils';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useMessage } from '@/composables/useMessage'; import { hasPermission } from '@/rbac/permissions';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { useLoadingService } from '@/composables/useLoadingService'; import { useLoadingService } from '@/composables/useLoadingService';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useUsersStore } from '@/stores/users.store';
import { SOURCE_CONTROL_PULL_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY, VIEWS } from '@/constants'; import { SOURCE_CONTROL_PULL_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY, VIEWS } from '@/constants';
import type { SourceControlAggregatedFile } from '../Interface'; import type { SourceControlAggregatedFile } from '../Interface';
import { sourceControlEventBus } from '@/event-bus/source-control'; import { sourceControlEventBus } from '@/event-bus/source-control';
@ -24,9 +23,7 @@ const responseStatuses = {
const router = useRouter(); const router = useRouter();
const loadingService = useLoadingService(); const loadingService = useLoadingService();
const uiStore = useUIStore(); const uiStore = useUIStore();
const usersStore = useUsersStore();
const sourceControlStore = useSourceControlStore(); const sourceControlStore = useSourceControlStore();
const message = useMessage();
const toast = useToast(); const toast = useToast();
const i18n = useI18n(); const i18n = useI18n();
@ -36,8 +33,11 @@ const tooltipOpenDelay = ref(300);
const currentBranch = computed(() => { const currentBranch = computed(() => {
return sourceControlStore.preferences.branchName; return sourceControlStore.preferences.branchName;
}); });
const isInstanceOwner = computed(() => usersStore.isInstanceOwner); const sourceControlAvailable = computed(
const setupButtonTooltipPlacement = computed(() => (props.isCollapsed ? 'right' : 'top')); () =>
sourceControlStore.isEnterpriseSourceControlEnabled &&
hasPermission(['rbac'], { rbac: { scope: 'sourceControl:manage' } }),
);
async function pushWorkfolder() { async function pushWorkfolder() {
loadingService.startLoading(); loadingService.startLoading();
@ -125,7 +125,7 @@ const goToSourceControlSetup = async () => {
<template> <template>
<div <div
v-if="sourceControlStore.isEnterpriseSourceControlEnabled && isInstanceOwner" v-if="sourceControlAvailable"
:class="{ :class="{
[$style.sync]: true, [$style.sync]: true,
[$style.collapsed]: isCollapsed, [$style.collapsed]: isCollapsed,

View file

@ -25,7 +25,7 @@
<el-switch <el-switch
class="mr-s" class="mr-s"
:disabled="!isInstanceOwner" :disabled="readonly"
:modelValue="nodeParameters.enabled" :modelValue="nodeParameters.enabled"
@update:modelValue="onEnabledSwitched($event, destination.id)" @update:modelValue="onEnabledSwitched($event, destination.id)"
:title=" :title="
@ -84,7 +84,7 @@ export default defineComponent({
required: true, required: true,
default: deepCopy(defaultMessageEventBusDestinationOptions), default: deepCopy(defaultMessageEventBusDestinationOptions),
}, },
isInstanceOwner: Boolean, readonly: Boolean,
}, },
mounted() { mounted() {
this.nodeParameters = Object.assign( this.nodeParameters = Object.assign(
@ -105,7 +105,7 @@ export default defineComponent({
value: DESTINATION_LIST_ITEM_ACTIONS.OPEN, value: DESTINATION_LIST_ITEM_ACTIONS.OPEN,
}, },
]; ];
if (this.isInstanceOwner) { if (!this.readonly) {
actions.push({ actions.push({
label: this.$locale.baseText('workflows.item.delete'), label: this.$locale.baseText('workflows.item.delete'),
value: DESTINATION_LIST_ITEM_ACTIONS.DELETE, value: DESTINATION_LIST_ITEM_ACTIONS.DELETE,

View file

@ -45,7 +45,7 @@
@click="sendTestEvent" @click="sendTestEvent"
data-test-id="destination-test-button" data-test-id="destination-test-button"
/> />
<template v-if="isInstanceOwner"> <template v-if="canManageLogStreaming">
<n8n-icon-button <n8n-icon-button
v-if="nodeParameters && hasOnceBeenSaved" v-if="nodeParameters && hasOnceBeenSaved"
:title="$locale.baseText('settings.log-streaming.delete')" :title="$locale.baseText('settings.log-streaming.delete')"
@ -117,7 +117,7 @@
:parameters="webhookDescription" :parameters="webhookDescription"
:hideDelete="true" :hideDelete="true"
:nodeValues="nodeParameters" :nodeValues="nodeParameters"
:isReadOnly="!isInstanceOwner" :isReadOnly="!canManageLogStreaming"
path="" path=""
@valueChanged="valueChanged" @valueChanged="valueChanged"
/> />
@ -127,7 +127,7 @@
:parameters="syslogDescription" :parameters="syslogDescription"
:hideDelete="true" :hideDelete="true"
:nodeValues="nodeParameters" :nodeValues="nodeParameters"
:isReadOnly="!isInstanceOwner" :isReadOnly="!canManageLogStreaming"
path="" path=""
@valueChanged="valueChanged" @valueChanged="valueChanged"
/> />
@ -137,7 +137,7 @@
:parameters="sentryDescription" :parameters="sentryDescription"
:hideDelete="true" :hideDelete="true"
:nodeValues="nodeParameters" :nodeValues="nodeParameters"
:isReadOnly="!isInstanceOwner" :isReadOnly="!canManageLogStreaming"
path="" path=""
@valueChanged="valueChanged" @valueChanged="valueChanged"
/> />
@ -156,7 +156,7 @@
:destinationId="destination.id" :destinationId="destination.id"
@input="onInput" @input="onInput"
@change="valueChanged" @change="valueChanged"
:readonly="!isInstanceOwner" :readonly="!canManageLogStreaming"
/> />
</div> </div>
</div> </div>
@ -194,7 +194,7 @@ import { LOG_STREAM_MODAL_KEY, MODAL_CONFIRM } from '@/constants';
import Modal from '@/components/Modal.vue'; import Modal from '@/components/Modal.vue';
import { useMessage } from '@/composables/useMessage'; import { useMessage } from '@/composables/useMessage';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { useUsersStore } from '@/stores/users.store'; import { hasPermission } from '@/rbac/permissions';
import { destinationToFakeINodeUi } from '@/components/SettingsLogStreaming/Helpers.ee'; import { destinationToFakeINodeUi } from '@/components/SettingsLogStreaming/Helpers.ee';
import { import {
webhookModalDescription, webhookModalDescription,
@ -252,12 +252,11 @@ export default defineComponent({
headerLabel: this.destination.label, headerLabel: this.destination.label,
testMessageSent: false, testMessageSent: false,
testMessageResult: false, testMessageResult: false,
isInstanceOwner: false,
LOG_STREAM_MODAL_KEY, LOG_STREAM_MODAL_KEY,
}; };
}, },
computed: { computed: {
...mapStores(useUIStore, useUsersStore, useLogStreamingStore, useNDVStore, useWorkflowsStore), ...mapStores(useUIStore, useLogStreamingStore, useNDVStore, useWorkflowsStore),
typeSelectOptions(): Array<{ value: string; label: BaseTextKey }> { typeSelectOptions(): Array<{ value: string; label: BaseTextKey }> {
const options: Array<{ value: string; label: BaseTextKey }> = []; const options: Array<{ value: string; label: BaseTextKey }> = [];
for (const t of Object.values(MessageEventBusDestinationTypeNames)) { for (const t of Object.values(MessageEventBusDestinationTypeNames)) {
@ -306,9 +305,11 @@ export default defineComponent({
} }
return items; return items;
}, },
canManageLogStreaming(): boolean {
return hasPermission(['rbac'], { rbac: { scope: 'logStreaming:manage' } });
},
}, },
mounted() { mounted() {
this.isInstanceOwner = this.usersStore.currentUser?.globalRole?.name === 'owner';
this.setupNode( this.setupNode(
Object.assign(deepCopy(defaultMessageEventBusDestinationOptions), this.destination), Object.assign(deepCopy(defaultMessageEventBusDestinationOptions), this.destination),
); );

View file

@ -381,6 +381,8 @@ import { useRootStore } from '@/stores/n8nRoot.store';
import { useWorkflowsEEStore } from '@/stores/workflows.ee.store'; import { useWorkflowsEEStore } from '@/stores/workflows.ee.store';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { createEventBus } from 'n8n-design-system/utils'; import { createEventBus } from 'n8n-design-system/utils';
import type { IPermissions } from '@/permissions';
import { getWorkflowPermissions } from '@/permissions';
export default defineComponent({ export default defineComponent({
name: 'WorkflowSettings', name: 'WorkflowSettings',
@ -479,6 +481,9 @@ export default defineComponent({
return this.workflowsEEStore.getWorkflowOwnerName(`${this.workflowId}`, fallback); return this.workflowsEEStore.getWorkflowOwnerName(`${this.workflowId}`, fallback);
}, },
workflowPermissions(): IPermissions {
return getWorkflowPermissions(this.currentUser, this.workflow);
},
}, },
async mounted() { async mounted() {
this.executionTimeout = this.rootStore.executionTimeout; this.executionTimeout = this.rootStore.executionTimeout;
@ -584,8 +589,6 @@ export default defineComponent({
}; };
}, },
async loadWorkflowCallerPolicyOptions() { async loadWorkflowCallerPolicyOptions() {
const currentUserIsOwner = this.workflow.ownedBy?.id === this.currentUser?.id;
this.workflowCallerPolicyOptions = [ this.workflowCallerPolicyOptions = [
{ {
key: 'none', key: 'none',
@ -597,7 +600,7 @@ export default defineComponent({
'workflowSettings.callerPolicy.options.workflowsFromSameOwner', 'workflowSettings.callerPolicy.options.workflowsFromSameOwner',
{ {
interpolate: { interpolate: {
owner: currentUserIsOwner owner: this.workflowPermissions.isOwner
? this.$locale.baseText( ? this.$locale.baseText(
'workflowSettings.callerPolicy.options.workflowsFromSameOwner.owner', 'workflowSettings.callerPolicy.options.workflowsFromSameOwner.owner',
) )

View file

@ -8,13 +8,13 @@ import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
import MainSidebarSourceControl from '@/components/MainSidebarSourceControl.vue'; import MainSidebarSourceControl from '@/components/MainSidebarSourceControl.vue';
import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { useUsersStore } from '@/stores/users.store'; import { useRBACStore } from '@/stores/rbac.store';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
let pinia: ReturnType<typeof createTestingPinia>; let pinia: ReturnType<typeof createTestingPinia>;
let sourceControlStore: ReturnType<typeof useSourceControlStore>; let sourceControlStore: ReturnType<typeof useSourceControlStore>;
let uiStore: ReturnType<typeof useUIStore>; let uiStore: ReturnType<typeof useUIStore>;
let usersStore: ReturnType<typeof useUsersStore>; let rbacStore: ReturnType<typeof useRBACStore>;
const renderComponent = createComponentRenderer(MainSidebarSourceControl); const renderComponent = createComponentRenderer(MainSidebarSourceControl);
@ -28,8 +28,8 @@ describe('MainSidebarSourceControl', () => {
}, },
}); });
usersStore = useUsersStore(pinia); rbacStore = useRBACStore(pinia);
vi.spyOn(usersStore, 'isInstanceOwner', 'get').mockReturnValue(true); vi.spyOn(rbacStore, 'hasScope').mockReturnValue(true);
sourceControlStore = useSourceControlStore(); sourceControlStore = useSourceControlStore();
vi.spyOn(sourceControlStore, 'isEnterpriseSourceControlEnabled', 'get').mockReturnValue(true); vi.spyOn(sourceControlStore, 'isEnterpriseSourceControlEnabled', 'get').mockReturnValue(true);
@ -38,7 +38,7 @@ describe('MainSidebarSourceControl', () => {
}); });
it('should render nothing when not instance owner', async () => { it('should render nothing when not instance owner', async () => {
vi.spyOn(usersStore, 'isInstanceOwner', 'get').mockReturnValue(false); vi.spyOn(rbacStore, 'hasScope').mockReturnValue(false);
const { container } = renderComponent({ pinia, props: { isCollapsed: false } }); const { container } = renderComponent({ pinia, props: { isCollapsed: false } });
expect(container).toBeEmptyDOMElement(); expect(container).toBeEmptyDOMElement();
}); });

View file

@ -1,15 +1,17 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue';
import BaseBanner from '@/components/banners/BaseBanner.vue'; import BaseBanner from '@/components/banners/BaseBanner.vue';
import { i18n as locale } from '@/plugins/i18n'; import { i18n as locale } from '@/plugins/i18n';
import { useUsersStore } from '@/stores/users.store'; import { hasPermission } from '@/rbac/permissions';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
const uiStore = useUIStore(); const uiStore = useUIStore();
const usersStore = useUsersStore();
async function dismissPermanently() { async function dismissPermanently() {
await uiStore.dismissBanner('V1', 'permanent'); await uiStore.dismissBanner('V1', 'permanent');
} }
const hasOwnerPermission = computed(() => hasPermission(['instanceOwner']));
</script> </script>
<template> <template>
@ -17,7 +19,7 @@ async function dismissPermanently() {
<template #mainContent> <template #mainContent>
<span v-html="locale.baseText('banners.v1.message')"></span> <span v-html="locale.baseText('banners.v1.message')"></span>
<a <a
v-if="usersStore.isInstanceOwner" v-if="hasOwnerPermission"
:class="$style.link" :class="$style.link"
@click="dismissPermanently" @click="dismissPermanently"
data-test-id="banner-confirm-v1" data-test-id="banner-confirm-v1"

View file

@ -38,12 +38,13 @@ import { isObject } from '@/utils/objectUtils';
import { getCredentialPermissions } from '@/permissions'; import { getCredentialPermissions } from '@/permissions';
import { mapStores } from 'pinia'; import { mapStores } from 'pinia';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import { useUsersStore } from '@/stores/users.store'; import { hasPermission } from '@/rbac/permissions';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { useRootStore } from '@/stores/n8nRoot.store'; import { useRootStore } from '@/stores/n8nRoot.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useCredentialsStore } from '@/stores/credentials.store'; import { useCredentialsStore } from '@/stores/credentials.store';
import { defineComponent } from 'vue'; import { defineComponent } from 'vue';
import { useUsersStore } from '@/stores/users.store';
export const nodeHelpers = defineComponent({ export const nodeHelpers = defineComponent({
computed: { computed: {
@ -53,7 +54,6 @@ export const nodeHelpers = defineComponent({
useNodeTypesStore, useNodeTypesStore,
useSettingsStore, useSettingsStore,
useWorkflowsStore, useWorkflowsStore,
useUsersStore,
useRootStore, useRootStore,
), ),
}, },
@ -475,11 +475,13 @@ export const nodeHelpers = defineComponent({
} }
if (nameMatches.length === 0) { if (nameMatches.length === 0) {
const isInstanceOwner = this.usersStore.isInstanceOwner;
const isCredentialUsedInWorkflow = const isCredentialUsedInWorkflow =
this.workflowsStore.usedCredentials?.[selectedCredentials.id as string]; this.workflowsStore.usedCredentials?.[selectedCredentials.id as string];
if (!isCredentialUsedInWorkflow && !isInstanceOwner) { if (
!isCredentialUsedInWorkflow &&
!hasPermission(['rbac'], { rbac: { scope: 'credential:read' } })
) {
foundIssues[credentialTypeDescription.name] = [ foundIssues[credentialTypeDescription.name] = [
this.$locale.baseText('nodeIssues.credentials.doNotExist', { this.$locale.baseText('nodeIssues.credentials.doNotExist', {
interpolate: { name: selectedCredentials.name, type: credentialDisplayName }, interpolate: { name: selectedCredentials.name, type: credentialDisplayName },

View file

@ -6,6 +6,7 @@ vi.mock('@/rbac/checks', () => ({
hasScope: vi.fn(), hasScope: vi.fn(),
isGuest: vi.fn(), isGuest: vi.fn(),
isDefaultUser: vi.fn(), isDefaultUser: vi.fn(),
isInstanceOwner: vi.fn(),
isAuthenticated: vi.fn(), isAuthenticated: vi.fn(),
isEnterpriseFeatureEnabled: vi.fn(), isEnterpriseFeatureEnabled: vi.fn(),
isValid: vi.fn(), isValid: vi.fn(),
@ -17,6 +18,7 @@ describe('hasPermission()', () => {
vi.mocked(checks.hasScope).mockReturnValue(true); vi.mocked(checks.hasScope).mockReturnValue(true);
vi.mocked(checks.isGuest).mockReturnValue(true); vi.mocked(checks.isGuest).mockReturnValue(true);
vi.mocked(checks.isDefaultUser).mockReturnValue(true); vi.mocked(checks.isDefaultUser).mockReturnValue(true);
vi.mocked(checks.isInstanceOwner).mockReturnValue(true);
vi.mocked(checks.isAuthenticated).mockReturnValue(true); vi.mocked(checks.isAuthenticated).mockReturnValue(true);
vi.mocked(checks.isEnterpriseFeatureEnabled).mockReturnValue(true); vi.mocked(checks.isEnterpriseFeatureEnabled).mockReturnValue(true);
vi.mocked(checks.isValid).mockReturnValue(true); vi.mocked(checks.isValid).mockReturnValue(true);
@ -30,6 +32,7 @@ describe('hasPermission()', () => {
'rbac', 'rbac',
'role', 'role',
'defaultUser', 'defaultUser',
'instanceOwner',
]), ]),
).toBe(true); ).toBe(true);
}); });

View file

@ -0,0 +1,26 @@
import { useUsersStore } from '@/stores/users.store';
import { isInstanceOwner } from '@/rbac/checks/isInstanceOwner';
vi.mock('@/stores/users.store', () => ({
useUsersStore: vi.fn(),
}));
describe('Checks', () => {
describe('isInstanceOwner()', () => {
it('should return false if user not logged in', () => {
vi.mocked(useUsersStore).mockReturnValue({ isInstanceOwner: false } as ReturnType<
typeof useUsersStore
>);
expect(isInstanceOwner()).toBe(false);
});
it('should return true if user is default user', () => {
vi.mocked(useUsersStore).mockReturnValue({ isInstanceOwner: true } as unknown as ReturnType<
typeof useUsersStore
>);
expect(isInstanceOwner()).toBe(true);
});
});
});

View file

@ -2,6 +2,7 @@ export * from './hasRole';
export * from './hasScope'; export * from './hasScope';
export * from './isAuthenticated'; export * from './isAuthenticated';
export * from './isDefaultUser'; export * from './isDefaultUser';
export * from './isInstanceOwner';
export * from './isEnterpriseFeatureEnabled'; export * from './isEnterpriseFeatureEnabled';
export * from './isGuest'; export * from './isGuest';
export * from './isValid'; export * from './isValid';

View file

@ -0,0 +1,5 @@
import { useUsersStore } from '@/stores/users.store';
import type { DefaultUserMiddlewareOptions, RBACPermissionCheck } from '@/types/rbac';
export const isInstanceOwner: RBACPermissionCheck<DefaultUserMiddlewareOptions> = () =>
useUsersStore().isInstanceOwner;

View file

@ -3,6 +3,7 @@ import {
hasScope, hasScope,
isAuthenticated, isAuthenticated,
isDefaultUser, isDefaultUser,
isInstanceOwner,
isEnterpriseFeatureEnabled, isEnterpriseFeatureEnabled,
isGuest, isGuest,
isValid, isValid,
@ -17,6 +18,7 @@ export const permissions: Permissions = {
authenticated: isAuthenticated, authenticated: isAuthenticated,
custom: isValid, custom: isValid,
defaultUser: isDefaultUser, defaultUser: isDefaultUser,
instanceOwner: isInstanceOwner,
enterprise: isEnterpriseFeatureEnabled, enterprise: isEnterpriseFeatureEnabled,
guest: isGuest, guest: isGuest,
rbac: hasScope, rbac: hasScope,

View file

@ -8,6 +8,7 @@ import { useUsersStore } from '@/stores/users.store';
import { getAdminPanelLoginCode, getCurrentPlan, getCurrentUsage } from '@/api/cloudPlans'; import { getAdminPanelLoginCode, getCurrentPlan, getCurrentUsage } from '@/api/cloudPlans';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { CLOUD_TRIAL_CHECK_INTERVAL, STORES } from '@/constants'; import { CLOUD_TRIAL_CHECK_INTERVAL, STORES } from '@/constants';
import { hasPermission } from '@/rbac/permissions';
const DEFAULT_STATE: CloudPlanState = { const DEFAULT_STATE: CloudPlanState = {
initialized: false, initialized: false,
@ -55,13 +56,13 @@ export const useCloudPlanStore = defineStore(STORES.CLOUD_PLAN, () => {
const hasCloudPlan = computed(() => { const hasCloudPlan = computed(() => {
const cloudUserId = settingsStore.settings.n8nMetadata?.userId; const cloudUserId = settingsStore.settings.n8nMetadata?.userId;
return usersStore.isInstanceOwner && settingsStore.isCloudDeployment && cloudUserId; return hasPermission(['instanceOwner']) && settingsStore.isCloudDeployment && cloudUserId;
}); });
const getUserCloudAccount = async () => { const getUserCloudAccount = async () => {
if (!hasCloudPlan.value) throw new Error('User does not have a cloud plan'); if (!hasCloudPlan.value) throw new Error('User does not have a cloud plan');
try { try {
if (useUsersStore().isInstanceOwner) { if (hasPermission(['instanceOwner'])) {
await usersStore.fetchUserCloudAccount(); await usersStore.fetchUserCloudAccount();
if (!usersStore.currentUserCloudInfo?.confirmed && !userIsTrialing.value) { if (!usersStore.currentUserCloudInfo?.confirmed && !userIsTrialing.value) {
useUIStore().pushBannerToStack('EMAIL_CONFIRMATION'); useUIStore().pushBannerToStack('EMAIL_CONFIRMATION');

View file

@ -5,13 +5,13 @@ import { useRootStore } from '@/stores/n8nRoot.store';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import * as externalSecretsApi from '@/api/externalSecrets.ee'; import * as externalSecretsApi from '@/api/externalSecrets.ee';
import { connectProvider } from '@/api/externalSecrets.ee'; import { connectProvider } from '@/api/externalSecrets.ee';
import { useUsersStore } from '@/stores/users.store'; import { useRBACStore } from '@/stores/rbac.store';
import type { ExternalSecretsProvider } from '@/Interface'; import type { ExternalSecretsProvider } from '@/Interface';
export const useExternalSecretsStore = defineStore('externalSecrets', () => { export const useExternalSecretsStore = defineStore('externalSecrets', () => {
const rootStore = useRootStore(); const rootStore = useRootStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const usersStore = useUsersStore(); const rbacStore = useRBACStore();
const state = reactive({ const state = reactive({
providers: [] as ExternalSecretsProvider[], providers: [] as ExternalSecretsProvider[],
@ -65,7 +65,7 @@ export const useExternalSecretsStore = defineStore('externalSecrets', () => {
}); });
async function fetchAllSecrets() { async function fetchAllSecrets() {
if (usersStore.isInstanceOwner) { if (rbacStore.hasScope('externalSecret:list')) {
try { try {
state.secrets = await externalSecretsApi.getExternalSecrets(rootStore.getRestApiContext); state.secrets = await externalSecretsApi.getExternalSecrets(rootStore.getRestApiContext);
} catch (error) { } catch (error) {

View file

@ -60,7 +60,7 @@ import { getCurlToJson } from '@/api/curlHelper';
import { useCloudPlanStore } from '@/stores/cloudPlan.store'; import { useCloudPlanStore } from '@/stores/cloudPlan.store';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import { useUsersStore } from '@/stores/users.store'; import { hasPermission } from '@/rbac/permissions';
import { useTelemetryStore } from '@/stores/telemetry.store'; import { useTelemetryStore } from '@/stores/telemetry.store';
import { dismissBannerPermanently } from '@/api/ui'; import { dismissBannerPermanently } from '@/api/ui';
import type { BannerName } from 'n8n-workflow'; import type { BannerName } from 'n8n-workflow';
@ -374,9 +374,7 @@ export const useUIStore = defineStore(STORES.UI, {
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
const isOwner = useUsersStore().isInstanceOwner; if (deploymentType === 'cloud' && hasPermission(['instanceOwner'])) {
if (deploymentType === 'cloud' && isOwner) {
const adminPanelHost = new URL(window.location.href).host.split('.').slice(1).join('.'); const adminPanelHost = new URL(window.location.href).host.split('.').slice(1).join('.');
const { code } = await useCloudPlanStore().getAutoLoginCode(); const { code } = await useCloudPlanStore().getAutoLoginCode();
linkUrl = `https://${adminPanelHost}/login`; linkUrl = `https://${adminPanelHost}/login`;

View file

@ -137,7 +137,7 @@ export const useUsersStore = defineStore(STORES.USERS, {
: undefined, : undefined,
isDefaultUser: isDefaultUser(updatedUser), isDefaultUser: isDefaultUser(updatedUser),
isPendingUser: isPendingUser(updatedUser), isPendingUser: isPendingUser(updatedUser),
isOwner: updatedUser.globalRole?.name === ROLE.Owner, isOwner: isInstanceOwner(updatedUser),
}; };
this.users = { this.users = {

View file

@ -5,6 +5,7 @@ import type { IRole } from '@/Interface';
export type AuthenticatedPermissionOptions = {}; export type AuthenticatedPermissionOptions = {};
export type CustomPermissionOptions<C = {}> = RBACPermissionCheck<C>; export type CustomPermissionOptions<C = {}> = RBACPermissionCheck<C>;
export type DefaultUserMiddlewareOptions = {}; export type DefaultUserMiddlewareOptions = {};
export type InstanceOwnerMiddlewareOptions = {};
export type EnterprisePermissionOptions = { export type EnterprisePermissionOptions = {
feature?: EnterpriseEditionFeature | EnterpriseEditionFeature[]; feature?: EnterpriseEditionFeature | EnterpriseEditionFeature[];
mode?: 'oneOf' | 'allOf'; mode?: 'oneOf' | 'allOf';
@ -23,6 +24,7 @@ export type PermissionType =
| 'authenticated' | 'authenticated'
| 'custom' | 'custom'
| 'defaultUser' | 'defaultUser'
| 'instanceOwner'
| 'enterprise' | 'enterprise'
| 'guest' | 'guest'
| 'rbac' | 'rbac'
@ -31,6 +33,7 @@ export type PermissionTypeOptions = {
authenticated: AuthenticatedPermissionOptions; authenticated: AuthenticatedPermissionOptions;
custom: CustomPermissionOptions; custom: CustomPermissionOptions;
defaultUser: DefaultUserMiddlewareOptions; defaultUser: DefaultUserMiddlewareOptions;
instanceOwner: InstanceOwnerMiddlewareOptions;
enterprise: EnterprisePermissionOptions; enterprise: EnterprisePermissionOptions;
guest: GuestPermissionOptions; guest: GuestPermissionOptions;
rbac: RBACPermissionOptions; rbac: RBACPermissionOptions;

View file

@ -28,14 +28,14 @@
<event-destination-card <event-destination-card
:destination="logStreamingStore.items[item.key]?.destination" :destination="logStreamingStore.items[item.key]?.destination"
:eventBus="eventBus" :eventBus="eventBus"
:isInstanceOwner="isInstanceOwner" :readonly="!canManageLogStreaming"
@remove="onRemove(logStreamingStore.items[item.key]?.destination?.id)" @remove="onRemove(logStreamingStore.items[item.key]?.destination?.id)"
@edit="onEdit(logStreamingStore.items[item.key]?.destination?.id)" @edit="onEdit(logStreamingStore.items[item.key]?.destination?.id)"
/> />
</el-col> </el-col>
</el-row> </el-row>
<div class="mt-m text-right"> <div class="mt-m text-right">
<n8n-button v-if="isInstanceOwner" size="large" @click="addDestination"> <n8n-button v-if="canManageLogStreaming" size="large" @click="addDestination">
{{ $locale.baseText(`settings.log-streaming.add`) }} {{ $locale.baseText(`settings.log-streaming.add`) }}
</n8n-button> </n8n-button>
</div> </div>
@ -77,7 +77,7 @@ import { defineComponent, nextTick } from 'vue';
import { mapStores } from 'pinia'; import { mapStores } from 'pinia';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { useUsersStore } from '@/stores/users.store'; import { hasPermission } from '@/rbac/permissions';
import { useCredentialsStore } from '@/stores/credentials.store'; import { useCredentialsStore } from '@/stores/credentials.store';
import { useLogStreamingStore } from '@/stores/logStreaming.store'; import { useLogStreamingStore } from '@/stores/logStreaming.store';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
@ -100,13 +100,11 @@ export default defineComponent({
destinations: Array<MessageEventBusDestinationOptions>, destinations: Array<MessageEventBusDestinationOptions>,
disableLicense: false, disableLicense: false,
allDestinations: [] as MessageEventBusDestinationOptions[], allDestinations: [] as MessageEventBusDestinationOptions[],
isInstanceOwner: false,
}; };
}, },
async mounted() { async mounted() {
if (!this.isLicensed) return; if (!this.isLicensed) return;
this.isInstanceOwner = this.usersStore.currentUser?.globalRole?.name === 'owner';
// Prepare credentialsStore so modals can pick up credentials // Prepare credentialsStore so modals can pick up credentials
await this.credentialsStore.fetchCredentialTypes(false); await this.credentialsStore.fetchCredentialTypes(false);
await this.credentialsStore.fetchAllCredentials(); await this.credentialsStore.fetchAllCredentials();
@ -141,7 +139,6 @@ export default defineComponent({
useLogStreamingStore, useLogStreamingStore,
useWorkflowsStore, useWorkflowsStore,
useUIStore, useUIStore,
useUsersStore,
useCredentialsStore, useCredentialsStore,
), ),
sortedItemKeysByLabel() { sortedItemKeysByLabel() {
@ -158,6 +155,9 @@ export default defineComponent({
if (this.disableLicense) return false; if (this.disableLicense) return false;
return this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.LogStreaming); return this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.LogStreaming);
}, },
canManageLogStreaming(): boolean {
return hasPermission(['rbac'], { rbac: { scope: 'logStreaming:manage' } });
},
}, },
methods: { methods: {
onDestinationWasSaved() { onDestinationWasSaved() {