1
0
Эх сурвалжийг харах

feat(core): Add read/write permission support for settings store (#3828)

David Höck 3 сар өмнө
parent
commit
6d585a213f

+ 83 - 0
packages/core/e2e/fixtures/test-plugins/settings-store-rw-permissions-plugin.ts

@@ -0,0 +1,83 @@
+import { Permission, RwPermissionDefinition, SettingsStoreScopes, VendurePlugin } from '@vendure/core';
+
+/**
+ * Custom RwPermissionDefinition for testing dashboard saved views
+ */
+export const dashboardSavedViewsPermission = new RwPermissionDefinition('DashboardSavedViews');
+
+/**
+ * Test plugin that demonstrates the new read/write permission functionality
+ * for settings store fields.
+ */
+@VendurePlugin({
+    configuration: config => {
+        // Add custom permissions
+        config.authOptions = {
+            ...config.authOptions,
+            customPermissions: [
+                ...(config.authOptions?.customPermissions || []),
+                dashboardSavedViewsPermission,
+            ],
+        };
+
+        config.settingsStoreFields = {
+            ...config.settingsStoreFields,
+            rwtest: [
+                {
+                    name: 'separateReadWrite',
+                    scope: SettingsStoreScopes.global,
+                    // User can read with ReadCatalog but write with UpdateCatalog
+                    requiresPermission: {
+                        read: Permission.ReadCatalog,
+                        write: Permission.UpdateCatalog,
+                    },
+                },
+                {
+                    name: 'dashboardSavedViews',
+                    scope: SettingsStoreScopes.global,
+                    // Using custom RwPermissionDefinition for dashboard saved views
+                    requiresPermission: {
+                        read: dashboardSavedViewsPermission.Read,
+                        write: dashboardSavedViewsPermission.Write,
+                    },
+                },
+                {
+                    name: 'multipleReadPermissions',
+                    scope: SettingsStoreScopes.global,
+                    // Multiple permissions for read (OR logic)
+                    requiresPermission: {
+                        read: [Permission.ReadCatalog, Permission.ReadSettings],
+                        write: Permission.UpdateSettings,
+                    },
+                },
+                {
+                    name: 'backwardCompatible',
+                    scope: SettingsStoreScopes.global,
+                    // Still supports old requiresPermission (applies to both read and write)
+                    requiresPermission: Permission.UpdateSettings,
+                },
+                {
+                    name: 'readOnlyAccess',
+                    scope: SettingsStoreScopes.global,
+                    // Read-only field - anyone with ReadSettings can read, no one can write via API
+                    requiresPermission: {
+                        read: Permission.ReadSettings,
+                        // No write permission means only authenticated users can write (will be blocked by readonly)
+                    },
+                    readonly: true,
+                },
+                {
+                    name: 'publicRead',
+                    scope: SettingsStoreScopes.global,
+                    // Anyone authenticated can read, but only admins can write
+                    requiresPermission: {
+                        read: Permission.Authenticated,
+                        write: Permission.CreateAdministrator,
+                    },
+                },
+            ],
+        };
+        return config;
+    },
+})
+export class SettingsStoreRwPermissionsPlugin {}

+ 477 - 0
packages/core/e2e/settings-store-rw-permissions.e2e-spec.ts

@@ -0,0 +1,477 @@
+import { DefaultLogger, LogLevel, mergeConfig, Permission } from '@vendure/core';
+import { createTestEnvironment, SimpleGraphQLClient } from '@vendure/testing';
+import gql from 'graphql-tag';
+import path from 'path';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+import { initialData } from '../../../e2e-common/e2e-initial-data';
+import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
+
+import {
+    dashboardSavedViewsPermission,
+    SettingsStoreRwPermissionsPlugin,
+} from './fixtures/test-plugins/settings-store-rw-permissions-plugin';
+import * as Codegen from './graphql/generated-e2e-admin-types';
+import { CREATE_ADMINISTRATOR, CREATE_ROLE } from './graphql/shared-definitions';
+
+const GET_SETTINGS_STORE_VALUE = gql`
+    query GetSettingsStoreValue($key: String!) {
+        getSettingsStoreValue(key: $key)
+    }
+`;
+
+const SET_SETTINGS_STORE_VALUE = gql`
+    mutation SetSettingsStoreValue($input: SettingsStoreInput!) {
+        setSettingsStoreValue(input: $input) {
+            key
+            result
+            error
+        }
+    }
+`;
+
+describe('Settings Store Read/Write Permissions', () => {
+    const { server, adminClient } = createTestEnvironment(
+        mergeConfig(testConfig(), {
+            logger: new DefaultLogger({ level: LogLevel.Warn }),
+            plugins: [SettingsStoreRwPermissionsPlugin],
+        }),
+    );
+
+    let readCatalogAdmin: Codegen.CreateAdministratorMutation['createAdministrator'];
+    let updateCatalogAdmin: Codegen.CreateAdministratorMutation['createAdministrator'];
+    let readWriteCatalogAdmin: Codegen.CreateAdministratorMutation['createAdministrator'];
+    let customReadAdmin: Codegen.CreateAdministratorMutation['createAdministrator'];
+    let customWriteAdmin: Codegen.CreateAdministratorMutation['createAdministrator'];
+    let customReadWriteAdmin: Codegen.CreateAdministratorMutation['createAdministrator'];
+    let readSettingsAdmin: Codegen.CreateAdministratorMutation['createAdministrator'];
+    let updateSettingsAdmin: Codegen.CreateAdministratorMutation['createAdministrator'];
+    let authenticatedOnlyAdmin: Codegen.CreateAdministratorMutation['createAdministrator'];
+
+    beforeAll(async () => {
+        await server.init({
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
+            customerCount: 1,
+        });
+        await adminClient.asSuperAdmin();
+
+        // Create admins with different permission sets
+        readCatalogAdmin = await createAdminWithPermissions({
+            adminClient,
+            name: 'ReadCatalog',
+            permissions: [Permission.ReadCatalog],
+        });
+
+        updateCatalogAdmin = await createAdminWithPermissions({
+            adminClient,
+            name: 'UpdateCatalog',
+            permissions: [Permission.UpdateCatalog],
+        });
+
+        readWriteCatalogAdmin = await createAdminWithPermissions({
+            adminClient,
+            name: 'ReadWriteCatalog',
+            permissions: [Permission.ReadCatalog, Permission.UpdateCatalog],
+        });
+
+        // Create admins with custom RwPermissionDefinition permissions
+        customReadAdmin = await createAdminWithPermissions({
+            adminClient,
+            name: 'CustomRead',
+            permissions: ['ReadDashboardSavedViews'],
+        });
+
+        customWriteAdmin = await createAdminWithPermissions({
+            adminClient,
+            name: 'CustomWrite',
+            permissions: ['WriteDashboardSavedViews'],
+        });
+
+        customReadWriteAdmin = await createAdminWithPermissions({
+            adminClient,
+            name: 'CustomReadWrite',
+            permissions: ['ReadDashboardSavedViews', 'WriteDashboardSavedViews'],
+        });
+
+        readSettingsAdmin = await createAdminWithPermissions({
+            adminClient,
+            name: 'ReadSettings',
+            permissions: [Permission.ReadSettings],
+        });
+
+        updateSettingsAdmin = await createAdminWithPermissions({
+            adminClient,
+            name: 'UpdateSettings',
+            permissions: [Permission.UpdateSettings],
+        });
+
+        authenticatedOnlyAdmin = await createAdminWithPermissions({
+            adminClient,
+            name: 'AuthenticatedOnly',
+            permissions: [Permission.Authenticated],
+        });
+
+        // Set up initial test data as super admin
+        await adminClient.asSuperAdmin();
+        await adminClient.query(SET_SETTINGS_STORE_VALUE, {
+            input: { key: 'rwtest.separateReadWrite', value: 'initial-separate-value' },
+        });
+        await adminClient.query(SET_SETTINGS_STORE_VALUE, {
+            input: { key: 'rwtest.dashboardSavedViews', value: { viewName: 'Test View', filters: [] } },
+        });
+        await adminClient.query(SET_SETTINGS_STORE_VALUE, {
+            input: { key: 'rwtest.multipleReadPermissions', value: 'multi-read-value' },
+        });
+        await adminClient.query(SET_SETTINGS_STORE_VALUE, {
+            input: { key: 'rwtest.backwardCompatible', value: 'backward-compatible-value' },
+        });
+        await adminClient.query(SET_SETTINGS_STORE_VALUE, {
+            input: { key: 'rwtest.publicRead', value: 'public-read-value' },
+        });
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    describe('Separate read/write permissions (object syntax)', () => {
+        it('user with read permission can read but not write', async () => {
+            await adminClient.asUserWithCredentials(readCatalogAdmin.emailAddress, 'test');
+
+            // Should be able to read
+            const { getSettingsStoreValue } = await adminClient.query(GET_SETTINGS_STORE_VALUE, {
+                key: 'rwtest.separateReadWrite',
+            });
+            expect(getSettingsStoreValue).toBe('initial-separate-value');
+
+            // Should not be able to write
+            const { setSettingsStoreValue } = await adminClient.query(SET_SETTINGS_STORE_VALUE, {
+                input: {
+                    key: 'rwtest.separateReadWrite',
+                    value: 'test-value',
+                },
+            });
+            expect(setSettingsStoreValue.result).toBe(false);
+            expect(setSettingsStoreValue.error).toContain('permissions');
+        });
+
+        it('user with write permission can write but not read', async () => {
+            await adminClient.asUserWithCredentials(updateCatalogAdmin.emailAddress, 'test');
+
+            // Should not be able to read
+            const { getSettingsStoreValue } = await adminClient.query(GET_SETTINGS_STORE_VALUE, {
+                key: 'rwtest.separateReadWrite',
+            });
+            expect(getSettingsStoreValue).toBeNull();
+
+            // Should be able to write
+            const { setSettingsStoreValue } = await adminClient.query(SET_SETTINGS_STORE_VALUE, {
+                input: {
+                    key: 'rwtest.separateReadWrite',
+                    value: 'write-only-value',
+                },
+            });
+            expect(setSettingsStoreValue.result).toBe(true);
+            expect(setSettingsStoreValue.error).toBeNull();
+        });
+
+        it('user with both permissions can read and write', async () => {
+            await adminClient.asUserWithCredentials(readWriteCatalogAdmin.emailAddress, 'test');
+
+            // Should be able to write
+            const { setSettingsStoreValue } = await adminClient.query(SET_SETTINGS_STORE_VALUE, {
+                input: {
+                    key: 'rwtest.separateReadWrite',
+                    value: 'read-write-value',
+                },
+            });
+            expect(setSettingsStoreValue.result).toBe(true);
+
+            // Should be able to read
+            const { getSettingsStoreValue } = await adminClient.query(GET_SETTINGS_STORE_VALUE, {
+                key: 'rwtest.separateReadWrite',
+            });
+            expect(getSettingsStoreValue).toBe('read-write-value');
+        });
+    });
+
+    describe('Custom RwPermissionDefinition', () => {
+        it('user with custom read permission can only read', async () => {
+            await adminClient.asUserWithCredentials(customReadAdmin.emailAddress, 'test');
+
+            // Should be able to read
+            const { getSettingsStoreValue } = await adminClient.query(GET_SETTINGS_STORE_VALUE, {
+                key: 'rwtest.dashboardSavedViews',
+            });
+            expect(getSettingsStoreValue).toEqual({ viewName: 'Test View', filters: [] });
+
+            // Should not be able to write
+            const { setSettingsStoreValue } = await adminClient.query(SET_SETTINGS_STORE_VALUE, {
+                input: {
+                    key: 'rwtest.dashboardSavedViews',
+                    value: { viewName: 'Modified View', filters: [] },
+                },
+            });
+            expect(setSettingsStoreValue.result).toBe(false);
+            expect(setSettingsStoreValue.error).toContain('permissions');
+        });
+
+        it('user with custom write permission can only write', async () => {
+            await adminClient.asUserWithCredentials(customWriteAdmin.emailAddress, 'test');
+
+            // Should not be able to read
+            const { getSettingsStoreValue } = await adminClient.query(GET_SETTINGS_STORE_VALUE, {
+                key: 'rwtest.dashboardSavedViews',
+            });
+            expect(getSettingsStoreValue).toBeNull();
+
+            // Should be able to write
+            const { setSettingsStoreValue } = await adminClient.query(SET_SETTINGS_STORE_VALUE, {
+                input: {
+                    key: 'rwtest.dashboardSavedViews',
+                    value: { viewName: 'Write-Only View', filters: [] },
+                },
+            });
+            expect(setSettingsStoreValue.result).toBe(true);
+        });
+
+        it('user with both custom permissions can read and write', async () => {
+            await adminClient.asUserWithCredentials(customReadWriteAdmin.emailAddress, 'test');
+
+            // Should be able to write
+            const { setSettingsStoreValue } = await adminClient.query(SET_SETTINGS_STORE_VALUE, {
+                input: {
+                    key: 'rwtest.dashboardSavedViews',
+                    value: { viewName: 'Custom RW View', filters: ['filter1'] },
+                },
+            });
+            expect(setSettingsStoreValue.result).toBe(true);
+
+            // Should be able to read
+            const { getSettingsStoreValue } = await adminClient.query(GET_SETTINGS_STORE_VALUE, {
+                key: 'rwtest.dashboardSavedViews',
+            });
+            expect(getSettingsStoreValue).toEqual({ viewName: 'Custom RW View', filters: ['filter1'] });
+        });
+
+        it('user without custom permissions cannot access', async () => {
+            await adminClient.asUserWithCredentials(authenticatedOnlyAdmin.emailAddress, 'test');
+
+            // Should not be able to read
+            const { getSettingsStoreValue } = await adminClient.query(GET_SETTINGS_STORE_VALUE, {
+                key: 'rwtest.dashboardSavedViews',
+            });
+            expect(getSettingsStoreValue).toBeNull();
+
+            // Should not be able to write
+            const { setSettingsStoreValue } = await adminClient.query(SET_SETTINGS_STORE_VALUE, {
+                input: {
+                    key: 'rwtest.dashboardSavedViews',
+                    value: { viewName: 'Unauthorized View', filters: [] },
+                },
+            });
+            expect(setSettingsStoreValue.result).toBe(false);
+            expect(setSettingsStoreValue.error).toContain('permissions');
+        });
+
+        it('demonstrates that RwPermissionDefinition generates correct permission names', () => {
+            // This test documents how to use the new RwPermissionDefinition
+            expect(dashboardSavedViewsPermission.Read).toBe('ReadDashboardSavedViews');
+            expect(dashboardSavedViewsPermission.Write).toBe('WriteDashboardSavedViews');
+        });
+    });
+
+    describe('Multiple read permissions (OR logic)', () => {
+        it('user with one of the read permissions can read', async () => {
+            await adminClient.asUserWithCredentials(readSettingsAdmin.emailAddress, 'test');
+
+            // Can read with ReadSettings permission (one of the allowed)
+            const { getSettingsStoreValue } = await adminClient.query(GET_SETTINGS_STORE_VALUE, {
+                key: 'rwtest.multipleReadPermissions',
+            });
+            expect(getSettingsStoreValue).toBe('multi-read-value');
+
+            // Cannot write (needs UpdateSettings)
+            const { setSettingsStoreValue } = await adminClient.query(SET_SETTINGS_STORE_VALUE, {
+                input: {
+                    key: 'rwtest.multipleReadPermissions',
+                    value: 'unauthorized-write',
+                },
+            });
+            expect(setSettingsStoreValue.result).toBe(false);
+            expect(setSettingsStoreValue.error).toContain('permissions');
+        });
+
+        it('user with the other read permission can also read', async () => {
+            await adminClient.asUserWithCredentials(readCatalogAdmin.emailAddress, 'test');
+
+            // Can read with ReadCatalog permission (the other allowed)
+            const { getSettingsStoreValue } = await adminClient.query(GET_SETTINGS_STORE_VALUE, {
+                key: 'rwtest.multipleReadPermissions',
+            });
+            expect(getSettingsStoreValue).toBe('multi-read-value');
+        });
+
+        it('user with write permission can write', async () => {
+            await adminClient.asUserWithCredentials(updateSettingsAdmin.emailAddress, 'test');
+
+            // Should not be able to read (doesn't have ReadCatalog or ReadSettings)
+            const { getSettingsStoreValue } = await adminClient.query(GET_SETTINGS_STORE_VALUE, {
+                key: 'rwtest.multipleReadPermissions',
+            });
+            expect(getSettingsStoreValue).toBeNull();
+
+            // Should be able to write
+            const { setSettingsStoreValue } = await adminClient.query(SET_SETTINGS_STORE_VALUE, {
+                input: {
+                    key: 'rwtest.multipleReadPermissions',
+                    value: 'write-authorized-value',
+                },
+            });
+            expect(setSettingsStoreValue.result).toBe(true);
+        });
+    });
+
+    describe('Backward compatibility', () => {
+        it('user without required permission cannot read or write', async () => {
+            await adminClient.asUserWithCredentials(readCatalogAdmin.emailAddress, 'test');
+
+            // Cannot read (needs UpdateSettings)
+            const { getSettingsStoreValue } = await adminClient.query(GET_SETTINGS_STORE_VALUE, {
+                key: 'rwtest.backwardCompatible',
+            });
+            expect(getSettingsStoreValue).toBeNull();
+
+            // Cannot write (needs UpdateSettings)
+            const { setSettingsStoreValue } = await adminClient.query(SET_SETTINGS_STORE_VALUE, {
+                input: {
+                    key: 'rwtest.backwardCompatible',
+                    value: 'unauthorized-value',
+                },
+            });
+            expect(setSettingsStoreValue.result).toBe(false);
+            expect(setSettingsStoreValue.error).toContain('permissions');
+        });
+
+        it('user with required permission can read and write', async () => {
+            await adminClient.asUserWithCredentials(updateSettingsAdmin.emailAddress, 'test');
+
+            // Should be able to read
+            const { getSettingsStoreValue } = await adminClient.query(GET_SETTINGS_STORE_VALUE, {
+                key: 'rwtest.backwardCompatible',
+            });
+            expect(getSettingsStoreValue).toBe('backward-compatible-value');
+
+            // Should be able to write
+            const { setSettingsStoreValue } = await adminClient.query(SET_SETTINGS_STORE_VALUE, {
+                input: {
+                    key: 'rwtest.backwardCompatible',
+                    value: 'authorized-backward-value',
+                },
+            });
+            expect(setSettingsStoreValue.result).toBe(true);
+        });
+    });
+
+    describe('Public read with restricted write', () => {
+        it('authenticated user can read but not write', async () => {
+            await adminClient.asUserWithCredentials(authenticatedOnlyAdmin.emailAddress, 'test');
+
+            // Should be able to read (only requires Authenticated)
+            const { getSettingsStoreValue } = await adminClient.query(GET_SETTINGS_STORE_VALUE, {
+                key: 'rwtest.publicRead',
+            });
+            expect(getSettingsStoreValue).toBe('public-read-value');
+
+            // Should not be able to write (requires CreateAdministrator)
+            const { setSettingsStoreValue } = await adminClient.query(SET_SETTINGS_STORE_VALUE, {
+                input: {
+                    key: 'rwtest.publicRead',
+                    value: 'unauthorized-public-write',
+                },
+            });
+            expect(setSettingsStoreValue.result).toBe(false);
+            expect(setSettingsStoreValue.error).toContain('permissions');
+        });
+
+        it('super admin can write', async () => {
+            await adminClient.asSuperAdmin();
+
+            // Should be able to write
+            const { setSettingsStoreValue } = await adminClient.query(SET_SETTINGS_STORE_VALUE, {
+                input: {
+                    key: 'rwtest.publicRead',
+                    value: 'super-admin-write-value',
+                },
+            });
+            expect(setSettingsStoreValue.result).toBe(true);
+        });
+    });
+
+    describe('Read-only fields with permissions', () => {
+        it('read-only field prevents writes even with correct permissions', async () => {
+            await adminClient.asUserWithCredentials(readSettingsAdmin.emailAddress, 'test');
+
+            // Should be able to read
+            const { getSettingsStoreValue } = await adminClient.query(GET_SETTINGS_STORE_VALUE, {
+                key: 'rwtest.readOnlyAccess',
+            });
+            expect(getSettingsStoreValue).toBeNull(); // No initial value set for this field
+
+            // Should fail to write because field is readonly
+            const { setSettingsStoreValue } = await adminClient.query(SET_SETTINGS_STORE_VALUE, {
+                input: {
+                    key: 'rwtest.readOnlyAccess',
+                    value: 'readonly-attempt',
+                },
+            });
+            expect(setSettingsStoreValue.result).toBe(false);
+            expect(setSettingsStoreValue.error).toContain('readonly');
+        });
+
+        it('user without read permission cannot read readonly field', async () => {
+            await adminClient.asUserWithCredentials(authenticatedOnlyAdmin.emailAddress, 'test');
+
+            // Should not be able to read (needs ReadSettings)
+            const { getSettingsStoreValue } = await adminClient.query(GET_SETTINGS_STORE_VALUE, {
+                key: 'rwtest.readOnlyAccess',
+            });
+            expect(getSettingsStoreValue).toBeNull();
+        });
+    });
+});
+
+async function createAdminWithPermissions(input: {
+    adminClient: SimpleGraphQLClient;
+    name: string;
+    permissions: Array<Permission | string>;
+}) {
+    const { adminClient, name, permissions } = input;
+
+    // All permissions are standard - use the typed mutation
+    const { createRole } = await adminClient.query<
+        Codegen.CreateRoleMutation,
+        Codegen.CreateRoleMutationVariables
+    >(CREATE_ROLE, {
+        input: {
+            code: name,
+            description: name,
+            permissions: permissions as Permission[],
+        },
+    });
+
+    const { createAdministrator } = await adminClient.query<
+        Codegen.CreateAdministratorMutation,
+        Codegen.CreateAdministratorMutationVariables
+    >(CREATE_ADMINISTRATOR, {
+        input: {
+            firstName: name,
+            lastName: 'LastName',
+            emailAddress: `${name}@test.com`,
+            roleIds: [createRole.id],
+            password: 'test',
+        },
+    });
+    return createAdministrator;
+}

+ 43 - 25
packages/core/src/api/resolvers/admin/settings-store.resolver.ts

@@ -25,7 +25,7 @@ export class SettingsStoreAdminResolver {
 
     @Query()
     async getSettingsStoreValue(@Ctx() ctx: RequestContext, @Args('key') key: string): Promise<any> {
-        if (!this.settingsStoreService.hasPermission(ctx, key)) {
+        if (!this.settingsStoreService.hasReadPermission(ctx, key)) {
             return undefined;
         }
         return this.settingsStoreService.get(ctx, key);
@@ -38,7 +38,7 @@ export class SettingsStoreAdminResolver {
     ): Promise<Record<string, any>> {
         const permittedKeys = [];
         for (const key of keys) {
-            if (this.settingsStoreService.hasPermission(ctx, key)) {
+            if (this.settingsStoreService.hasReadPermission(ctx, key)) {
                 permittedKeys.push(key);
             }
         }
@@ -50,21 +50,30 @@ export class SettingsStoreAdminResolver {
         @Ctx() ctx: RequestContext,
         @Args('input') input: SettingsStoreInput,
     ): Promise<SetSettingsStoreValueResult> {
-        if (!this.settingsStoreService.hasPermission(ctx, input.key)) {
-            return {
-                key: input.key,
-                result: false,
-                error: ErrorMessage.permissions,
-            };
-        }
-        if (this.settingsStoreService.isReadonly(input.key)) {
+        try {
+            if (!this.settingsStoreService.hasWritePermission(ctx, input.key)) {
+                return {
+                    key: input.key,
+                    result: false,
+                    error: ErrorMessage.permissions,
+                };
+            }
+            if (this.settingsStoreService.isReadonly(input.key)) {
+                return {
+                    key: input.key,
+                    result: false,
+                    error: ErrorMessage.readonly,
+                };
+            }
+            return this.settingsStoreService.set(ctx, input.key, input.value);
+        } catch (error) {
+            // Handle validation errors (e.g., invalid keys) as structured errors
             return {
                 key: input.key,
                 result: false,
-                error: ErrorMessage.readonly,
+                error: error instanceof Error ? error.message : 'Unknown error occurred',
             };
         }
-        return this.settingsStoreService.set(ctx, input.key, input.value);
     }
 
     @Mutation()
@@ -74,23 +83,32 @@ export class SettingsStoreAdminResolver {
     ): Promise<SetSettingsStoreValueResult[]> {
         const results: SetSettingsStoreValueResult[] = [];
         for (const input of inputs) {
-            const hasPermission = this.settingsStoreService.hasPermission(ctx, input.key);
-            const isWritable = !this.settingsStoreService.isReadonly(input.key);
-            if (!hasPermission) {
+            try {
+                const hasPermission = this.settingsStoreService.hasWritePermission(ctx, input.key);
+                const isWritable = !this.settingsStoreService.isReadonly(input.key);
+                if (!hasPermission) {
+                    results.push({
+                        key: input.key,
+                        result: false,
+                        error: ErrorMessage.permissions,
+                    });
+                } else if (!isWritable) {
+                    results.push({
+                        key: input.key,
+                        result: false,
+                        error: ErrorMessage.readonly,
+                    });
+                } else {
+                    const result = await this.settingsStoreService.set(ctx, input.key, input.value);
+                    results.push(result);
+                }
+            } catch (error) {
+                // Handle validation errors (e.g., invalid keys) as structured errors
                 results.push({
                     key: input.key,
                     result: false,
-                    error: ErrorMessage.permissions,
-                });
-            } else if (!isWritable) {
-                results.push({
-                    key: input.key,
-                    result: false,
-                    error: ErrorMessage.readonly,
+                    error: error instanceof Error ? error.message : 'Unknown error occurred',
                 });
-            } else {
-                const result = await this.settingsStoreService.set(ctx, input.key, input.value);
-                results.push(result);
             }
         }
         return results;

+ 81 - 0
packages/core/src/common/permission-definition.ts

@@ -200,3 +200,84 @@ export class CrudPermissionDefinition extends PermissionDefinition {
         return `Delete${this.config.name}` as Permission;
     }
 }
+
+/**
+ * @description
+ * Defines a set of Read-Write Permissions for the given name, i.e. a `name` of 'DashboardSavedViews' will create
+ * 2 Permissions: 'ReadDashboardSavedViews' and 'WriteDashboardSavedViews'.
+ *
+ * @example
+ * ```ts
+ * export const dashboardSavedViews = new RwPermissionDefinition('DashboardSavedViews');
+ * ```
+ *
+ * ```ts
+ * const config: VendureConfig = {
+ *   authOptions: {
+ *     customPermissions: [dashboardSavedViews],
+ *   },
+ * }
+ * ```
+ *
+ * ```ts
+ * \@Resolver()
+ * export class DashboardResolver {
+ *
+ *   \@Allow(dashboardSavedViews.Read)
+ *   \@Query()
+ *   getDashboardSavedViews() {
+ *     // ...
+ *   }
+ *
+ *   \@Allow(dashboardSavedViews.Write)
+ *   \@Mutation()
+ *   saveDashboardView() {
+ *     // ...
+ *   }
+ * }
+ * ```
+ *
+ * @docsCategory auth
+ * @docsPage PermissionDefinition
+ * @docsWeight 2
+ * @since 3.5.0
+ */
+export class RwPermissionDefinition extends PermissionDefinition {
+    constructor(
+        name: string,
+        private descriptionFn?: (operation: 'read' | 'write') => string,
+    ) {
+        super({ name });
+    }
+
+    /** @internal */
+    getMetadata(): PermissionMetadata[] {
+        return ['Read', 'Write'].map(operation => ({
+            name: `${operation}${this.config.name}`,
+            description:
+                typeof this.descriptionFn === 'function'
+                    ? this.descriptionFn(operation.toLocaleLowerCase() as any)
+                    : `Grants permission to ${operation.toLocaleLowerCase()} ${this.config.name}`,
+            assignable: true,
+            internal: false,
+        }));
+    }
+
+    /**
+     * @description
+     * Returns the 'Read' permission defined by this definition, for use in the
+     * {@link Allow} decorator.
+     */
+    get Read(): Permission {
+        return `Read${this.config.name}` as Permission;
+    }
+
+    /**
+     * @description
+     * Returns the 'Write' permission defined by this definition, for use in the
+     * {@link Allow} decorator.
+     */
+    get Write(): Permission {
+        return `Write${this.config.name}` as Permission;
+    }
+}

+ 31 - 1
packages/core/src/config/settings-store/settings-store-types.ts

@@ -65,8 +65,38 @@ export interface SettingsStoreFieldConfig {
      * @description
      * Permissions required to access this field. If not specified,
      * basic authentication is required for admin API access.
+     *
+     * Can be either:
+     * - A single permission or array of permissions (applies to both read and write)
+     * - An object with `read` and `write` properties for granular control
+     *
+     * @example
+     * ```ts
+     * // Single permission for both read and write
+     * requiresPermission: Permission.UpdateSettings
+     *
+     * // Separate read and write permissions
+     * requiresPermission: {
+     *   read: Permission.ReadSettings,
+     *   write: Permission.UpdateSettings
+     * }
+     *
+     * // Using custom RwPermissionDefinition
+     * requiresPermission: {
+     *   read: dashboardSavedViews.Read,
+     *   write: dashboardSavedViews.Write
+     * }
+     * ```
+     * @since 3.5.0 - Added support for object with read/write properties
      */
-    requiresPermission?: Array<Permission | string> | Permission | string;
+    requiresPermission?:
+        | Array<Permission | string>
+        | Permission
+        | string
+        | {
+              read?: Array<Permission | string> | Permission | string;
+              write?: Array<Permission | string> | Permission | string;
+          };
 
     /**
      * @description

+ 93 - 8
packages/core/src/service/helpers/settings-store/settings-store.service.ts

@@ -460,26 +460,111 @@ export class SettingsStoreService implements OnModuleInit {
      * This is not called internally in the get and set methods, so should
      * be used by any methods which are exposing these methods via the GraphQL
      * APIs.
+     * @deprecated Use `hasReadPermission` or `hasWritePermission` for granular control
      */
     hasPermission(ctx: RequestContext, key: string): boolean {
+        // For backwards compatibility, check both read and write permissions
+        return this.hasReadPermission(ctx, key) && this.hasWritePermission(ctx, key);
+    }
+
+    /**
+     * @description
+     * Check if the current user has permission to read a field.
+     * @since 3.5.0
+     */
+    hasReadPermission(ctx: RequestContext, key: string): boolean {
+        // Get field config first - let validation errors (like unregistered keys) bubble up
+        const fieldConfig = this.getFieldConfig(key);
+
         try {
-            const fieldConfig = this.getFieldConfig(key);
-            // Admin API: check required permissions
             const requiredPermissions = fieldConfig.requiresPermission;
+
             if (requiredPermissions) {
-                const permissions = Array.isArray(requiredPermissions)
-                    ? requiredPermissions
-                    : [requiredPermissions];
-                return ctx.userHasPermissions(permissions as any);
+                if (this.isReadWritePermissionObject(requiredPermissions)) {
+                    const readPerms = requiredPermissions.read;
+                    if (readPerms) {
+                        return this.checkSimplePermissions(ctx, readPerms);
+                    }
+                    // If no read permission specified but write is, fall back to authenticated
+                    if (requiredPermissions.write) {
+                        return ctx.userHasPermissions([Permission.Authenticated]);
+                    }
+                } else {
+                    return this.checkSimplePermissions(ctx, requiredPermissions);
+                }
             }
 
-            // Default: require authentication
             return ctx.userHasPermissions([Permission.Authenticated]);
         } catch (error) {
-            return true;
+            // Only catch permission evaluation errors, not field validation errors
+            Logger.error(
+                `Error evaluating read permissions for settings store key "${key}": ${JSON.stringify(error)}`,
+            );
+            return false;
         }
     }
 
+    /**
+     * @description
+     * Check if the current user has permission to write a field.
+     * @since 3.5.0
+     */
+    hasWritePermission(ctx: RequestContext, key: string): boolean {
+        const fieldConfig = this.getFieldConfig(key); // Let validation errors bubble up
+        try {
+            const requiredPermissions = fieldConfig.requiresPermission;
+
+            if (requiredPermissions) {
+                if (this.isReadWritePermissionObject(requiredPermissions)) {
+                    const writePerms = requiredPermissions.write;
+                    if (writePerms) {
+                        return this.checkSimplePermissions(ctx, writePerms);
+                    }
+                    // If no write permission specified but read is, fall back to authenticated
+                    if (requiredPermissions.read) {
+                        return ctx.userHasPermissions([Permission.Authenticated]);
+                    }
+                } else {
+                    return this.checkSimplePermissions(ctx, requiredPermissions);
+                }
+            }
+
+            return ctx.userHasPermissions([Permission.Authenticated]);
+        } catch (error) {
+            Logger.error(
+                `Error evaluating write permissions for settings store key "${key}": ${JSON.stringify(error)}`,
+            );
+            return false;
+        }
+    }
+
+    /**
+     * @description
+     * Helper method to check if a permission configuration is a read/write object.
+     */
+    private isReadWritePermissionObject(permissions: any): permissions is {
+        read?: Array<Permission | string> | Permission | string;
+        write?: Array<Permission | string> | Permission | string;
+    } {
+        return (
+            typeof permissions === 'object' &&
+            !Array.isArray(permissions) &&
+            ('read' in permissions || 'write' in permissions)
+        );
+    }
+
+    /**
+     * @description
+     * Helper method to check simple permissions (single permission, array, or string).
+     */
+    private checkSimplePermissions(
+        ctx: RequestContext,
+        permissions: Array<Permission | string> | Permission | string,
+    ): boolean {
+        const permissionArray = Array.isArray(permissions) ? permissions : [permissions];
+        return ctx.userHasPermissions(permissionArray as Permission[]);
+    }
+
     /**
      * @description
      * Returns true if the settings field has the `readonly: true` configuration.