1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-02-28 00:17:12 +01:00

Merge branch 'main' into gitar_simplifyProjectOverview_true

This commit is contained in:
sjaanus 2025-01-08 13:14:14 +02:00
commit 6006544dcc
No known key found for this signature in database
GPG Key ID: 20E007C0248BA7FF
37 changed files with 379 additions and 181 deletions

View File

@ -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: <GoogleAuth />,
},
{
label: 'SCIM',
component: <ScimSettings />,
},
].filter(
(item) => uiConfig.flags?.googleAuthEnabled || item.label !== 'Google',
);
if (isEnterprise()) {
tabs.push({
label: 'SCIM',
component: <ScimSettings />,
});
}
const [activeTab, setActiveTab] = useState(0);
usePageTitle(`Single sign-on: ${tabs[activeTab].label}`);
@ -56,7 +52,7 @@ export const AuthSettings = () => {
withTabs
header={
<ConditionallyRender
condition={authenticationType === 'enterprise'}
condition={isEnterprise()}
show={
<Tabs
value={activeTab}
@ -85,41 +81,7 @@ export const AuthSettings = () => {
}
>
<ConditionallyRender
condition={authenticationType === 'open-source'}
show={<PremiumFeature feature='sso' />}
/>
<ConditionallyRender
condition={authenticationType === 'demo'}
show={
<Alert severity='warning'>
You are running Unleash in demo mode. You have
to use the Enterprise edition in order configure
Single Sign-on.
</Alert>
}
/>
<ConditionallyRender
condition={authenticationType === 'custom'}
show={
<Alert severity='warning'>
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.
</Alert>
}
/>
<ConditionallyRender
condition={authenticationType === 'hosted'}
show={
<Alert severity='info'>
Your Unleash instance is managed by the Unleash
team.
</Alert>
}
/>
<ConditionallyRender
condition={authenticationType === 'enterprise'}
condition={isEnterprise()}
show={
<div>
{tabs.map((tab, index) => (
@ -133,6 +95,7 @@ export const AuthSettings = () => {
))}
</div>
}
elseShow={<PremiumFeature feature='sso' />}
/>
</PageContent>
</PermissionGuard>

View File

@ -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' },
}}
/>
<UpdateButton permission={ADMIN} />
<UpdateButton permission={[ADMIN, UPDATE_CORS]} />
</Box>
</form>
);

View File

@ -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>

View File

@ -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<Record<'search', string>>;

View File

@ -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 (
<StyledContainer>

View File

@ -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 = () => (
<PermissionGuard permissions={ADMIN}>
<PermissionGuard permissions={[ADMIN, READ_LOGS]}>
<EventLog title='Event log' />
</PermissionGuard>
);

View File

@ -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 (
<div>
<PermissionGuard permissions={ADMIN}>
<PermissionGuard permissions={[ADMIN, READ_LOGS]}>
<LoginHistoryTable />
</PermissionGuard>
</div>

View File

@ -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 }) => ({

View File

@ -57,6 +57,10 @@ export const MilestoneCardName = ({
setEditMode(false);
}
}}
onClick={(ev) => {
ev.preventDefault();
ev.stopPropagation();
}}
/>
)}
{!editMode && (

View File

@ -5,6 +5,9 @@ export const useUiConfigApi = () => {
propagateErrors: true,
});
/**
* @deprecated remove when `granularAdminPermissions` flag is removed
*/
const setFrontendSettings = async (
frontendApiOrigins: string[],
): Promise<void> => {
@ -19,8 +22,18 @@ export const useUiConfigApi = () => {
await makeRequest(req.caller, req.id);
};
const setCors = async (frontendApiOrigins: string[]): Promise<void> => {
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,
};

View File

@ -89,7 +89,6 @@ export type UiFlags = {
productivityReportEmail?: boolean;
showUserDeviceCount?: boolean;
flagOverviewRedesign?: boolean;
licensedUsers?: boolean;
granularAdminPermissions?: boolean;
};

View File

@ -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": {

View File

@ -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<RevisionDeltaEntry | undefined> {
): Promise<ClientFeaturesDeltaSchema | undefined> {
if (this.clientFeatureToggleDelta !== null) {
return this.clientFeatureToggleDelta.getDelta(revisionId, query);
} else {

View File

@ -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'],
});
});

View File

@ -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<RevisionDeltaEntry>,
res: Response<ClientFeaturesDeltaSchema>,
): Promise<void> {
if (!this.flagResolver.isEnabled('deltaApi')) {
throw new NotFoundError();

View File

@ -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 {

View File

@ -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<RevisionDeltaEntry | undefined> {
): Promise<ClientFeaturesDeltaSchema | undefined> {
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!),
),
];

View File

@ -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<ContextService>;
private openApiService: OpenApiService;
@ -59,14 +60,17 @@ export class ContextController extends Controller {
constructor(
config: IUnleashConfig,
{
contextService,
transactionalContextService,
openApiService,
}: Pick<IUnleashServices, 'contextService' | 'openApiService'>,
}: 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<ContextFieldsSchema>,
): Promise<void> {
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<void> {
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<void> {
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<void> {
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),
);

View File

@ -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 () => {

View File

@ -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(false)) || {};
await this.services.settingService.insert(
frontendSettingsKey,
{ ...settings, frontendApiOrigins: value },
auditUser,
false,
);
}
async fetchFrontendSettings(): Promise<FrontendSettings> {
try {
this.cachedFrontendSettings =

View File

@ -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);

View File

@ -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();
});

View File

@ -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';

View File

@ -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<typeof setCorsSchema>;

View File

@ -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']);
});

View File

@ -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<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;

View File

@ -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,

View File

@ -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 = {

View File

@ -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',

View File

@ -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,
];

View File

@ -69,6 +69,7 @@ export interface IUnleashServices {
clientInstanceService: ClientInstanceService;
clientMetricsServiceV2: ClientMetricsServiceV2;
contextService: ContextService;
transactionalContextService: WithTransactional<ContextService>;
emailService: EmailService;
environmentService: EnvironmentService;
transactionalEnvironmentService: WithTransactional<EnvironmentService>;

View File

@ -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,
);
};

View File

@ -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);
}

View File

@ -53,7 +53,6 @@ process.nextTick(async () => {
releasePlans: false,
showUserDeviceCount: true,
flagOverviewRedesign: false,
licensedUsers: true,
granularAdminPermissions: true,
deltaApi: true,
},

View File

@ -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<void>((resolve, reject) => {

View File

@ -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);
});

View File

@ -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