diff --git a/frontend/src/component/admin/auth/AuthSettings.tsx b/frontend/src/component/admin/auth/AuthSettings.tsx index 59856f3bbda2..59df7bfd3751 100644 --- a/frontend/src/component/admin/auth/AuthSettings.tsx +++ b/frontend/src/component/admin/auth/AuthSettings.tsx @@ -1,4 +1,4 @@ -import { Alert, Tab, Tabs } from '@mui/material'; +import { Tab, Tabs } from '@mui/material'; import { PageContent } from 'component/common/PageContent/PageContent'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; @@ -15,7 +15,6 @@ import { TabPanel } from 'component/common/TabNav/TabPanel/TabPanel'; import { usePageTitle } from 'hooks/usePageTitle'; export const AuthSettings = () => { - const { authenticationType } = useUiConfig().uiConfig; const { uiConfig, isEnterprise } = useUiConfig(); const tabs = [ @@ -35,17 +34,14 @@ export const AuthSettings = () => { label: 'Google', component: , }, + { + label: 'SCIM', + component: , + }, ].filter( (item) => uiConfig.flags?.googleAuthEnabled || item.label !== 'Google', ); - if (isEnterprise()) { - tabs.push({ - label: 'SCIM', - component: , - }); - } - const [activeTab, setActiveTab] = useState(0); usePageTitle(`Single sign-on: ${tabs[activeTab].label}`); @@ -56,7 +52,7 @@ export const AuthSettings = () => { withTabs header={ { } > } - /> - - You are running Unleash in demo mode. You have - to use the Enterprise edition in order configure - Single Sign-on. - - } - /> - - You have decided to use custom authentication - type. You have to use the Enterprise edition in - order configure Single Sign-on from the user - interface. - - } - /> - - Your Unleash instance is managed by the Unleash - team. - - } - /> - {tabs.map((tab, index) => ( @@ -133,6 +95,7 @@ export const AuthSettings = () => { ))} } + elseShow={} /> 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/component/admin/groups/GroupsList/GroupsList.tsx b/frontend/src/component/admin/groups/GroupsList/GroupsList.tsx index d15098522c0b..0b1d66cd0d52 100644 --- a/frontend/src/component/admin/groups/GroupsList/GroupsList.tsx +++ b/frontend/src/component/admin/groups/GroupsList/GroupsList.tsx @@ -23,6 +23,7 @@ const StyledGridContainer = styled('div')(({ theme }) => ({ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: theme.spacing(2), + gridAutoRows: '1fr', })); type PageQueryType = Partial>; diff --git a/frontend/src/component/admin/users/UsersHeader/UsersHeader.tsx b/frontend/src/component/admin/users/UsersHeader/UsersHeader.tsx index 43b6edb6233f..61f984d09e7f 100644 --- a/frontend/src/component/admin/users/UsersHeader/UsersHeader.tsx +++ b/frontend/src/component/admin/users/UsersHeader/UsersHeader.tsx @@ -1,6 +1,5 @@ import { Box, styled } from '@mui/material'; import { InviteLinkBar } from '../InviteLinkBar/InviteLinkBar'; -import { useUiFlag } from 'hooks/useUiFlag'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import { LicensedUsersBox } from './LicensedUsersBox'; @@ -24,9 +23,8 @@ const StyledElement = styled(Box)(({ theme }) => ({ })); export const UsersHeader = () => { - const licensedUsers = useUiFlag('licensedUsers'); const { isOss } = useUiConfig(); - const licensedUsersEnabled = licensedUsers && !isOss(); + const licensedUsersEnabled = !isOss(); return ( diff --git a/frontend/src/component/events/EventPage/EventPage.tsx b/frontend/src/component/events/EventPage/EventPage.tsx index a5155a309bcc..ad5f572c7648 100644 --- a/frontend/src/component/events/EventPage/EventPage.tsx +++ b/frontend/src/component/events/EventPage/EventPage.tsx @@ -1,9 +1,9 @@ -import { ADMIN } from 'component/providers/AccessProvider/permissions'; import { PermissionGuard } from 'component/common/PermissionGuard/PermissionGuard'; import { EventLog } from 'component/events/EventLog/EventLog'; +import { READ_LOGS, ADMIN } from '@server/types/permissions'; export const EventPage = () => ( - + ); diff --git a/frontend/src/component/loginHistory/LoginHistory.tsx b/frontend/src/component/loginHistory/LoginHistory.tsx index bbd8b4674a2b..319cd92055d7 100644 --- a/frontend/src/component/loginHistory/LoginHistory.tsx +++ b/frontend/src/component/loginHistory/LoginHistory.tsx @@ -3,6 +3,7 @@ import { PermissionGuard } from 'component/common/PermissionGuard/PermissionGuar import { LoginHistoryTable } from './LoginHistoryTable/LoginHistoryTable'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import { PremiumFeature } from 'component/common/PremiumFeature/PremiumFeature'; +import { READ_LOGS } from '@server/types/permissions'; export const LoginHistory = () => { const { isEnterprise } = useUiConfig(); @@ -13,7 +14,7 @@ export const LoginHistory = () => { return (
- +
diff --git a/frontend/src/component/releases/ReleasePlanTemplate/TemplateForm/MilestoneList/MilestoneCard/MilestoneCard.tsx b/frontend/src/component/releases/ReleasePlanTemplate/TemplateForm/MilestoneList/MilestoneCard/MilestoneCard.tsx index 10e115c6df92..87a269f77de0 100644 --- a/frontend/src/component/releases/ReleasePlanTemplate/TemplateForm/MilestoneList/MilestoneCard/MilestoneCard.tsx +++ b/frontend/src/component/releases/ReleasePlanTemplate/TemplateForm/MilestoneList/MilestoneCard/MilestoneCard.tsx @@ -71,9 +71,14 @@ const StyledAccordion = styled(Accordion)(({ theme }) => ({ const StyledAccordionSummary = styled(AccordionSummary)(({ theme }) => ({ boxShadow: 'none', padding: theme.spacing(1.5, 2), + borderRadius: theme.shape.borderRadiusMedium, [theme.breakpoints.down(400)]: { padding: theme.spacing(1, 2), }, + '&.Mui-focusVisible': { + backgroundColor: theme.palette.background.paper, + padding: theme.spacing(0.5, 2, 0.3, 2), + }, })); const StyledAccordionDetails = styled(AccordionDetails)(({ theme }) => ({ diff --git a/frontend/src/component/releases/ReleasePlanTemplate/TemplateForm/MilestoneList/MilestoneCard/MilestoneCardName.tsx b/frontend/src/component/releases/ReleasePlanTemplate/TemplateForm/MilestoneList/MilestoneCard/MilestoneCardName.tsx index 602bd8969383..d348f1485759 100644 --- a/frontend/src/component/releases/ReleasePlanTemplate/TemplateForm/MilestoneList/MilestoneCard/MilestoneCardName.tsx +++ b/frontend/src/component/releases/ReleasePlanTemplate/TemplateForm/MilestoneList/MilestoneCard/MilestoneCardName.tsx @@ -57,6 +57,10 @@ export const MilestoneCardName = ({ setEditMode(false); } }} + onClick={(ev) => { + ev.preventDefault(); + ev.stopPropagation(); + }} /> )} {!editMode && ( 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/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index 8c15358e56f9..792d7aef1c2d 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -89,7 +89,6 @@ export type UiFlags = { productivityReportEmail?: boolean; showUserDeviceCount?: boolean; flagOverviewRedesign?: boolean; - licensedUsers?: boolean; granularAdminPermissions?: boolean; }; diff --git a/package.json b/package.json index 237bb76cc171..ad39e457d898 100644 --- a/package.json +++ b/package.json @@ -167,7 +167,7 @@ "stoppable": "^1.1.0", "ts-toolbelt": "^9.6.0", "type-is": "^1.6.18", - "unleash-client": "^6.3.3", + "unleash-client": "^6.4.0", "uuid": "^9.0.0" }, "devDependencies": { diff --git a/src/lib/features/client-feature-toggles/client-feature-toggle-service.ts b/src/lib/features/client-feature-toggles/client-feature-toggle-service.ts index a0d85e042ec2..cbcc77fd6f6d 100644 --- a/src/lib/features/client-feature-toggles/client-feature-toggle-service.ts +++ b/src/lib/features/client-feature-toggles/client-feature-toggle-service.ts @@ -9,10 +9,8 @@ import type { import type { Logger } from '../../logger'; import type { FeatureConfigurationClient } from '../feature-toggle/types/feature-toggle-strategies-store-type'; -import type { - RevisionDeltaEntry, - ClientFeatureToggleDelta, -} from './delta/client-feature-toggle-delta'; +import type { ClientFeatureToggleDelta } from './delta/client-feature-toggle-delta'; +import type { ClientFeaturesDeltaSchema } from '../../openapi'; export class ClientFeatureToggleService { private logger: Logger; @@ -44,7 +42,7 @@ export class ClientFeatureToggleService { async getClientDelta( revisionId: number | undefined, query: IFeatureToggleQuery, - ): Promise { + ): Promise { if (this.clientFeatureToggleDelta !== null) { return this.clientFeatureToggleDelta.getDelta(revisionId, query); } else { diff --git a/src/lib/features/client-feature-toggles/tests/client-feature-delta-api.e2e.test.ts b/src/lib/features/client-feature-toggles/delta/client-feature-delta-api.e2e.test.ts similarity index 80% rename from src/lib/features/client-feature-toggles/tests/client-feature-delta-api.e2e.test.ts rename to src/lib/features/client-feature-toggles/delta/client-feature-delta-api.e2e.test.ts index ed729de825a0..fd540ba4185f 100644 --- a/src/lib/features/client-feature-toggles/tests/client-feature-delta-api.e2e.test.ts +++ b/src/lib/features/client-feature-toggles/delta/client-feature-delta-api.e2e.test.ts @@ -140,3 +140,37 @@ const syncRevisions = async () => { // @ts-ignore await app.services.clientFeatureToggleService.clientFeatureToggleDelta.onUpdateRevisionEvent(); }; + +test('archived features should not be returned as updated', async () => { + await app.createFeature('base_feature'); + await syncRevisions(); + const { body } = await app.request.get('/api/client/delta').expect(200); + const currentRevisionId = body.revisionId; + + expect(body).toMatchObject({ + updated: [ + { + name: 'base_feature', + }, + ], + }); + + await app.archiveFeature('base_feature'); + await app.createFeature('new_feature'); + + await syncRevisions(); + + const { body: deltaBody } = await app.request + .get('/api/client/delta') + .set('If-None-Match', currentRevisionId) + .expect(200); + + expect(deltaBody).toMatchObject({ + updated: [ + { + name: 'new_feature', + }, + ], + removed: ['base_feature'], + }); +}); diff --git a/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta-controller.ts b/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta-controller.ts index afa0769be76e..4c153a773ce0 100644 --- a/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta-controller.ts +++ b/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta-controller.ts @@ -17,8 +17,10 @@ import type { OpenApiService } from '../../../services/openapi-service'; import { NONE } from '../../../types/permissions'; import { createResponseSchema } from '../../../openapi/util/create-response-schema'; import type { ClientFeatureToggleService } from '../client-feature-toggle-service'; -import type { RevisionDeltaEntry } from './client-feature-toggle-delta'; -import { clientFeaturesDeltaSchema } from '../../../openapi'; +import { + type ClientFeaturesDeltaSchema, + clientFeaturesDeltaSchema, +} from '../../../openapi'; import type { QueryOverride } from '../client-feature-toggle.controller'; export default class ClientFeatureToggleDeltaController extends Controller { @@ -75,7 +77,7 @@ export default class ClientFeatureToggleDeltaController extends Controller { async getDelta( req: IAuthRequest, - res: Response, + res: Response, ): Promise { if (!this.flagResolver.isEnabled('deltaApi')) { throw new NotFoundError(); diff --git a/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta-read-model-type.ts b/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta-read-model-type.ts index 3b82bd508faa..cf0ec977c898 100644 --- a/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta-read-model-type.ts +++ b/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta-read-model-type.ts @@ -4,7 +4,7 @@ import type { FeatureConfigurationClient } from '../../feature-toggle/types/feat export interface FeatureConfigurationDeltaClient extends FeatureConfigurationClient { description: string; - impressionData: false; + impressionData: boolean; } export interface IClientFeatureToggleDeltaReadModel { diff --git a/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta.ts b/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta.ts index 79e04578a252..38234ea1df25 100644 --- a/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta.ts +++ b/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta.ts @@ -17,6 +17,7 @@ import type { import { CLIENT_DELTA_MEMORY } from '../../../metric-events'; import type EventEmitter from 'events'; import type { Logger } from '../../../logger'; +import type { ClientFeaturesDeltaSchema } from '../../../openapi'; type DeletedFeature = { name: string; @@ -79,6 +80,7 @@ const filterRevisionByProject = ( (feature) => projects.includes('*') || projects.includes(feature.project), ); + return { ...revision, updated, removed }; }; @@ -153,7 +155,7 @@ export class ClientFeatureToggleDelta { async getDelta( sdkRevisionId: number | undefined, query: IFeatureToggleQuery, - ): Promise { + ): Promise { const projects = query.project ? query.project : ['*']; const environment = query.environment ? query.environment : 'default'; // TODO: filter by tags, what is namePrefix? anything else? @@ -181,9 +183,10 @@ export class ClientFeatureToggleDelta { projects, ); - const revisionResponse = { + const revisionResponse: ClientFeaturesDeltaSchema = { ...compressedRevision, segments: this.segments, + removed: compressedRevision.removed.map((feature) => feature.name), }; return Promise.resolve(revisionResponse); @@ -197,6 +200,9 @@ export class ClientFeatureToggleDelta { } } + /** + * This is used in client-feature-delta-api.e2e.test.ts, do not remove + */ public resetDelta() { this.delta = {}; } @@ -217,6 +223,7 @@ export class ClientFeatureToggleDelta { ...new Set( changeEvents .filter((event) => event.featureName) + .filter((event) => event.type !== 'feature-archived') .map((event) => event.featureName!), ), ]; diff --git a/src/lib/features/context/context.ts b/src/lib/features/context/context.ts index ca21648ac26f..46bfcf6e232f 100644 --- a/src/lib/features/context/context.ts +++ b/src/lib/features/context/context.ts @@ -40,6 +40,7 @@ import type { UpdateContextFieldSchema } from '../../openapi/spec/update-context import type { CreateContextFieldSchema } from '../../openapi/spec/create-context-field-schema'; import { extractUserIdFromUser } from '../../util'; import type { LegalValueSchema } from '../../openapi'; +import type { WithTransactional } from '../../db/transaction'; interface ContextParam { contextField: string; @@ -50,7 +51,7 @@ interface DeleteLegalValueParam extends ContextParam { } export class ContextController extends Controller { - private contextService: ContextService; + private transactionalContextService: WithTransactional; private openApiService: OpenApiService; @@ -59,14 +60,17 @@ export class ContextController extends Controller { constructor( config: IUnleashConfig, { - contextService, + transactionalContextService, openApiService, - }: Pick, + }: Pick< + IUnleashServices, + 'transactionalContextService' | 'openApiService' + >, ) { super(config); this.openApiService = openApiService; this.logger = config.getLogger('/admin-api/context.ts'); - this.contextService = contextService; + this.transactionalContextService = transactionalContextService; this.route({ method: 'get', @@ -257,7 +261,9 @@ export class ContextController extends Controller { res: Response, ): Promise { res.status(200) - .json(serializeDates(await this.contextService.getAll())) + .json( + serializeDates(await this.transactionalContextService.getAll()), + ) .end(); } @@ -268,7 +274,7 @@ export class ContextController extends Controller { try { const name = req.params.contextField; const contextField = - await this.contextService.getContextField(name); + await this.transactionalContextService.getContextField(name); this.openApiService.respondWithValidation( 200, res, @@ -286,9 +292,8 @@ export class ContextController extends Controller { ): Promise { const value = req.body; - const result = await this.contextService.createContextField( - value, - req.audit, + const result = await this.transactionalContextService.transactional( + (service) => service.createContextField(value, req.audit), ); this.openApiService.respondWithValidation( @@ -307,9 +312,8 @@ export class ContextController extends Controller { const name = req.params.contextField; const contextField = req.body; - await this.contextService.updateContextField( - { ...contextField, name }, - req.audit, + await this.transactionalContextService.transactional((service) => + service.updateContextField({ ...contextField, name }, req.audit), ); res.status(200).end(); } @@ -321,9 +325,8 @@ export class ContextController extends Controller { const name = req.params.contextField; const legalValue = req.body; - await this.contextService.updateLegalValue( - { name, legalValue }, - req.audit, + await this.transactionalContextService.transactional((service) => + service.updateLegalValue({ name, legalValue }, req.audit), ); res.status(200).end(); } @@ -335,9 +338,8 @@ export class ContextController extends Controller { const name = req.params.contextField; const legalValue = req.params.legalValue; - await this.contextService.deleteLegalValue( - { name, legalValue }, - req.audit, + await this.transactionalContextService.transactional((service) => + service.deleteLegalValue({ name, legalValue }, req.audit), ); res.status(200).end(); } @@ -348,7 +350,9 @@ export class ContextController extends Controller { ): Promise { const name = req.params.contextField; - await this.contextService.deleteContextField(name, req.audit); + await this.transactionalContextService.transactional((service) => + service.deleteContextField(name, req.audit), + ); res.status(200).end(); } @@ -358,7 +362,7 @@ export class ContextController extends Controller { ): Promise { const { name } = req.body; - await this.contextService.validateName(name); + await this.transactionalContextService.validateName(name); res.status(200).end(); } @@ -369,7 +373,7 @@ export class ContextController extends Controller { const { contextField } = req.params; const { user } = req; const contextFields = - await this.contextService.getStrategiesByContextField( + await this.transactionalContextService.getStrategiesByContextField( contextField, extractUserIdFromUser(user), ); diff --git a/src/lib/features/feature-search/feature.search.e2e.test.ts b/src/lib/features/feature-search/feature.search.e2e.test.ts index ea836622523d..ed7f3368f345 100644 --- a/src/lib/features/feature-search/feature.search.e2e.test.ts +++ b/src/lib/features/feature-search/feature.search.e2e.test.ts @@ -6,7 +6,12 @@ import { } from '../../../test/e2e/helpers/test-helper'; import getLogger from '../../../test/fixtures/no-logger'; import type { FeatureSearchQueryParameters } from '../../openapi/spec/feature-search-query-parameters'; -import { DEFAULT_PROJECT, type IUnleashStores } from '../../types'; +import { + CREATE_FEATURE_STRATEGY, + DEFAULT_PROJECT, + type IUnleashStores, + UPDATE_FEATURE_ENVIRONMENT, +} from '../../types'; import { DEFAULT_ENV } from '../../util'; let app: IUnleashTest; @@ -29,7 +34,7 @@ beforeAll(async () => { ); stores = db.stores; - await app.request + const { body } = await app.request .post(`/auth/demo/login`) .send({ email: 'user@getunleash.io', @@ -43,12 +48,30 @@ beforeAll(async () => { await app.linkProjectToEnvironment('default', 'development'); + await stores.accessStore.addPermissionsToRole( + body.rootRole, + [ + { name: UPDATE_FEATURE_ENVIRONMENT }, + { name: CREATE_FEATURE_STRATEGY }, + ], + 'development', + ); + await stores.environmentStore.create({ name: 'production', type: 'production', }); await app.linkProjectToEnvironment('default', 'production'); + + await stores.accessStore.addPermissionsToRole( + body.rootRole, + [ + { name: UPDATE_FEATURE_ENVIRONMENT }, + { name: CREATE_FEATURE_STRATEGY }, + ], + 'production', + ); }); afterAll(async () => { diff --git a/src/lib/features/frontend-api/frontend-api-service.ts b/src/lib/features/frontend-api/frontend-api-service.ts index 81858f5b2f3b..b2dd9fd39f07 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(false)) || {}; + await this.services.settingService.insert( + frontendSettingsKey, + { ...settings, frontendApiOrigins: value }, + auditUser, + false, + ); + } + async fetchFrontendSettings(): Promise { try { this.cachedFrontendSettings = diff --git a/src/lib/features/project/project-service.e2e.test.ts b/src/lib/features/project/project-service.e2e.test.ts index c7e5cf9c44b8..16a46dda2fe9 100644 --- a/src/lib/features/project/project-service.e2e.test.ts +++ b/src/lib/features/project/project-service.e2e.test.ts @@ -805,6 +805,11 @@ describe('Managing Project access', () => { mode: 'open' as const, defaultStickiness: 'clientId', }; + await db.stores.environmentStore.create({ + name: 'production', + type: 'production', + enabled: true, + }); const auditUser = extractAuditInfoFromUser(user); await projectService.createProject(project, user, auditUser); diff --git a/src/lib/openapi/spec/client-features-delta-schema.test.ts b/src/lib/openapi/spec/client-features-delta-schema.test.ts new file mode 100644 index 000000000000..0510137771e6 --- /dev/null +++ b/src/lib/openapi/spec/client-features-delta-schema.test.ts @@ -0,0 +1,27 @@ +import { validateSchema } from '../validate'; +import type { ClientFeaturesDeltaSchema } from './client-features-delta-schema'; + +test('clientFeaturesDeltaSchema all fields', () => { + const data: ClientFeaturesDeltaSchema = { + revisionId: 6, + updated: [ + { + impressionData: false, + enabled: false, + name: 'base_feature', + description: null, + project: 'default', + stale: false, + type: 'release', + variants: [], + strategies: [], + }, + ], + removed: [], + segments: [], + }; + + expect( + validateSchema('#/components/schemas/clientFeaturesDeltaSchema', data), + ).toBeUndefined(); +}); diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index 38b878c2c5ee..f22c5fa1acc4 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -177,6 +177,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..c3f05a742102 --- /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 CORS configuration.', + 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 ec19b96e86ce..93f1d32976c3 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( @@ -197,6 +217,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/services/index.ts b/src/lib/services/index.ts index f40705d2479d..3cdc3fe1ba02 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -200,9 +200,10 @@ export const createServices = ( ? new FeatureLifecycleReadModel(db, config.flagResolver) : new FakeFeatureLifecycleReadModel(); - const contextService = db + const transactionalContextService = db ? withTransactional(createContextService(config), db) : withFakeTransactional(createFakeContextService(config)); + const contextService = transactionalContextService; const emailService = new EmailService(config); const featureTypeService = new FeatureTypeService( stores, @@ -434,6 +435,7 @@ export const createServices = ( clientInstanceService, clientMetricsServiceV2, contextService, + transactionalContextService, versionService, apiTokenService, emailService, diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index a6248820f56c..5e0f22052a14 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -56,12 +56,12 @@ export type IFlagKey = | 'showUserDeviceCount' | 'deleteStaleUserSessions' | 'memorizeStats' - | 'licensedUsers' | 'granularAdminPermissions' | 'streaming' | 'etagVariant' | 'oidcRedirect' - | 'deltaApi'; + | 'deltaApi' + | 'newHostedAuthHandler'; export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>; @@ -265,10 +265,6 @@ const flags: IFlags = { process.env.UNLEASH_EXPERIMENTAL_FLAG_OVERVIEW_REDESIGN, false, ), - licensedUsers: parseEnvVarBoolean( - process.env.UNLEASH_EXPERIMENTAL_FLAG_LICENSED_USERS, - false, - ), granularAdminPermissions: parseEnvVarBoolean( process.env.UNLEASH_EXPERIMENTAL_GRANULAR_ADMIN_PERMISSIONS, false, @@ -290,6 +286,10 @@ const flags: IFlags = { process.env.UNLEASH_EXPERIMENTAL_DELTA_API, false, ), + newHostedAuthHandler: parseEnvVarBoolean( + process.env.UNLEASH_EXPERIMENTAL_NEW_HOSTED_AUTH_HANDLER, + false, + ), }; export const defaultExperimentalOptions: IExperimentalOptions = { diff --git a/src/lib/types/option.ts b/src/lib/types/option.ts index b4919ad34152..98cd76f8cc8f 100644 --- a/src/lib/types/option.ts +++ b/src/lib/types/option.ts @@ -54,7 +54,13 @@ export interface IVersionOption { export enum IAuthType { OPEN_SOURCE = 'open-source', DEMO = 'demo', + /** + * Self-hosted by the customer. Should eventually be renamed to better reflect this. + */ ENTERPRISE = 'enterprise', + /** + * Hosted by Unleash. + */ HOSTED = 'hosted', CUSTOM = 'custom', NONE = 'none', diff --git a/src/lib/types/permissions.ts b/src/lib/types/permissions.ts index d179e93f8833..bbf07539eaa3 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', @@ -162,4 +169,5 @@ export const MAINTENANCE_MODE_PERMISSIONS = [ READ_CLIENT_API_TOKEN, READ_FRONTEND_API_TOKEN, UPDATE_MAINTENANCE_MODE, + READ_LOGS, ]; diff --git a/src/lib/types/services.ts b/src/lib/types/services.ts index 711bd4abf6ec..5fa6d107c7fc 100644 --- a/src/lib/types/services.ts +++ b/src/lib/types/services.ts @@ -69,6 +69,7 @@ export interface IUnleashServices { clientInstanceService: ClientInstanceService; clientMetricsServiceV2: ClientMetricsServiceV2; contextService: ContextService; + transactionalContextService: WithTransactional; emailService: EmailService; environmentService: EnvironmentService; transactionalEnvironmentService: WithTransactional; diff --git a/src/migrations/20241129102205-add-foreign-key-to-role-permissions.js b/src/migrations/20241129102205-add-foreign-key-to-role-permissions.js new file mode 100644 index 000000000000..f2e44bc69f3c --- /dev/null +++ b/src/migrations/20241129102205-add-foreign-key-to-role-permissions.js @@ -0,0 +1,20 @@ +exports.up = function (db, cb) { + db.runSql( + ` + UPDATE role_permission SET environment = null where environment = ''; + DELETE FROM role_permission WHERE environment IS NOT NULL AND environment NOT IN (SELECT name FROM environments); + ALTER TABLE role_permission ADD CONSTRAINT fk_role_permission_environment FOREIGN KEY (environment) REFERENCES environments(name) ON DELETE CASCADE; + `, + cb, + ); +}; + +exports.down = function (db, cb) { + db.runSql( + ` + ALTER TABLE role_permission + DROP CONSTRAINT IF EXISTS fk_role_permission_environment; + `, + cb, + ); +}; diff --git a/src/migrations/20250102150900-add-permission-read-logs.js b/src/migrations/20250102150900-add-permission-read-logs.js new file mode 100644 index 000000000000..e42096a8a063 --- /dev/null +++ b/src/migrations/20250102150900-add-permission-read-logs.js @@ -0,0 +1,13 @@ +'use strict'; + +exports.up = function (db, cb) { + db.runSql(` + INSERT INTO permissions (permission, display_name, type) VALUES ('READ_LOGS', 'Read instance logs and login history', 'root'); + `, cb); +} + +exports.down = function (db, cb) { + db.runSql(` + DELETE FROM permissions WHERE permission IN ('READ_LOGS'); + `, cb); +} diff --git a/src/server-dev.ts b/src/server-dev.ts index ef98f3895fa4..22aea88451de 100644 --- a/src/server-dev.ts +++ b/src/server-dev.ts @@ -53,7 +53,6 @@ process.nextTick(async () => { releasePlans: false, showUserDeviceCount: true, flagOverviewRedesign: false, - licensedUsers: true, granularAdminPermissions: true, deltaApi: true, }, diff --git a/src/test/e2e/helpers/database-init.ts b/src/test/e2e/helpers/database-init.ts index 8ce1e28482bc..5182282d741d 100644 --- a/src/test/e2e/helpers/database-init.ts +++ b/src/test/e2e/helpers/database-init.ts @@ -21,9 +21,19 @@ delete process.env.DATABASE_URL; // because of db-migrate bug (https://github.com/Unleash/unleash/issues/171) process.setMaxListeners(0); +async function getDefaultEnvRolePermissions(knex) { + return knex.table('role_permission').whereIn('environment', ['default']); +} + +async function restoreRolePermissions(knex, rolePermissions) { + await knex.table('role_permission').insert(rolePermissions); +} + async function resetDatabase(knex) { return Promise.all([ - knex.table('environments').del(), + knex + .table('environments') + .del(), // deletes role permissions transitively knex.table('strategies').del(), knex.table('features').del(), knex.table('client_applications').del(), @@ -110,15 +120,20 @@ export default async function init( const testDb = createDb(config); const stores = await createStores(config, testDb); stores.eventStore.setMaxListeners(0); + const defaultRolePermissions = await getDefaultEnvRolePermissions(testDb); await resetDatabase(testDb); await setupDatabase(stores); + await restoreRolePermissions(testDb, defaultRolePermissions); return { rawDatabase: testDb, stores, reset: async () => { + const defaultRolePermissions = + await getDefaultEnvRolePermissions(testDb); await resetDatabase(testDb); await setupDatabase(stores); + await restoreRolePermissions(testDb, defaultRolePermissions); }, destroy: async () => { return new Promise((resolve, reject) => { diff --git a/src/test/e2e/services/access-service.e2e.test.ts b/src/test/e2e/services/access-service.e2e.test.ts index 8f2cbd8c317f..75fe15e356e9 100644 --- a/src/test/e2e/services/access-service.e2e.test.ts +++ b/src/test/e2e/services/access-service.e2e.test.ts @@ -94,8 +94,6 @@ const createRole = async (rolePermissions: PermissionRef[]) => { const hasCommonProjectAccess = async (user, projectName, condition) => { const defaultEnv = 'default'; - const developmentEnv = 'development'; - const productionEnv = 'production'; const { CREATE_FEATURE, @@ -155,70 +153,6 @@ const hasCommonProjectAccess = async (user, projectName, condition) => { defaultEnv, ), ).toBe(condition); - expect( - await accessService.hasPermission( - user, - CREATE_FEATURE_STRATEGY, - projectName, - developmentEnv, - ), - ).toBe(condition); - expect( - await accessService.hasPermission( - user, - UPDATE_FEATURE_STRATEGY, - projectName, - developmentEnv, - ), - ).toBe(condition); - expect( - await accessService.hasPermission( - user, - DELETE_FEATURE_STRATEGY, - projectName, - developmentEnv, - ), - ).toBe(condition); - expect( - await accessService.hasPermission( - user, - UPDATE_FEATURE_ENVIRONMENT, - projectName, - developmentEnv, - ), - ).toBe(condition); - expect( - await accessService.hasPermission( - user, - CREATE_FEATURE_STRATEGY, - projectName, - productionEnv, - ), - ).toBe(condition); - expect( - await accessService.hasPermission( - user, - UPDATE_FEATURE_STRATEGY, - projectName, - productionEnv, - ), - ).toBe(condition); - expect( - await accessService.hasPermission( - user, - DELETE_FEATURE_STRATEGY, - projectName, - productionEnv, - ), - ).toBe(condition); - expect( - await accessService.hasPermission( - user, - UPDATE_FEATURE_ENVIRONMENT, - projectName, - productionEnv, - ), - ).toBe(condition); }; const hasFullProjectAccess = async (user, projectName: string, condition) => { @@ -378,7 +312,7 @@ test('should remove CREATE_FEATURE on default environment', async () => { await accessService.addPermissionToRole( editRole.id, permissions.CREATE_FEATURE, - '*', + 'default', ); // TODO: to validate the remove works, we should make sure that we had permission before removing it @@ -637,7 +571,7 @@ test('should support permission with "ALL" environment requirement', async () => await accessStore.addPermissionsToRole( customRole.id, [{ name: CREATE_FEATURE_STRATEGY }], - 'production', + 'default', ); await accessStore.addUserToRole(user.id, customRole.id, ALL_PROJECTS); @@ -645,7 +579,7 @@ test('should support permission with "ALL" environment requirement', async () => user, CREATE_FEATURE_STRATEGY, 'default', - 'production', + 'default', ); expect(hasAccess).toBe(true); @@ -667,7 +601,7 @@ test('Should have access to create a strategy in an environment', async () => { user, CREATE_FEATURE_STRATEGY, 'default', - 'development', + 'default', ), ).toBe(true); }); @@ -693,7 +627,7 @@ test('Should have access to edit a strategy in an environment', async () => { user, UPDATE_FEATURE_STRATEGY, 'default', - 'development', + 'default', ), ).toBe(true); }); @@ -706,7 +640,7 @@ test('Should have access to delete a strategy in an environment', async () => { user, DELETE_FEATURE_STRATEGY, 'default', - 'development', + 'default', ), ).toBe(true); }); diff --git a/yarn.lock b/yarn.lock index 54192fc856c3..014f65b439c9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9229,9 +9229,9 @@ __metadata: languageName: node linkType: hard -"unleash-client@npm:^6.3.3": - version: 6.3.3 - resolution: "unleash-client@npm:6.3.3" +"unleash-client@npm:^6.4.0": + version: 6.4.0 + resolution: "unleash-client@npm:6.4.0" dependencies: http-proxy-agent: "npm:^7.0.2" https-proxy-agent: "npm:^7.0.5" @@ -9241,7 +9241,7 @@ __metadata: murmurhash3js: "npm:^3.0.1" proxy-from-env: "npm:^1.1.0" semver: "npm:^7.6.2" - checksum: 10c0/047f3b63aa1cadde15abc39a4f627f77c297aaa15d11928d93d86815f88b7d72811c931be4a7fcd44887cc12bdb5ad32f674dbe19b62665ece4a86dac77224bb + checksum: 10c0/df9647b903d21537f1d4d1fbebc4bd1451e8e1f6090f17bdab1eba67158bd98794e535c7850be1472f839cd3f2b06398add097fc7a1a8c5c1ec936c6c0274427 languageName: node linkType: hard @@ -9356,7 +9356,7 @@ __metadata: tsc-watch: "npm:6.2.1" type-is: "npm:^1.6.18" typescript: "npm:5.4.5" - unleash-client: "npm:^6.3.3" + unleash-client: "npm:^6.4.0" uuid: "npm:^9.0.0" wait-on: "npm:^7.2.0" languageName: unknown