1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-03-18 00:19:49 +01: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 },
eventService,
);
return new AccessService(
{ accessStore, accountStore, roleStore, environmentStore },
{ getLogger },

View File

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

View File

@ -5,7 +5,7 @@ import { EventEmitter } from 'stream';
import { EVENTS_CREATED_BY_PROCESSED } from '../../metric-events';
import type { IUnleashConfig } from '../../types';
import { createTestConfig } from '../../../test/config/test-config';
import EventService from './event-service';
import { createEventsService } from './createEventsService';
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 eventBus = new EventEmitter();
const service = new EventService(
{ eventStore: store, featureTagStore: db.stores.featureTagStore },
{ getLogger, eventBus },
{} as any,
);
const config = createTestConfig();
const service = createEventsService(db.rawDatabase, config);
let triggered = false;
eventBus.on(EVENTS_CREATED_BY_PROCESSED, ({ updated }) => {
config.eventBus.on(EVENTS_CREATED_BY_PROCESSED, ({ updated }) => {
expect(updated).toBe(2);
triggered = true;
});

View File

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

View File

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

View File

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

View File

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

View File

@ -23,13 +23,22 @@ import {
} from '../../lib/types';
import BadDataError from '../../lib/error/bad-data-error';
import { createFakeEventsService } from '../../lib/features/events/createEventsService';
import { createFakeAccessReadModel } from '../features/access/createAccessReadModel';
function getSetup() {
const config = createTestConfig({
getLogger,
});
return createFakeAccessService(config);
const { accessService, eventStore, accessStore } =
createFakeAccessService(config);
return {
accessService,
eventStore,
accessStore,
accessReadModel: createFakeAccessReadModel(accessStore),
};
}
test('should fail when name exists', async () => {
@ -287,28 +296,28 @@ describe('addAccessToProject', () => {
});
test('should return true if user has admin role', async () => {
const { accessService, accessStore } = getSetup();
const { accessReadModel, accessStore } = getSetup();
const userId = 1;
accessStore.getRolesForUserId = jest
.fn()
.mockResolvedValue([{ id: 1, name: 'ADMIN', type: 'custom' }]);
const result = await accessService.isRootAdmin(userId);
const result = await accessReadModel.isRootAdmin(userId);
expect(result).toBe(true);
expect(accessStore.getRolesForUserId).toHaveBeenCalledWith(userId);
});
test('should return false if user does not have admin role', async () => {
const { accessService, accessStore } = getSetup();
const { accessReadModel, accessStore } = getSetup();
const userId = 2;
accessStore.getRolesForUserId = jest
.fn()
.mockResolvedValue([{ id: 2, name: 'user', type: 'custom' }]);
const result = await accessService.isRootAdmin(userId);
const result = await accessReadModel.isRootAdmin(userId);
expect(result).toBe(false);
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 { GroupService } from './group-service';
import {
ADMIN_TOKEN_USER,
type IUnleashConfig,
type IUserAccessOverview,
RoleCreatedEvent,
RoleDeletedEvent,
RoleUpdatedEvent,
SYSTEM_USER_ID,
} from '../types';
import type EventService from '../features/events/event-service';
@ -889,14 +887,4 @@ export class AccessService {
async getUserAccessOverview(): Promise<IUserAccessOverview[]> {
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,
{},
{},
);
const featureToggleService = new FeatureToggleService(

View File

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

View File

@ -1,34 +1,97 @@
import type { EventSearchQueryParameters } from '../../../../lib/openapi/spec/event-search-query-parameters';
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 {
type IUnleashTest,
setupAppWithCustomConfig,
} from '../../helpers/test-helper';
FEATURE_CREATED,
type IUnleashConfig,
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 { createTestConfig } from '../../../config/test-config';
import type { IRole } from '../../../../lib/types/stores/access-store';
let app: IUnleashTest;
let db: ITestDb;
let eventService: EventService;
const TEST_USER_ID = -9999;
const regularUserName = 'import-user';
const adminUserName = 'admin-user';
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 () => {
db = await dbInit('event_search', getLogger);
app = await setupAppWithCustomConfig(db.stores, {
experimental: {
flags: {
strictSchemaValidation: true,
stores = db.stores;
app = await setupAppWithAuth(
db.stores,
{
experimental: {
flags: {
strictSchemaValidation: true,
},
},
},
});
db.rawDatabase,
);
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 () => {
@ -37,6 +100,7 @@ afterAll(async () => {
});
beforeEach(async () => {
await loginAdminUser();
await db.stores.featureToggleStore.deleteAll();
await db.stores.segmentStore.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 () => {
await eventService.storeEvent({
type: FEATURE_CREATED,
project: 'default',
createdBy: 'admin1@example.com',
createdByUserId: TEST_USER_ID + 1,
ip: '127.0.0.1',
@ -189,6 +254,7 @@ test('should filter events by created by', async () => {
await eventService.storeEvent({
type: FEATURE_CREATED,
project: 'default',
createdBy: 'admin2@example.com',
createdByUserId: TEST_USER_ID,
ip: '127.0.0.1',
@ -311,8 +377,7 @@ test('should filter events by feature using IS_ANY_OF', async () => {
});
await eventService.storeEvent({
type: FEATURE_CREATED,
project: 'default',
type: USER_CREATED,
featureName: 'my_feature_b',
createdBy: 'test-user',
createdByUserId: TEST_USER_ID,
@ -335,7 +400,7 @@ test('should filter events by feature using IS_ANY_OF', async () => {
expect(body).toMatchObject({
events: [
{
type: 'feature-created',
type: 'user-created',
featureName: 'my_feature_b',
},
{
@ -390,3 +455,63 @@ test('should filter events by project using IS_ANY_OF', async () => {
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,
});
});