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:
parent
6c5ce52470
commit
df73c65073
3
src/lib/features/access/access-read-model-type.ts
Normal file
3
src/lib/features/access/access-read-model-type.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export interface IAccessReadModel {
|
||||
isRootAdmin(userId: number): Promise<boolean>;
|
||||
}
|
28
src/lib/features/access/access-read-model.ts
Normal file
28
src/lib/features/access/access-read-model.ts
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
24
src/lib/features/access/createAccessReadModel.ts
Normal file
24
src/lib/features/access/createAccessReadModel.ts
Normal 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 });
|
||||
};
|
@ -33,7 +33,6 @@ export const createAccessService = (
|
||||
{ getLogger },
|
||||
eventService,
|
||||
);
|
||||
|
||||
return new AccessService(
|
||||
{ accessStore, accountStore, roleStore, environmentStore },
|
||||
{ getLogger },
|
||||
|
@ -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,
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
||||
});
|
||||
|
@ -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 = (
|
||||
|
@ -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]);
|
||||
|
@ -55,7 +55,7 @@ export type IQueryOperator =
|
||||
export interface IQueryParam {
|
||||
field: string;
|
||||
operator: IQueryOperator;
|
||||
values: string[];
|
||||
values: (string | null)[];
|
||||
}
|
||||
|
||||
export interface IFeatureStrategiesStore
|
||||
|
@ -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,
|
||||
}),
|
||||
);
|
||||
|
@ -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);
|
||||
|
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -44,6 +44,7 @@ test('Should only store events for potentially stale on', async () => {
|
||||
},
|
||||
config,
|
||||
{},
|
||||
{},
|
||||
);
|
||||
|
||||
const featureToggleService = new FeatureToggleService(
|
||||
|
@ -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);
|
||||
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user