From 4bb80d2bff2ec5e4f914d9dadc4513fd89437b0e Mon Sep 17 00:00:00 2001
From: Tymoteusz Czech <2625371+Tymek@users.noreply.github.com>
Date: Thu, 19 Dec 2024 19:39:24 +0100
Subject: [PATCH] cors setup - backend

---
 .../src/component/admin/cors/CorsForm.tsx     |  4 +-
 frontend/src/component/admin/cors/index.tsx   |  4 +-
 .../frontend-api/frontend-api-service.ts      | 17 +++++++
 src/lib/openapi/spec/index.ts                 |  1 +
 src/lib/openapi/spec/set-cors-schema.ts       | 20 ++++++++
 src/lib/routes/admin-api/config.test.ts       | 28 +++++++++++
 src/lib/routes/admin-api/config.ts            | 46 ++++++++++++++++++-
 7 files changed, 115 insertions(+), 5 deletions(-)
 create mode 100644 src/lib/openapi/spec/set-cors-schema.ts

diff --git a/frontend/src/component/admin/cors/CorsForm.tsx b/frontend/src/component/admin/cors/CorsForm.tsx
index c3885e8821c5..00fa8471bcbe 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,6 +6,7 @@ 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';
 
 interface ICorsFormProps {
     frontendApiOrigins: string[] | undefined;
@@ -67,7 +67,7 @@ export const CorsForm = ({ frontendApiOrigins }: ICorsFormProps) => {
                         style: { fontFamily: 'monospace', fontSize: '0.8em' },
                     }}
                 />
-                <UpdateButton permission={ADMIN} />
+                <UpdateButton permission={[ADMIN, UPDATE_CORS]} />
             </Box>
         </form>
     );
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 = () => (
     <div>
-        <PermissionGuard permissions={ADMIN}>
+        <PermissionGuard permissions={[ADMIN, UPDATE_CORS]}>
             <CorsPage />
         </PermissionGuard>
     </div>
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<void> {
+        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<FrontendSettings> {
         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<typeof setCorsSchema>;
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<void, void, SetCorsSchema>,
+        res: Response<string>,
+    ): Promise<void> {
+        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;