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

feat: walking skeleton of private projects (#4753)

This commit is contained in:
Jaanus Sellin 2023-09-15 15:52:54 +03:00 committed by GitHub
parent 387f48617d
commit 15baea1d25
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 247 additions and 7 deletions

View File

@ -29,6 +29,7 @@ import maintenanceMiddleware from './middleware/maintenance-middleware';
import { unless } from './middleware/unless-middleware';
import { catchAllErrorHandler } from './middleware/catch-all-error-handler';
import NotFoundError from './error/notfound-error';
import privateProjectMiddleware from './features/private-project/privateProjectMiddleware';
export default async function getApp(
config: IUnleashConfig,
@ -157,6 +158,8 @@ export default async function getApp(
}
}
app.use(baseUriPath, privateProjectMiddleware(config, services));
app.use(
baseUriPath,
rbacMiddleware(config, stores, services.accessService),

View File

@ -36,6 +36,7 @@ import { AccountStore } from './account-store';
import ProjectStatsStore from './project-stats-store';
import { Db } from './db';
import { ImportTogglesStore } from '../features/export-import-toggles/import-toggles-store';
import PrivateProjectStore from '../features/private-project/privateProjectStore';
export const createStores = (
config: IUnleashConfig,
@ -128,6 +129,7 @@ export const createStores = (
),
projectStatsStore: new ProjectStatsStore(db, eventBus, getLogger),
importTogglesStore: new ImportTogglesStore(db),
privateProjectStore: new PrivateProjectStore(db, getLogger),
};
};

View File

@ -41,6 +41,10 @@ import {
} from '../segment/createSegmentService';
import StrategyStore from '../../db/strategy-store';
import FakeStrategiesStore from '../../../test/fixtures/fake-strategies-store';
import {
createFakeprivateProjectChecker,
createPrivateProjectChecker,
} from '../private-project/createPrivateProjectChecker';
export const createFeatureToggleService = (
db: Db,
@ -98,6 +102,9 @@ export const createFeatureToggleService = (
db,
config,
);
const privateProjectChecker = createPrivateProjectChecker(db, config);
const featureToggleService = new FeatureToggleService(
{
featureStrategiesStore,
@ -114,6 +121,7 @@ export const createFeatureToggleService = (
segmentService,
accessService,
changeRequestAccessReadModel,
privateProjectChecker,
);
return featureToggleService;
};
@ -147,6 +155,7 @@ export const createFakeFeatureToggleService = (
);
const segmentService = createFakeSegmentService(config);
const changeRequestAccessReadModel = createFakeChangeRequestAccessService();
const fakeprivateProjectChecker = createFakeprivateProjectChecker();
const featureToggleService = new FeatureToggleService(
{
featureStrategiesStore,
@ -163,6 +172,7 @@ export const createFakeFeatureToggleService = (
segmentService,
accessService,
changeRequestAccessReadModel,
fakeprivateProjectChecker,
);
return featureToggleService;
};

View File

@ -0,0 +1,21 @@
import { Db, IUnleashConfig } from 'lib/server-impl';
import PrivateProjectStore from './privateProjectStore';
import { PrivateProjectChecker } from './privateProjectChecker';
import { FakeprivateProjectChecker } from './fakePrivateProjectChecker';
export const createPrivateProjectChecker = (
db: Db,
config: IUnleashConfig,
): PrivateProjectChecker => {
const { getLogger } = config;
const privateProjectStore = new PrivateProjectStore(db, getLogger);
return new PrivateProjectChecker({
privateProjectStore: privateProjectStore,
});
};
export const createFakeprivateProjectChecker =
(): FakeprivateProjectChecker => {
return new FakeprivateProjectChecker();
};

View File

@ -0,0 +1,8 @@
import { IPrivateProjectChecker } from './privateProjectCheckerType';
export class FakeprivateProjectChecker implements IPrivateProjectChecker {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async getUserAccessibleProjects(userId: number): Promise<string[]> {
throw new Error('Method not implemented.');
}
}

View File

@ -0,0 +1,17 @@
import { IUnleashStores } from '../../types';
import { IPrivateProjectStore } from './privateProjectStoreType';
import { IPrivateProjectChecker } from './privateProjectCheckerType';
export class PrivateProjectChecker implements IPrivateProjectChecker {
private privateProjectStore: IPrivateProjectStore;
constructor({
privateProjectStore,
}: Pick<IUnleashStores, 'privateProjectStore'>) {
this.privateProjectStore = privateProjectStore;
}
async getUserAccessibleProjects(userId: number): Promise<string[]> {
return this.privateProjectStore.getUserAccessibleProjects(userId);
}
}

View File

@ -0,0 +1,3 @@
export interface IPrivateProjectChecker {
getUserAccessibleProjects(userId: number): Promise<string[]>;
}

View File

@ -0,0 +1,40 @@
import { IUnleashConfig, IUnleashServices } from '../../types';
import { findParam } from '../../middleware';
import { NextFunction, Response } from 'express';
const privateProjectMiddleware = (
{
getLogger,
flagResolver,
}: Pick<IUnleashConfig, 'getLogger' | 'flagResolver'>,
{ projectService, accessService }: IUnleashServices,
): any => {
const logger = getLogger('/middleware/project-middleware.ts');
logger.debug('Enabling private project middleware');
if (!flagResolver.isEnabled('privateProjects')) {
return (req, res, next) => next();
}
return async (req, res: Response, next: NextFunction) => {
req.checkPrivateProjectPermissions = async () => {
const { user } = req;
let projectId =
findParam('projectId', req) || findParam('project', req);
if (projectId === undefined) {
return true;
}
const permissions = await accessService.getPermissionsForUser(user);
return (
permissions.map((p) => p.permission).includes('ADMIN') ||
projectService.isProjectUser(user.id, projectId)
);
};
next();
};
};
export default privateProjectMiddleware;

View File

@ -0,0 +1,42 @@
import { Db } from '../../db/db';
import { Logger, LogProvider } from '../../logger';
import { IPrivateProjectStore } from './privateProjectStoreType';
class PrivateProjectStore implements IPrivateProjectStore {
private db: Db;
private logger: Logger;
constructor(db: Db, getLogger: LogProvider) {
this.db = db;
this.logger = getLogger('project-permission-store.ts');
}
destroy(): void {}
async getUserAccessibleProjects(userId: number): Promise<string[]> {
const projects = await this.db
.from((db) => {
db.select('project')
.from('role_user')
.leftJoin('roles', 'role_user.role_id', 'roles.id')
.where('user_id', userId)
.union((queryBuilder) => {
queryBuilder
.select('project')
.from('group_role')
.leftJoin(
'group_user',
'group_user.group_id',
'group_role.group_id',
)
.where('user_id', userId);
})
.as('query');
})
.pluck('project');
return projects;
}
}
export default PrivateProjectStore;

View File

@ -0,0 +1,3 @@
export interface IPrivateProjectStore {
getUserAccessibleProjects(userId: number): Promise<string[]>;
}

View File

@ -7,6 +7,7 @@ import {
import { IUnleashConfig } from '../types/option';
import { IUnleashStores } from '../types/stores';
import User from '../types/user';
import { Request } from 'express';
interface PermissionChecker {
hasPermission(
@ -17,9 +18,9 @@ interface PermissionChecker {
): Promise<boolean>;
}
function findParam(
export function findParam(
name: string,
{ params, body }: any,
{ params, body }: Request,
defaultValue?: string,
): string | undefined {
let found = params ? params[name] : undefined;

View File

@ -1,7 +1,6 @@
import { IRouter, Router, Request, Response, RequestHandler } from 'express';
import { Logger } from 'lib/logger';
import { IUnleashConfig } from '../types/option';
import { NONE } from '../types/permissions';
import { IUnleashConfig, NONE } from '../types';
import { handleErrors } from './util';
import requireContentType from '../middleware/content_type_checker';
import { PermissionError } from '../error';
@ -55,6 +54,17 @@ const checkPermission =
return res.status(403).json(new PermissionError(permissions)).end();
};
const checkPrivateProjectPermissions = () => async (req, res, next) => {
if (
!req.checkPrivateProjectPermissions ||
(await req.checkPrivateProjectPermissions())
) {
return next();
}
return res.status(404).end();
};
/**
* Base class for Controllers to standardize binding to express Router.
*
@ -100,6 +110,7 @@ export default class Controller {
this.app[options.method](
options.path,
checkPermission(options.permission),
checkPrivateProjectPermissions(),
this.useContentTypeMiddleware(options),
this.useRouteErrorHandler(options.handler.bind(this)),
);
@ -186,6 +197,7 @@ export default class Controller {
this.app.post(
path,
checkPermission(permission),
checkPrivateProjectPermissions(),
filehandler.bind(this),
this.useRouteErrorHandler(handler.bind(this)),
);

View File

@ -9,6 +9,7 @@ import FeatureToggleService from './feature-toggle-service';
import { AccessService } from './access-service';
import { IChangeRequestAccessReadModel } from 'lib/features/change-request-access-service/change-request-access-read-model';
import { ISegmentService } from 'lib/segments/segment-service-interface';
import { IPrivateProjectChecker } from '../features/private-project/privateProjectCheckerType';
test('Should only store events for potentially stale on', async () => {
expect.assertions(2);
@ -49,6 +50,7 @@ test('Should only store events for potentially stale on', async () => {
{} as ISegmentService,
{} as AccessService,
{} as IChangeRequestAccessReadModel,
{} as IPrivateProjectChecker,
);
await featureToggleService.updatePotentiallyStaleFeatures();

View File

@ -95,6 +95,7 @@ import { unique } from '../util/unique';
import { ISegmentService } from 'lib/segments/segment-service-interface';
import { IChangeRequestAccessReadModel } from '../features/change-request-access-service/change-request-access-read-model';
import { checkFeatureFlagNamesAgainstPattern } from '../features/feature-naming-pattern/feature-naming-validation';
import { IPrivateProjectChecker } from '../features/private-project/privateProjectCheckerType';
interface IFeatureContext {
featureName: string;
@ -154,6 +155,8 @@ class FeatureToggleService {
private changeRequestAccessReadModel: IChangeRequestAccessReadModel;
private privateProjectChecker: IPrivateProjectChecker;
constructor(
{
featureStrategiesStore,
@ -184,6 +187,7 @@ class FeatureToggleService {
segmentService: ISegmentService,
accessService: AccessService,
changeRequestAccessReadModel: IChangeRequestAccessReadModel,
privateProjectChecker: IPrivateProjectChecker,
) {
this.logger = getLogger('services/feature-toggle-service.ts');
this.featureStrategiesStore = featureStrategiesStore;
@ -199,6 +203,7 @@ class FeatureToggleService {
this.accessService = accessService;
this.flagResolver = flagResolver;
this.changeRequestAccessReadModel = changeRequestAccessReadModel;
this.privateProjectChecker = privateProjectChecker;
}
async validateFeaturesContext(
@ -1017,11 +1022,20 @@ class FeatureToggleService {
userId?: number,
archived: boolean = false,
): Promise<FeatureToggle[]> {
return this.featureToggleClientStore.getAdmin({
const features = await this.featureToggleClientStore.getAdmin({
featureQuery: query,
userId,
archived,
});
if (this.flagResolver.isEnabled('privateProjects') && userId) {
const projects =
await this.privateProjectChecker.getUserAccessibleProjects(
userId,
);
return features.filter((f) => projects.includes(f.project));
}
return features;
}
async getFeatureOverview(

View File

@ -60,6 +60,10 @@ import ConfigurationRevisionService from '../features/feature-toggle/configurati
import { createFeatureToggleService } from '../features';
import EventAnnouncerService from './event-announcer-service';
import { createGroupService } from '../features/group/createGroupService';
import {
createFakeprivateProjectChecker,
createPrivateProjectChecker,
} from '../features/private-project/createPrivateProjectChecker';
// TODO: will be moved to scheduler feature directory
export const scheduleServices = async (
@ -184,12 +188,16 @@ export const createServices = (
changeRequestAccessReadModel,
config,
);
const privateProjectChecker = db
? createPrivateProjectChecker(db, config)
: createFakeprivateProjectChecker();
const featureToggleServiceV2 = new FeatureToggleService(
stores,
config,
segmentService,
accessService,
changeRequestAccessReadModel,
privateProjectChecker,
);
const environmentService = new EnvironmentService(stores, config);
const featureTagService = new FeatureTagService(stores, config);

View File

@ -33,6 +33,7 @@ import { IFavoriteProjectsStore } from './stores/favorite-projects';
import { IAccountStore } from './stores/account-store';
import { IProjectStatsStore } from './stores/project-stats-store-type';
import { IImportTogglesStore } from '../features/export-import-toggles/import-toggles-store-type';
import { IPrivateProjectStore } from '../features/private-project/privateProjectStoreType';
export interface IUnleashStores {
accessStore: IAccessStore;
@ -70,6 +71,7 @@ export interface IUnleashStores {
favoriteProjectsStore: IFavoriteProjectsStore;
projectStatsStore: IProjectStatsStore;
importTogglesStore: IImportTogglesStore;
privateProjectStore: IPrivateProjectStore;
}
export {
@ -107,4 +109,5 @@ export {
IFavoriteFeaturesStore,
IFavoriteProjectsStore,
IImportTogglesStore,
IPrivateProjectStore,
};

View File

@ -43,7 +43,7 @@ process.nextTick(async () => {
featureNamingPattern: true,
doraMetrics: true,
variantTypeNumber: true,
privateProjects: true,
privateProjects: false,
accessOverview: true,
},
},

View File

@ -21,6 +21,7 @@ import { SegmentService } from '../../../lib/services/segment-service';
import { GroupService } from '../../../lib/services/group-service';
import { FavoritesService } from '../../../lib/services';
import { ChangeRequestAccessReadModel } from '../../../lib/features/change-request-access-service/sql-change-request-access-read-model';
import { createPrivateProjectChecker } from '../../../lib/features/private-project/createPrivateProjectChecker';
let db: ITestDb;
let stores: IUnleashStores;
@ -244,12 +245,17 @@ beforeAll(async () => {
db.rawDatabase,
accessService,
);
const privateProjectChecker = createPrivateProjectChecker(
db.rawDatabase,
config,
);
featureToggleService = new FeatureToggleService(
stores,
config,
new SegmentService(stores, changeRequestAccessReadModel, config),
accessService,
changeRequestAccessReadModel,
privateProjectChecker,
);
favoritesService = new FavoritesService(stores, config);
projectService = new ProjectService(

View File

@ -12,6 +12,7 @@ import { SegmentService } from '../../../lib/services/segment-service';
import { GroupService } from '../../../lib/services/group-service';
import { FavoritesService } from '../../../lib/services';
import { ChangeRequestAccessReadModel } from '../../../lib/features/change-request-access-service/sql-change-request-access-read-model';
import { createPrivateProjectChecker } from '../../../lib/features/private-project/createPrivateProjectChecker';
let db;
let stores;
@ -31,12 +32,17 @@ beforeAll(async () => {
db.rawDatabase,
accessService,
);
const privateProjectChecker = createPrivateProjectChecker(
db.rawDatabase,
config,
);
const featureToggleService = new FeatureToggleService(
stores,
config,
new SegmentService(stores, changeRequestAccessReadModel, config),
accessService,
changeRequestAccessReadModel,
privateProjectChecker,
);
const project = {
id: 'test-project',

View File

@ -18,6 +18,7 @@ import {
} from '../../../lib/error';
import { ISegmentService } from '../../../lib/segments/segment-service-interface';
import { ChangeRequestAccessReadModel } from '../../../lib/features/change-request-access-service/sql-change-request-access-read-model';
import { createPrivateProjectChecker } from '../../../lib/features/private-project/createPrivateProjectChecker';
let stores;
let db;
@ -58,12 +59,17 @@ beforeAll(async () => {
changeRequestAccessReadModel,
config,
);
const privateProjectChecker = createPrivateProjectChecker(
db.rawDatabase,
config,
);
service = new FeatureToggleService(
stores,
config,
segmentService,
accessService,
changeRequestAccessReadModel,
privateProjectChecker,
);
});
@ -449,6 +455,10 @@ test('If change requests are enabled, cannot change variants without going via C
db.rawDatabase,
accessService,
);
const privateProjectChecker = createPrivateProjectChecker(
db.rawDatabase,
unleashConfig,
);
// Force all feature flags on to make sure we have Change requests on
const customFeatureService = new FeatureToggleService(
stores,
@ -461,6 +471,7 @@ test('If change requests are enabled, cannot change variants without going via C
segmentService,
accessService,
changeRequestAccessReadModel,
privateProjectChecker,
);
const newVariant: IVariant = {
@ -532,6 +543,10 @@ test('If CRs are protected for any environment in the project stops bulk update
db.rawDatabase,
accessService,
);
const privateProjectChecker = createPrivateProjectChecker(
db.rawDatabase,
unleashConfig,
);
// Force all feature flags on to make sure we have Change requests on
const customFeatureService = new FeatureToggleService(
stores,
@ -544,6 +559,7 @@ test('If CRs are protected for any environment in the project stops bulk update
segmentService,
accessService,
changeRequestAccessReadModel,
privateProjectChecker,
);
const toggle = await service.createFeatureToggle(

View File

@ -25,6 +25,7 @@ import { GroupService } from '../../../lib/services/group-service';
import { AccessService } from '../../../lib/services/access-service';
import { ISegmentService } from '../../../lib/segments/segment-service-interface';
import { ChangeRequestAccessReadModel } from '../../../lib/features/change-request-access-service/sql-change-request-access-read-model';
import { createPrivateProjectChecker } from '../../../lib/features/private-project/createPrivateProjectChecker';
let stores: IUnleashStores;
let db: ITestDb;
@ -47,12 +48,17 @@ beforeAll(async () => {
changeRequestAccessReadModel,
config,
);
const privateProjectChecker = createPrivateProjectChecker(
db.rawDatabase,
config,
);
featureToggleService = new FeatureToggleService(
stores,
config,
segmentService,
accessService,
changeRequestAccessReadModel,
privateProjectChecker,
);
service = new PlaygroundService(config, {
featureToggleServiceV2: featureToggleService,

View File

@ -11,6 +11,7 @@ import { SegmentService } from '../../../lib/services/segment-service';
import { GroupService } from '../../../lib/services/group-service';
import { FavoritesService } from '../../../lib/services';
import { ChangeRequestAccessReadModel } from '../../../lib/features/change-request-access-service/sql-change-request-access-read-model';
import { createPrivateProjectChecker } from '../../../lib/features/private-project/createPrivateProjectChecker';
let stores: IUnleashStores;
let db: ITestDb;
@ -36,12 +37,17 @@ beforeAll(async () => {
db.rawDatabase,
accessService,
);
const privateProjectChecker = createPrivateProjectChecker(
db.rawDatabase,
config,
);
featureToggleService = new FeatureToggleService(
stores,
config,
new SegmentService(stores, changeRequestAccessReadModel, config),
accessService,
changeRequestAccessReadModel,
privateProjectChecker,
);
favoritesService = new FavoritesService(stores, config);

View File

@ -15,6 +15,7 @@ import { FavoritesService } from '../../../lib/services';
import { FeatureEnvironmentEvent } from '../../../lib/types/events';
import { addDays, subDays } from 'date-fns';
import { ChangeRequestAccessReadModel } from '../../../lib/features/change-request-access-service/sql-change-request-access-read-model';
import { createPrivateProjectChecker } from '../../../lib/features/private-project/createPrivateProjectChecker';
let stores;
let db: ITestDb;
@ -57,12 +58,17 @@ beforeAll(async () => {
db.rawDatabase,
accessService,
);
const privateProjectChecker = createPrivateProjectChecker(
db.rawDatabase,
config,
);
featureToggleService = new FeatureToggleService(
stores,
config,
new SegmentService(stores, changeRequestAccessReadModel, config),
accessService,
changeRequestAccessReadModel,
privateProjectChecker,
);
favoritesService = new FavoritesService(stores, config);

View File

@ -15,7 +15,11 @@ import FakeUserFeedbackStore from './fake-user-feedback-store';
import FakeFeatureTagStore from './fake-feature-tag-store';
import FakeEnvironmentStore from './fake-environment-store';
import FakeStrategiesStore from './fake-strategies-store';
import { IImportTogglesStore, IUnleashStores } from '../../lib/types';
import {
IImportTogglesStore,
IPrivateProjectStore,
IUnleashStores,
} from '../../lib/types';
import FakeSessionStore from './fake-session-store';
import FakeFeatureEnvironmentStore from './fake-feature-environment-store';
import FakeApiTokenStore from './fake-api-token-store';
@ -78,6 +82,7 @@ const createStores: () => IUnleashStores = () => {
favoriteProjectsStore: new FakeFavoriteProjectsStore(),
projectStatsStore: new FakeProjectStatsStore(),
importTogglesStore: {} as IImportTogglesStore,
privateProjectStore: {} as IPrivateProjectStore,
};
};