1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-10 01:16:39 +02:00

feat: filter projectless events for normal users (#7914)

Now events that do not have project ( for example user creation, segment
creation etc), will not be displayed to non root admins.
This commit is contained in:
Jaanus Sellin 2024-08-21 13:59:24 +03:00 committed by GitHub
parent 6c5ce52470
commit df73c65073
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 246 additions and 44 deletions

View File

@ -0,0 +1,3 @@
export interface IAccessReadModel {
isRootAdmin(userId: number): Promise<boolean>;
}

View File

@ -0,0 +1,28 @@
import {
ADMIN_TOKEN_USER,
type IAccessStore,
type IUnleashStores,
SYSTEM_USER_ID,
} from '../../types';
import type { IAccessReadModel } from './access-read-model-type';
import * as permissions from '../../types/permissions';
const { ADMIN } = permissions;
export class AccessReadModel implements IAccessReadModel {
private store: IAccessStore;
constructor({ accessStore }: Pick<IUnleashStores, 'accessStore'>) {
this.store = accessStore;
}
async isRootAdmin(userId: number): Promise<boolean> {
if (userId === SYSTEM_USER_ID || userId === ADMIN_TOKEN_USER.id) {
return true;
}
const roles = await this.store.getRolesForUserId(userId);
return roles.some(
(role) => role.name.toLowerCase() === ADMIN.toLowerCase(),
);
}
}

View File

@ -0,0 +1,24 @@
import type { Db, IUnleashConfig } from '../../server-impl';
import type { IAccessReadModel } from './access-read-model-type';
import { AccessReadModel } from './access-read-model';
import { AccessStore } from '../../db/access-store';
import FakeRoleStore from '../../../test/fixtures/fake-role-store';
import FakeAccessStore from '../../../test/fixtures/fake-access-store';
import type { IAccessStore } from '../../types';
export const createAccessReadModel = (
db: Db,
config: IUnleashConfig,
): IAccessReadModel => {
const { eventBus, getLogger } = config;
const accessStore = new AccessStore(db, eventBus, getLogger);
return new AccessReadModel({ accessStore });
};
export const createFakeAccessReadModel = (
accessStore?: IAccessStore,
): IAccessReadModel => {
const roleStore = new FakeRoleStore();
const finalAccessStore = accessStore ?? new FakeAccessStore(roleStore);
return new AccessReadModel({ accessStore: finalAccessStore });
};

View File

@ -33,7 +33,6 @@ export const createAccessService = (
{ getLogger }, { getLogger },
eventService, eventService,
); );
return new AccessService( return new AccessService(
{ accessStore, accountStore, roleStore, environmentStore }, { accessStore, accountStore, roleStore, environmentStore },
{ getLogger }, { getLogger },

View File

@ -13,6 +13,10 @@ import {
createFakePrivateProjectChecker, createFakePrivateProjectChecker,
createPrivateProjectChecker, createPrivateProjectChecker,
} from '../private-project/createPrivateProjectChecker'; } from '../private-project/createPrivateProjectChecker';
import {
createAccessReadModel,
createFakeAccessReadModel,
} from '../access/createAccessReadModel';
export const createEventsService: ( export const createEventsService: (
db: Db, db: Db,
@ -25,10 +29,12 @@ export const createEventsService: (
config.getLogger, config.getLogger,
); );
const privateProjectChecker = createPrivateProjectChecker(db, config); const privateProjectChecker = createPrivateProjectChecker(db, config);
const accessReadModel = createAccessReadModel(db, config);
return new EventService( return new EventService(
{ eventStore, featureTagStore }, { eventStore, featureTagStore },
config, config,
privateProjectChecker, privateProjectChecker,
accessReadModel,
); );
}; };
@ -43,9 +49,11 @@ export const createFakeEventsService: (
const featureTagStore = const featureTagStore =
stores?.featureTagStore || new FakeFeatureTagStore(); stores?.featureTagStore || new FakeFeatureTagStore();
const fakePrivateProjectChecker = createFakePrivateProjectChecker(); const fakePrivateProjectChecker = createFakePrivateProjectChecker();
const fakeAccessReadModel = createFakeAccessReadModel();
return new EventService( return new EventService(
{ eventStore, featureTagStore }, { eventStore, featureTagStore },
config, config,
fakePrivateProjectChecker, fakePrivateProjectChecker,
fakeAccessReadModel,
); );
}; };

View File

@ -5,7 +5,7 @@ import { EventEmitter } from 'stream';
import { EVENTS_CREATED_BY_PROCESSED } from '../../metric-events'; import { EVENTS_CREATED_BY_PROCESSED } from '../../metric-events';
import type { IUnleashConfig } from '../../types'; import type { IUnleashConfig } from '../../types';
import { createTestConfig } from '../../../test/config/test-config'; import { createTestConfig } from '../../../test/config/test-config';
import EventService from './event-service'; import { createEventsService } from './createEventsService';
let db: ITestDb; let db: ITestDb;
@ -126,14 +126,11 @@ test('emits events with details on amount of updated rows', async () => {
const store = new EventStore(db.rawDatabase, getLogger); const store = new EventStore(db.rawDatabase, getLogger);
const eventBus = new EventEmitter(); const eventBus = new EventEmitter();
const service = new EventService( const config = createTestConfig();
{ eventStore: store, featureTagStore: db.stores.featureTagStore }, const service = createEventsService(db.rawDatabase, config);
{ getLogger, eventBus },
{} as any,
);
let triggered = false; let triggered = false;
eventBus.on(EVENTS_CREATED_BY_PROCESSED, ({ updated }) => { config.eventBus.on(EVENTS_CREATED_BY_PROCESSED, ({ updated }) => {
expect(updated).toBe(2); expect(updated).toBe(2);
triggered = true; triggered = true;
}); });

View File

@ -16,6 +16,7 @@ import { parseSearchOperatorValue } from '../feature-search/search-utils';
import { endOfDay, formatISO } from 'date-fns'; import { endOfDay, formatISO } from 'date-fns';
import type { IPrivateProjectChecker } from '../private-project/privateProjectCheckerType'; import type { IPrivateProjectChecker } from '../private-project/privateProjectCheckerType';
import type { ProjectAccess } from '../private-project/privateProjectStore'; import type { ProjectAccess } from '../private-project/privateProjectStore';
import type { IAccessReadModel } from '../access/access-read-model-type';
export default class EventService { export default class EventService {
private logger: Logger; private logger: Logger;
@ -24,6 +25,8 @@ export default class EventService {
private featureTagStore: IFeatureTagStore; private featureTagStore: IFeatureTagStore;
private accessReadModel: IAccessReadModel;
private privateProjectChecker: IPrivateProjectChecker; private privateProjectChecker: IPrivateProjectChecker;
private eventBus: EventEmitter; private eventBus: EventEmitter;
@ -35,12 +38,14 @@ export default class EventService {
}: Pick<IUnleashStores, 'eventStore' | 'featureTagStore'>, }: Pick<IUnleashStores, 'eventStore' | 'featureTagStore'>,
{ getLogger, eventBus }: Pick<IUnleashConfig, 'getLogger' | 'eventBus'>, { getLogger, eventBus }: Pick<IUnleashConfig, 'getLogger' | 'eventBus'>,
privateProjectChecker: IPrivateProjectChecker, privateProjectChecker: IPrivateProjectChecker,
accessReadModel: IAccessReadModel,
) { ) {
this.logger = getLogger('services/event-service.ts'); this.logger = getLogger('services/event-service.ts');
this.eventStore = eventStore; this.eventStore = eventStore;
this.privateProjectChecker = privateProjectChecker; this.privateProjectChecker = privateProjectChecker;
this.featureTagStore = featureTagStore; this.featureTagStore = featureTagStore;
this.eventBus = eventBus; this.eventBus = eventBus;
this.accessReadModel = accessReadModel;
} }
async getEvents(): Promise<IEventList> { async getEvents(): Promise<IEventList> {
@ -77,6 +82,8 @@ export default class EventService {
); );
const queryParams = this.convertToDbParams(search); const queryParams = this.convertToDbParams(search);
const projectFilter = await this.getProjectFilterForNonAdmins(userId);
queryParams.push(...projectFilter);
const totalEvents = await this.eventStore.searchEventsCount( const totalEvents = await this.eventStore.searchEventsCount(
{ {
@ -222,6 +229,14 @@ export default class EventService {
async getEventCreators() { async getEventCreators() {
return this.eventStore.getEventCreators(); return this.eventStore.getEventCreators();
} }
async getProjectFilterForNonAdmins(userId: number): Promise<IQueryParam[]> {
const isRootAdmin = await this.accessReadModel.isRootAdmin(userId);
if (!isRootAdmin) {
return [{ field: 'project', operator: 'IS_NOT', values: [null] }];
}
return [];
}
} }
export const filterAccessibleProjects = ( export const filterAccessibleProjects = (

View File

@ -50,6 +50,7 @@ export const applyGenericQueryParams = (
queryParams: IQueryParam[], queryParams: IQueryParam[],
): void => { ): void => {
queryParams.forEach((param) => { queryParams.forEach((param) => {
const isSingleParam = param.values.length === 1;
switch (param.operator) { switch (param.operator) {
case 'IS': case 'IS':
case 'IS_ANY_OF': case 'IS_ANY_OF':
@ -57,7 +58,11 @@ export const applyGenericQueryParams = (
break; break;
case 'IS_NOT': case 'IS_NOT':
case 'IS_NONE_OF': case 'IS_NONE_OF':
if (isSingleParam) {
query.whereNot(param.field, param.values[0]);
} else {
query.whereNotIn(param.field, param.values); query.whereNotIn(param.field, param.values);
}
break; break;
case 'IS_BEFORE': case 'IS_BEFORE':
query.where(param.field, '<', param.values[0]); query.where(param.field, '<', param.values[0]);

View File

@ -55,7 +55,7 @@ export type IQueryOperator =
export interface IQueryParam { export interface IQueryParam {
field: string; field: string;
operator: IQueryOperator; operator: IQueryOperator;
values: string[]; values: (string | null)[];
} }
export interface IFeatureStrategiesStore export interface IFeatureStrategiesStore

View File

@ -154,7 +154,7 @@ export class SegmentService implements ISegmentService {
await this.eventService.storeEvent( await this.eventService.storeEvent(
new SegmentCreatedEvent({ new SegmentCreatedEvent({
data: segment, data: segment,
project: segment.project || 'no-project', project: segment.project,
auditUser, auditUser,
}), }),
); );

View File

@ -23,13 +23,22 @@ import {
} from '../../lib/types'; } from '../../lib/types';
import BadDataError from '../../lib/error/bad-data-error'; import BadDataError from '../../lib/error/bad-data-error';
import { createFakeEventsService } from '../../lib/features/events/createEventsService'; import { createFakeEventsService } from '../../lib/features/events/createEventsService';
import { createFakeAccessReadModel } from '../features/access/createAccessReadModel';
function getSetup() { function getSetup() {
const config = createTestConfig({ const config = createTestConfig({
getLogger, getLogger,
}); });
return createFakeAccessService(config); const { accessService, eventStore, accessStore } =
createFakeAccessService(config);
return {
accessService,
eventStore,
accessStore,
accessReadModel: createFakeAccessReadModel(accessStore),
};
} }
test('should fail when name exists', async () => { test('should fail when name exists', async () => {
@ -287,28 +296,28 @@ describe('addAccessToProject', () => {
}); });
test('should return true if user has admin role', async () => { test('should return true if user has admin role', async () => {
const { accessService, accessStore } = getSetup(); const { accessReadModel, accessStore } = getSetup();
const userId = 1; const userId = 1;
accessStore.getRolesForUserId = jest accessStore.getRolesForUserId = jest
.fn() .fn()
.mockResolvedValue([{ id: 1, name: 'ADMIN', type: 'custom' }]); .mockResolvedValue([{ id: 1, name: 'ADMIN', type: 'custom' }]);
const result = await accessService.isRootAdmin(userId); const result = await accessReadModel.isRootAdmin(userId);
expect(result).toBe(true); expect(result).toBe(true);
expect(accessStore.getRolesForUserId).toHaveBeenCalledWith(userId); expect(accessStore.getRolesForUserId).toHaveBeenCalledWith(userId);
}); });
test('should return false if user does not have admin role', async () => { test('should return false if user does not have admin role', async () => {
const { accessService, accessStore } = getSetup(); const { accessReadModel, accessStore } = getSetup();
const userId = 2; const userId = 2;
accessStore.getRolesForUserId = jest accessStore.getRolesForUserId = jest
.fn() .fn()
.mockResolvedValue([{ id: 2, name: 'user', type: 'custom' }]); .mockResolvedValue([{ id: 2, name: 'user', type: 'custom' }]);
const result = await accessService.isRootAdmin(userId); const result = await accessReadModel.isRootAdmin(userId);
expect(result).toBe(false); expect(result).toBe(false);
expect(accessStore.getRolesForUserId).toHaveBeenCalledWith(userId); expect(accessStore.getRolesForUserId).toHaveBeenCalledWith(userId);

View File

@ -41,13 +41,11 @@ import BadDataError from '../error/bad-data-error';
import type { IGroup } from '../types/group'; import type { IGroup } from '../types/group';
import type { GroupService } from './group-service'; import type { GroupService } from './group-service';
import { import {
ADMIN_TOKEN_USER,
type IUnleashConfig, type IUnleashConfig,
type IUserAccessOverview, type IUserAccessOverview,
RoleCreatedEvent, RoleCreatedEvent,
RoleDeletedEvent, RoleDeletedEvent,
RoleUpdatedEvent, RoleUpdatedEvent,
SYSTEM_USER_ID,
} from '../types'; } from '../types';
import type EventService from '../features/events/event-service'; import type EventService from '../features/events/event-service';
@ -889,14 +887,4 @@ export class AccessService {
async getUserAccessOverview(): Promise<IUserAccessOverview[]> { async getUserAccessOverview(): Promise<IUserAccessOverview[]> {
return this.store.getUserAccessOverview(); return this.store.getUserAccessOverview();
} }
async isRootAdmin(userId: number): Promise<boolean> {
if (userId === SYSTEM_USER_ID || userId === ADMIN_TOKEN_USER.id) {
return true;
}
const roles = await this.store.getRolesForUserId(userId);
return roles.some(
(role) => role.name.toLowerCase() === ADMIN.toLowerCase(),
);
}
} }

View File

@ -44,6 +44,7 @@ test('Should only store events for potentially stale on', async () => {
}, },
config, config,
{}, {},
{},
); );
const featureToggleService = new FeatureToggleService( const featureToggleService = new FeatureToggleService(

View File

@ -1930,12 +1930,12 @@ export class AddonConfigDeletedEvent extends BaseEvent {
} }
export class SegmentCreatedEvent extends BaseEvent { export class SegmentCreatedEvent extends BaseEvent {
readonly project: string; readonly project: string | undefined;
readonly data: any; readonly data: any;
constructor(eventData: { constructor(eventData: {
auditUser: IAuditUser; auditUser: IAuditUser;
project: string; project: string | undefined;
data: any; data: any;
}) { }) {
super(SEGMENT_CREATED, eventData.auditUser); super(SEGMENT_CREATED, eventData.auditUser);

View File

@ -1,34 +1,97 @@
import type { EventSearchQueryParameters } from '../../../../lib/openapi/spec/event-search-query-parameters'; import type { EventSearchQueryParameters } from '../../../../lib/openapi/spec/event-search-query-parameters';
import dbInit, { type ITestDb } from '../../helpers/database-init'; import dbInit, { type ITestDb } from '../../helpers/database-init';
import { FEATURE_CREATED, type IUnleashConfig } from '../../../../lib/types';
import type { EventService } from '../../../../lib/services';
import getLogger from '../../../fixtures/no-logger';
import { import {
type IUnleashTest, FEATURE_CREATED,
setupAppWithCustomConfig, type IUnleashConfig,
} from '../../helpers/test-helper'; type IUnleashStores,
RoleName,
USER_CREATED,
} from '../../../../lib/types';
import type { AccessService, EventService } from '../../../../lib/services';
import getLogger from '../../../fixtures/no-logger';
import { type IUnleashTest, setupAppWithAuth } from '../../helpers/test-helper';
import { createEventsService } from '../../../../lib/features'; import { createEventsService } from '../../../../lib/features';
import { createTestConfig } from '../../../config/test-config'; import { createTestConfig } from '../../../config/test-config';
import type { IRole } from '../../../../lib/types/stores/access-store';
let app: IUnleashTest; let app: IUnleashTest;
let db: ITestDb; let db: ITestDb;
let eventService: EventService; let eventService: EventService;
const TEST_USER_ID = -9999; const TEST_USER_ID = -9999;
const regularUserName = 'import-user';
const adminUserName = 'admin-user';
const config: IUnleashConfig = createTestConfig(); const config: IUnleashConfig = createTestConfig();
let adminRole: IRole;
let stores: IUnleashStores;
let accessService: AccessService;
const loginRegularUser = () =>
app.request
.post(`/auth/demo/login`)
.send({
email: `${regularUserName}@getunleash.io`,
})
.expect(200);
const loginAdminUser = () =>
app.request
.post(`/auth/demo/login`)
.send({
email: `${adminUserName}@getunleash.io`,
})
.expect(200);
const createUserEditorAccess = async (name, email) => {
const { userStore } = stores;
const user = await userStore.insert({
name,
email,
});
return user;
};
const createUserAdminAccess = async (name, email) => {
const { userStore } = stores;
const user = await userStore.insert({
name,
email,
});
await accessService.addUserToRole(user.id, adminRole.id, 'default');
return user;
};
beforeAll(async () => { beforeAll(async () => {
db = await dbInit('event_search', getLogger); db = await dbInit('event_search', getLogger);
app = await setupAppWithCustomConfig(db.stores, { stores = db.stores;
app = await setupAppWithAuth(
db.stores,
{
experimental: { experimental: {
flags: { flags: {
strictSchemaValidation: true, strictSchemaValidation: true,
}, },
}, },
}); },
db.rawDatabase,
);
eventService = createEventsService(db.rawDatabase, config); eventService = createEventsService(db.rawDatabase, config);
accessService = app.services.accessService;
const roles = await accessService.getRootRoles();
adminRole = roles.find((role) => role.name === RoleName.ADMIN)!;
await createUserEditorAccess(
regularUserName,
`${regularUserName}@getunleash.io`,
);
await createUserAdminAccess(
adminUserName,
`${adminUserName}@getunleash.io`,
);
}); });
afterAll(async () => { afterAll(async () => {
@ -37,6 +100,7 @@ afterAll(async () => {
}); });
beforeEach(async () => { beforeEach(async () => {
await loginAdminUser();
await db.stores.featureToggleStore.deleteAll(); await db.stores.featureToggleStore.deleteAll();
await db.stores.segmentStore.deleteAll(); await db.stores.segmentStore.deleteAll();
await db.stores.eventStore.deleteAll(); await db.stores.eventStore.deleteAll();
@ -182,6 +246,7 @@ test('should filter events by type', async () => {
test('should filter events by created by', async () => { test('should filter events by created by', async () => {
await eventService.storeEvent({ await eventService.storeEvent({
type: FEATURE_CREATED, type: FEATURE_CREATED,
project: 'default',
createdBy: 'admin1@example.com', createdBy: 'admin1@example.com',
createdByUserId: TEST_USER_ID + 1, createdByUserId: TEST_USER_ID + 1,
ip: '127.0.0.1', ip: '127.0.0.1',
@ -189,6 +254,7 @@ test('should filter events by created by', async () => {
await eventService.storeEvent({ await eventService.storeEvent({
type: FEATURE_CREATED, type: FEATURE_CREATED,
project: 'default',
createdBy: 'admin2@example.com', createdBy: 'admin2@example.com',
createdByUserId: TEST_USER_ID, createdByUserId: TEST_USER_ID,
ip: '127.0.0.1', ip: '127.0.0.1',
@ -311,8 +377,7 @@ test('should filter events by feature using IS_ANY_OF', async () => {
}); });
await eventService.storeEvent({ await eventService.storeEvent({
type: FEATURE_CREATED, type: USER_CREATED,
project: 'default',
featureName: 'my_feature_b', featureName: 'my_feature_b',
createdBy: 'test-user', createdBy: 'test-user',
createdByUserId: TEST_USER_ID, createdByUserId: TEST_USER_ID,
@ -335,7 +400,7 @@ test('should filter events by feature using IS_ANY_OF', async () => {
expect(body).toMatchObject({ expect(body).toMatchObject({
events: [ events: [
{ {
type: 'feature-created', type: 'user-created',
featureName: 'my_feature_b', featureName: 'my_feature_b',
}, },
{ {
@ -390,3 +455,63 @@ test('should filter events by project using IS_ANY_OF', async () => {
total: 2, total: 2,
}); });
}); });
test('should not show user creation events for non-admins', async () => {
await loginRegularUser();
await eventService.storeEvent({
type: USER_CREATED,
createdBy: 'test-user',
createdByUserId: TEST_USER_ID,
ip: '127.0.0.1',
});
await eventService.storeEvent({
type: FEATURE_CREATED,
project: 'default',
createdBy: 'test-user',
createdByUserId: TEST_USER_ID,
ip: '127.0.0.1',
});
const { body } = await searchEvents({});
expect(body).toMatchObject({
events: [
{
type: FEATURE_CREATED,
},
],
total: 1,
});
});
test('should show user creation events for admins', async () => {
await eventService.storeEvent({
type: USER_CREATED,
createdBy: 'test-user',
createdByUserId: TEST_USER_ID,
ip: '127.0.0.1',
});
await eventService.storeEvent({
type: FEATURE_CREATED,
project: 'default',
createdBy: 'test-user',
createdByUserId: TEST_USER_ID,
ip: '127.0.0.1',
});
const { body } = await searchEvents({});
expect(body).toMatchObject({
events: [
{
type: FEATURE_CREATED,
},
{
type: USER_CREATED,
},
],
total: 2,
});
});