diff --git a/frontend/src/component/admin/cors/CorsForm.tsx b/frontend/src/component/admin/cors/CorsForm.tsx index c3885e8821c5..0f8e2d7ca4a3 100644 --- a/frontend/src/component/admin/cors/CorsForm.tsx +++ b/frontend/src/component/admin/cors/CorsForm.tsx @@ -1,4 +1,3 @@ -import { ADMIN } from 'component/providers/AccessProvider/permissions'; import type React from 'react'; import { useState } from 'react'; import { TextField, Box } from '@mui/material'; @@ -7,23 +6,30 @@ import { useUiConfigApi } from 'hooks/api/actions/useUiConfigApi/useUiConfigApi' import useToast from 'hooks/useToast'; import { formatUnknownError } from 'utils/formatUnknownError'; import { useId } from 'hooks/useId'; +import { ADMIN, UPDATE_CORS } from '@server/types/permissions'; +import { useUiFlag } from 'hooks/useUiFlag'; interface ICorsFormProps { frontendApiOrigins: string[] | undefined; } export const CorsForm = ({ frontendApiOrigins }: ICorsFormProps) => { - const { setFrontendSettings } = useUiConfigApi(); + const { setFrontendSettings, setCors } = useUiConfigApi(); const { setToastData, setToastApiError } = useToast(); const [value, setValue] = useState(formatInputValue(frontendApiOrigins)); const inputFieldId = useId(); const helpTextId = useId(); + const isGranularPermissionsEnabled = useUiFlag('granularAdminPermissions'); const onSubmit = async (event: React.FormEvent) => { try { const split = parseInputValue(value); event.preventDefault(); - await setFrontendSettings(split); + if (isGranularPermissionsEnabled) { + await setCors(split); + } else { + await setFrontendSettings(split); + } setValue(formatInputValue(split)); setToastData({ text: 'Settings saved', type: 'success' }); } catch (error) { @@ -67,7 +73,7 @@ export const CorsForm = ({ frontendApiOrigins }: ICorsFormProps) => { style: { fontFamily: 'monospace', fontSize: '0.8em' }, }} /> - + ); diff --git a/frontend/src/component/admin/cors/index.tsx b/frontend/src/component/admin/cors/index.tsx index ceda3f629c8b..1f84bfa1dd42 100644 --- a/frontend/src/component/admin/cors/index.tsx +++ b/frontend/src/component/admin/cors/index.tsx @@ -1,15 +1,15 @@ import { PermissionGuard } from 'component/common/PermissionGuard/PermissionGuard'; -import { ADMIN } from 'component/providers/AccessProvider/permissions'; import { PageContent } from 'component/common/PageContent/PageContent'; import { PageHeader } from 'component/common/PageHeader/PageHeader'; import { Box } from '@mui/material'; import { CorsHelpAlert } from 'component/admin/cors/CorsHelpAlert'; import { CorsForm } from 'component/admin/cors/CorsForm'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import { ADMIN, UPDATE_CORS } from '@server/types/permissions'; export const CorsAdmin = () => (
- +
diff --git a/frontend/src/hooks/api/actions/useUiConfigApi/useUiConfigApi.ts b/frontend/src/hooks/api/actions/useUiConfigApi/useUiConfigApi.ts index 9853704beff7..d80c89761fbd 100644 --- a/frontend/src/hooks/api/actions/useUiConfigApi/useUiConfigApi.ts +++ b/frontend/src/hooks/api/actions/useUiConfigApi/useUiConfigApi.ts @@ -5,6 +5,9 @@ export const useUiConfigApi = () => { propagateErrors: true, }); + /** + * @deprecated remove when `granularAdminPermissions` flag is removed + */ const setFrontendSettings = async ( frontendApiOrigins: string[], ): Promise => { @@ -19,8 +22,18 @@ export const useUiConfigApi = () => { await makeRequest(req.caller, req.id); }; + const setCors = async (frontendApiOrigins: string[]): Promise => { + const req = createRequest( + 'api/admin/ui-config/cors', + { method: 'POST', body: JSON.stringify({ frontendApiOrigins }) }, + 'setCors', + ); + await makeRequest(req.caller, req.id); + }; + return { setFrontendSettings, + setCors, loading, errors, }; diff --git a/src/lib/features/frontend-api/frontend-api-service.ts b/src/lib/features/frontend-api/frontend-api-service.ts index 81858f5b2f3b..98b1d3e60158 100644 --- a/src/lib/features/frontend-api/frontend-api-service.ts +++ b/src/lib/features/frontend-api/frontend-api-service.ts @@ -208,6 +208,23 @@ export class FrontendApiService { ); } + async setFrontendCorsSettings( + value: FrontendSettings['frontendApiOrigins'], + auditUser: IAuditUser, + ): Promise { + const error = validateOrigins(value); + if (error) { + throw new BadDataError(error); + } + const settings = (await this.getFrontendSettings()) || {}; + await this.services.settingService.insert( + frontendSettingsKey, + { ...settings, frontendApiOrigins: value }, + auditUser, + false, + ); + } + async fetchFrontendSettings(): Promise { try { this.cachedFrontendSettings = diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index db6f696b1051..f934b0d23b9c 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -180,6 +180,7 @@ export * from './search-features-schema'; export * from './segment-schema'; export * from './segment-strategies-schema'; export * from './segments-schema'; +export * from './set-cors-schema'; export * from './set-strategy-sort-order-schema'; export * from './set-ui-config-schema'; export * from './sort-order-schema'; diff --git a/src/lib/openapi/spec/set-cors-schema.ts b/src/lib/openapi/spec/set-cors-schema.ts new file mode 100644 index 000000000000..6e268e85cd99 --- /dev/null +++ b/src/lib/openapi/spec/set-cors-schema.ts @@ -0,0 +1,20 @@ +import type { FromSchema } from 'json-schema-to-ts'; + +export const setCorsSchema = { + $id: '#/components/schemas/setCorsSchema', + type: 'object', + additionalProperties: false, + description: 'Unleash configuration settings affect the admin UI.', + properties: { + frontendApiOrigins: { + description: + 'The list of origins that the front-end API should accept requests from.', + example: ['*'], + type: 'array', + items: { type: 'string' }, + }, + }, + components: {}, +} as const; + +export type SetCorsSchema = FromSchema; diff --git a/src/lib/routes/admin-api/config.test.ts b/src/lib/routes/admin-api/config.test.ts index 364712cf8eb1..e65da9941393 100644 --- a/src/lib/routes/admin-api/config.test.ts +++ b/src/lib/routes/admin-api/config.test.ts @@ -19,6 +19,11 @@ const uiConfig = { async function getSetup() { const base = `/random${Math.round(Math.random() * 1000)}`; const config = createTestConfig({ + experimental: { + flags: { + granularAdminPermissions: true, + }, + }, server: { baseUriPath: base }, ui: uiConfig, }); @@ -56,3 +61,26 @@ test('should get ui config', async () => { expect(body.segmentValuesLimit).toEqual(DEFAULT_SEGMENT_VALUES_LIMIT); expect(body.strategySegmentsLimit).toEqual(DEFAULT_STRATEGY_SEGMENTS_LIMIT); }); + +test('should update CORS settings', async () => { + const { body } = await request + .get(`${base}/api/admin/ui-config`) + .expect('Content-Type', /json/) + .expect(200); + + expect(body.frontendApiOrigins).toEqual(['*']); + + await request + .post(`${base}/api/admin/ui-config/cors`) + .send({ + frontendApiOrigins: ['https://example.com'], + }) + .expect(204); + + const { body: updatedBody } = await request + .get(`${base}/api/admin/ui-config`) + .expect('Content-Type', /json/) + .expect(200); + + expect(updatedBody.frontendApiOrigins).toEqual(['https://example.com']); +}); diff --git a/src/lib/routes/admin-api/config.ts b/src/lib/routes/admin-api/config.ts index 36c28be817ca..1b206a8509e5 100644 --- a/src/lib/routes/admin-api/config.ts +++ b/src/lib/routes/admin-api/config.ts @@ -10,7 +10,7 @@ import { type SimpleAuthSettings, simpleAuthSettingsKey, } from '../../types/settings/simple-auth-settings'; -import { ADMIN, NONE } from '../../types/permissions'; +import { ADMIN, NONE, UPDATE_CORS } from '../../types/permissions'; import { createResponseSchema } from '../../openapi/util/create-response-schema'; import { uiConfigSchema, @@ -22,6 +22,7 @@ import { emptyResponse } from '../../openapi/util/standard-responses'; import type { IAuthRequest } from '../unleash-types'; import NotFoundError from '../../error/notfound-error'; import type { SetUiConfigSchema } from '../../openapi/spec/set-ui-config-schema'; +import type { SetCorsSchema } from '../../openapi/spec/set-cors-schema'; import { createRequestSchema } from '../../openapi/util/create-request-schema'; import type { FrontendApiService, SessionService } from '../../services'; import type MaintenanceService from '../../features/maintenance/maintenance-service'; @@ -99,6 +100,7 @@ class ConfigController extends Controller { ], }); + // TODO: deprecate when removing `granularAdminPermissions` flag this.route({ method: 'post', path: '', @@ -116,6 +118,24 @@ class ConfigController extends Controller { }), ], }); + + this.route({ + method: 'post', + path: '/cors', + handler: this.setCors, + permission: [ADMIN, UPDATE_CORS], + middleware: [ + openApiService.validPath({ + tags: ['Admin UI'], + summary: 'Sets allowed CORS origins', + description: + 'Sets Cross-Origin Resource Sharing headers for Frontend SDK API.', + operationId: 'setCors', + requestBody: createRequestSchema('setCorsSchema'), + responses: { 204: emptyResponse }, + }), + ], + }); } async getUiConfig( @@ -198,6 +218,30 @@ class ConfigController extends Controller { throw new NotFoundError(); } + + async setCors( + req: IAuthRequest, + res: Response, + ): Promise { + const granularAdminPermissions = this.flagResolver.isEnabled( + 'granularAdminPermissions', + ); + + if (!granularAdminPermissions) { + throw new NotFoundError(); + } + + if (req.body.frontendApiOrigins) { + await this.frontendApiService.setFrontendCorsSettings( + req.body.frontendApiOrigins, + req.audit, + ); + res.sendStatus(204); + return; + } + + throw new NotFoundError(); + } } export default ConfigController; diff --git a/src/lib/types/permissions.ts b/src/lib/types/permissions.ts index d179e93f8833..f0a60aee1ba0 100644 --- a/src/lib/types/permissions.ts +++ b/src/lib/types/permissions.ts @@ -41,8 +41,10 @@ export const CREATE_TAG_TYPE = 'CREATE_TAG_TYPE'; export const UPDATE_TAG_TYPE = 'UPDATE_TAG_TYPE'; export const DELETE_TAG_TYPE = 'DELETE_TAG_TYPE'; +export const READ_LOGS = 'READ_LOGS'; export const UPDATE_MAINTENANCE_MODE = 'UPDATE_MAINTENANCE_MODE'; export const UPDATE_INSTANCE_BANNERS = 'UPDATE_INSTANCE_BANNERS'; +export const UPDATE_CORS = 'UPDATE_CORS'; export const UPDATE_AUTH_CONFIGURATION = 'UPDATE_AUTH_CONFIGURATION'; // Project @@ -147,7 +149,12 @@ export const ROOT_PERMISSION_CATEGORIES = [ }, { label: 'Instance maintenance', - permissions: [UPDATE_MAINTENANCE_MODE, UPDATE_INSTANCE_BANNERS], + permissions: [ + READ_LOGS, + UPDATE_MAINTENANCE_MODE, + UPDATE_INSTANCE_BANNERS, + UPDATE_CORS, + ], }, { label: 'Authentication',