1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-25 00:07:47 +01:00

Feat/grouping (#1845)

* Implement user grouping feature for permissions

Co-authored-by: Thomas Heartman <thomas@getunleash.ai>
Co-authored-by: Jaanus Sellin <sellinjaanus@gmail.com>
Co-authored-by: Nuno Góis <github@nunogois.com>
Co-authored-by: Thomas Heartman <thomas@getunleash.ai>
This commit is contained in:
sighphyre 2022-07-21 16:23:56 +02:00 committed by GitHub
parent 09fa031e0f
commit 5806b6748f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
57 changed files with 1838 additions and 80 deletions

View File

@ -4,9 +4,11 @@ import metricsHelper from '../util/metrics-helper';
import { DB_TIME } from '../metric-events'; import { DB_TIME } from '../metric-events';
import { Logger } from '../logger'; import { Logger } from '../logger';
import { import {
IAccessInfo,
IAccessStore, IAccessStore,
IRole, IRole,
IUserPermission, IUserPermission,
IUserRole,
} from '../types/stores/access-store'; } from '../types/stores/access-store';
import { IPermission } from '../types/model'; import { IPermission } from '../types/model';
import NotFoundError from '../error/notfound-error'; import NotFoundError from '../error/notfound-error';
@ -18,6 +20,9 @@ import {
const T = { const T = {
ROLE_USER: 'role_user', ROLE_USER: 'role_user',
ROLES: 'roles', ROLES: 'roles',
GROUPS: 'groups',
GROUP_ROLE: 'group_role',
GROUP_USER: 'group_user',
ROLE_PERMISSION: 'role_permission', ROLE_PERMISSION: 'role_permission',
PERMISSIONS: 'permissions', PERMISSIONS: 'permissions',
PERMISSION_TYPES: 'permission_types', PERMISSION_TYPES: 'permission_types',
@ -40,8 +45,16 @@ export class AccessStore implements IAccessStore {
private db: Knex; private db: Knex;
constructor(db: Knex, eventBus: EventEmitter, getLogger: Function) { private enableUserGroupPermissions: boolean;
constructor(
db: Knex,
eventBus: EventEmitter,
getLogger: Function,
enableUserGroupPermissions: boolean,
) {
this.db = db; this.db = db;
this.enableUserGroupPermissions = enableUserGroupPermissions;
this.logger = getLogger('access-store.ts'); this.logger = getLogger('access-store.ts');
this.timer = (action: string) => this.timer = (action: string) =>
metricsHelper.wrapTimer(eventBus, DB_TIME, { metricsHelper.wrapTimer(eventBus, DB_TIME, {
@ -62,7 +75,7 @@ export class AccessStore implements IAccessStore {
async exists(key: number): Promise<boolean> { async exists(key: number): Promise<boolean> {
const result = await this.db.raw( const result = await this.db.raw(
`SELECT EXISTS (SELECT 1 FROM ${T.ROLES} WHERE id = ?) AS present`, `SELECT EXISTS(SELECT 1 FROM ${T.ROLES} WHERE id = ?) AS present`,
[key], [key],
); );
const { present } = result.rows[0]; const { present } = result.rows[0];
@ -107,7 +120,7 @@ export class AccessStore implements IAccessStore {
async getPermissionsForUser(userId: number): Promise<IUserPermission[]> { async getPermissionsForUser(userId: number): Promise<IUserPermission[]> {
const stopTimer = this.timer('getPermissionsForUser'); const stopTimer = this.timer('getPermissionsForUser');
const rows = await this.db let userPermissionQuery = this.db
.select( .select(
'project', 'project',
'permission', 'permission',
@ -119,6 +132,29 @@ export class AccessStore implements IAccessStore {
.join(`${T.ROLE_USER} AS ur`, 'ur.role_id', 'rp.role_id') .join(`${T.ROLE_USER} AS ur`, 'ur.role_id', 'rp.role_id')
.join(`${T.PERMISSIONS} AS p`, 'p.id', 'rp.permission_id') .join(`${T.PERMISSIONS} AS p`, 'p.id', 'rp.permission_id')
.where('ur.user_id', '=', userId); .where('ur.user_id', '=', userId);
if (this.enableUserGroupPermissions) {
userPermissionQuery = userPermissionQuery.union((db) => {
db.select(
'project',
'permission',
'environment',
'p.type',
'gr.role_id',
)
.from<IPermissionRow>(`${T.GROUP_USER} AS gu`)
.join(`${T.GROUPS} AS g`, 'g.id', 'gu.group_id')
.join(`${T.GROUP_ROLE} AS gr`, 'gu.group_id', 'gr.group_id')
.join(
`${T.ROLE_PERMISSION} AS rp`,
'rp.role_id',
'gr.role_id',
)
.join(`${T.PERMISSIONS} AS p`, 'p.id', 'rp.permission_id')
.where('gu.user_id', '=', userId);
});
}
const rows = await userPermissionQuery;
stopTimer(); stopTimer();
return rows.map(this.mapUserPermission); return rows.map(this.mapUserPermission);
} }
@ -137,12 +173,11 @@ export class AccessStore implements IAccessStore {
? row.environment ? row.environment
: undefined; : undefined;
const result = { return {
project, project,
environment, environment,
permission: row.permission, permission: row.permission,
}; };
return result;
} }
async getPermissionsForRole(roleId: number): Promise<IPermission[]> { async getPermissionsForRole(roleId: number): Promise<IPermission[]> {
@ -192,17 +227,20 @@ export class AccessStore implements IAccessStore {
.delete(); .delete();
} }
async getProjectUserIdsForRole( async getProjectUsersForRole(
roleId: number, roleId: number,
projectId?: string, projectId?: string,
): Promise<number[]> { ): Promise<IUserRole[]> {
const rows = await this.db const rows = await this.db
.select(['user_id']) .select(['user_id', 'ru.created_at'])
.from<IRole>(`${T.ROLE_USER} AS ru`) .from<IRole>(`${T.ROLE_USER} AS ru`)
.join(`${T.ROLES} as r`, 'ru.role_id', 'id') .join(`${T.ROLES} as r`, 'ru.role_id', 'id')
.where('r.id', roleId) .where('r.id', roleId)
.andWhere('ru.project', projectId); .andWhere('ru.project', projectId);
return rows.map((r) => r.user_id); return rows.map((r) => ({
userId: r.user_id,
addedAt: r.created_at,
}));
} }
async getRolesForUserId(userId: number): Promise<IRole[]> { async getRolesForUserId(userId: number): Promise<IRole[]> {
@ -224,12 +262,12 @@ export class AccessStore implements IAccessStore {
async addUserToRole( async addUserToRole(
userId: number, userId: number,
roleId: number, roleId: number,
projecId?: string, projectId?: string,
): Promise<void> { ): Promise<void> {
return this.db(T.ROLE_USER).insert({ return this.db(T.ROLE_USER).insert({
user_id: userId, user_id: userId,
role_id: roleId, role_id: roleId,
project: projecId, project: projectId,
}); });
} }
@ -247,6 +285,34 @@ export class AccessStore implements IAccessStore {
.delete(); .delete();
} }
async addGroupToRole(
groupId: number,
roleId: number,
createdBy: string,
projectId?: string,
): Promise<void> {
return this.db(T.GROUP_ROLE).insert({
group_id: groupId,
role_id: roleId,
project: projectId,
created_by: createdBy,
});
}
async removeGroupFromRole(
groupId: number,
roleId: number,
projectId?: string,
): Promise<void> {
return this.db(T.GROUP_ROLE)
.where({
group_id: groupId,
role_id: roleId,
project: projectId,
})
.delete();
}
async updateUserProjectRole( async updateUserProjectRole(
userId: number, userId: number,
roleId: number, roleId: number,
@ -264,6 +330,63 @@ export class AccessStore implements IAccessStore {
.update('role_id', roleId); .update('role_id', roleId);
} }
updateGroupProjectRole(
groupId: number,
roleId: number,
projectId: string,
): Promise<void> {
return this.db(T.GROUP_ROLE)
.where({
group_id: groupId,
project: projectId,
})
.whereNotIn(
'role_id',
this.db(T.ROLES).select('id as role_id').where('type', 'root'),
)
.update('role_id', roleId);
}
async addAccessToProject(
users: IAccessInfo[],
groups: IAccessInfo[],
projectId: string,
roleId: number,
createdBy: string,
): Promise<void> {
const userRows = users.map((user) => {
return {
user_id: user.id,
project: projectId,
role_id: roleId,
};
});
const groupRows = groups.map((group) => {
return {
group_id: group.id,
project: projectId,
role_id: roleId,
created_by: createdBy,
};
});
await this.db.transaction(async (tx) => {
if (userRows.length > 0) {
await tx(T.ROLE_USER)
.insert(userRows)
.onConflict(['project', 'role_id', 'user_id'])
.merge();
}
if (groupRows.length > 0) {
await tx(T.GROUP_ROLE)
.insert(groupRows)
.onConflict(['project', 'role_id', 'group_id'])
.merge();
}
});
}
async removeRolesOfTypeForUser( async removeRolesOfTypeForUser(
userId: number, userId: number,
roleType: string, roleType: string,

225
src/lib/db/group-store.ts Normal file
View File

@ -0,0 +1,225 @@
import { IGroupStore, IStoreGroup } from '../types/stores/group-store';
import { Knex } from 'knex';
import NotFoundError from '../error/notfound-error';
import Group, {
IGroup,
IGroupModel,
IGroupProject,
IGroupRole,
IGroupUser,
IGroupUserModel,
} from '../types/group';
import Transaction = Knex.Transaction;
const T = {
GROUPS: 'groups',
GROUP_USER: 'group_user',
GROUP_ROLE: 'group_role',
USERS: 'users',
PROJECTS: 'projects',
};
const GROUP_COLUMNS = ['id', 'name', 'description', 'created_at', 'created_by'];
const GROUP_ROLE_COLUMNS = ['group_id', 'role_id', 'created_at'];
const rowToGroup = (row) => {
if (!row) {
throw new NotFoundError('No group found');
}
return new Group({
id: row.id,
name: row.name,
description: row.description,
createdAt: row.created_at,
createdBy: row.created_by,
});
};
const rowToGroupUser = (row) => {
if (!row) {
throw new NotFoundError('No group user found');
}
return {
userId: row.user_id,
groupId: row.group_id,
role: row.role,
joinedAt: row.created_at,
};
};
const groupToRow = (user: IStoreGroup) => ({
name: user.name,
description: user.description,
});
export default class GroupStore implements IGroupStore {
private db: Knex;
constructor(db: Knex) {
this.db = db;
}
async getAllWithId(ids: number[]): Promise<Group[]> {
const groups = await this.db
.select(GROUP_COLUMNS)
.from(T.GROUPS)
.whereIn('id', ids);
return groups.map(rowToGroup);
}
async update(group: IGroupModel): Promise<IGroup> {
const rows = await this.db(T.GROUPS)
.where({ id: group.id })
.update({
name: group.name,
description: group.description,
})
.returning(GROUP_COLUMNS);
return rowToGroup(rows[0]);
}
async getProjectGroupRoles(projectId: string): Promise<IGroupRole[]> {
const rows = await this.db
.select(GROUP_ROLE_COLUMNS)
.from(`${T.GROUP_ROLE}`)
.where('project', projectId);
return rows.map((r) => {
return {
groupId: r.group_id,
roleId: r.role_id,
createdAt: r.created_at,
};
});
}
async getGroupProjects(groupIds: number[]): Promise<IGroupProject[]> {
const rows = await this.db
.select('group_id', 'project')
.from(T.GROUP_ROLE)
.whereIn('group_id', groupIds)
.distinct();
return rows.map((r) => {
return {
groupId: r.group_id,
project: r.project,
};
});
}
async getAllUsersByGroups(groupIds: number[]): Promise<IGroupUser[]> {
const rows = await this.db
.select('gu.group_id', 'u.id as user_id', 'role', 'gu.created_at')
.from(`${T.GROUP_USER} AS gu`)
.join(`${T.USERS} AS u`, 'u.id', 'gu.user_id')
.whereIn('gu.group_id', groupIds);
return rows.map(rowToGroupUser);
}
async getAll(): Promise<Group[]> {
const groups = await this.db.select(GROUP_COLUMNS).from(T.GROUPS);
return groups.map(rowToGroup);
}
async delete(id: number): Promise<void> {
return this.db(T.GROUPS).where({ id }).del();
}
async deleteAll(): Promise<void> {
await this.db(T.GROUPS).del();
}
destroy(): void {}
async exists(id: number): Promise<boolean> {
const result = await this.db.raw(
`SELECT EXISTS(SELECT 1 FROM ${T.GROUPS} WHERE id = ?) AS present`,
[id],
);
const { present } = result.rows[0];
return present;
}
async existsWithName(name: string): Promise<boolean> {
const result = await this.db.raw(
`SELECT EXISTS(SELECT 1 FROM ${T.GROUPS} WHERE name = ?) AS present`,
[name],
);
const { present } = result.rows[0];
return present;
}
async get(id: number): Promise<Group> {
const row = await this.db(T.GROUPS).where({ id }).first();
return rowToGroup(row);
}
async create(group: IStoreGroup): Promise<Group> {
const row = await this.db(T.GROUPS)
.insert(groupToRow(group))
.returning('*');
return rowToGroup(row[0]);
}
async addNewUsersToGroup(
groupId: number,
users: IGroupUserModel[],
userName: string,
transaction?: Transaction,
): Promise<void> {
const rows = users.map((user) => {
return {
group_id: groupId,
user_id: user.user.id,
role: user.role,
created_by: userName,
};
});
return (transaction || this.db).batchInsert(T.GROUP_USER, rows);
}
async updateExistingUsersInGroup(
groupId: number,
existingUsers: IGroupUserModel[],
transaction?: Transaction,
): Promise<void> {
const queries = [];
existingUsers.forEach((user) => {
queries.push(
(transaction || this.db)(T.GROUP_USER)
.where({ group_id: groupId, user_id: user.user.id })
.update({ role: user.role })
.transacting(transaction),
);
});
await Promise.all(queries);
}
async deleteOldUsersFromGroup(
deletableUsers: IGroupUser[],
transaction?: Transaction,
): Promise<void> {
return (transaction || this.db)(T.GROUP_USER)
.whereIn(
['group_id', 'user_id'],
deletableUsers.map((user) => [user.groupId, user.userId]),
)
.delete();
}
async updateGroupUsers(
groupId: number,
newUsers: IGroupUserModel[],
existingUsers: IGroupUserModel[],
deletableUsers: IGroupUser[],
userName: string,
): Promise<void> {
await this.db.transaction(async (tx) => {
await this.addNewUsersToGroup(groupId, newUsers, userName, tx);
await this.updateExistingUsersInGroup(groupId, existingUsers, tx);
await this.deleteOldUsersFromGroup(deletableUsers, tx);
});
}
}

View File

@ -29,6 +29,7 @@ import { ClientMetricsStoreV2 } from './client-metrics-store-v2';
import UserSplashStore from './user-splash-store'; import UserSplashStore from './user-splash-store';
import RoleStore from './role-store'; import RoleStore from './role-store';
import SegmentStore from './segment-store'; import SegmentStore from './segment-store';
import GroupStore from './group-store';
export const createStores = ( export const createStores = (
config: IUnleashConfig, config: IUnleashConfig,
@ -56,7 +57,12 @@ export const createStores = (
tagStore: new TagStore(db, eventBus, getLogger), tagStore: new TagStore(db, eventBus, getLogger),
tagTypeStore: new TagTypeStore(db, eventBus, getLogger), tagTypeStore: new TagTypeStore(db, eventBus, getLogger),
addonStore: new AddonStore(db, eventBus, getLogger), addonStore: new AddonStore(db, eventBus, getLogger),
accessStore: new AccessStore(db, eventBus, getLogger), accessStore: new AccessStore(
db,
eventBus,
getLogger,
config?.experimental?.userGroups,
),
apiTokenStore: new ApiTokenStore(db, eventBus, getLogger), apiTokenStore: new ApiTokenStore(db, eventBus, getLogger),
resetTokenStore: new ResetTokenStore(db, eventBus, getLogger), resetTokenStore: new ResetTokenStore(db, eventBus, getLogger),
sessionStore: new SessionStore(db, eventBus, getLogger), sessionStore: new SessionStore(db, eventBus, getLogger),
@ -82,6 +88,7 @@ export const createStores = (
userSplashStore: new UserSplashStore(db, eventBus, getLogger), userSplashStore: new UserSplashStore(db, eventBus, getLogger),
roleStore: new RoleStore(db, eventBus, getLogger), roleStore: new RoleStore(db, eventBus, getLogger),
segmentStore: new SegmentStore(db, eventBus, getLogger), segmentStore: new SegmentStore(db, eventBus, getLogger),
groupStore: new GroupStore(db),
}; };
}; };

View File

@ -12,6 +12,7 @@ import { IRole, IUserRole } from 'lib/types/stores/access-store';
const T = { const T = {
ROLE_USER: 'role_user', ROLE_USER: 'role_user',
GROUP_ROLE: 'group_role',
ROLES: 'roles', ROLES: 'roles',
}; };

View File

@ -1,6 +1,7 @@
export interface IExperimentalOptions { export interface IExperimentalOptions {
metricsV2?: IExperimentalToggle; metricsV2?: IExperimentalToggle;
clientFeatureMemoize?: IExperimentalToggle; clientFeatureMemoize?: IExperimentalToggle;
userGroups?: boolean;
anonymiseEventLog?: boolean; anonymiseEventLog?: boolean;
} }

View File

@ -101,6 +101,9 @@ import { versionSchema } from './spec/version-schema';
import { IServerOption } from '../types'; import { IServerOption } from '../types';
import { URL } from 'url'; import { URL } from 'url';
import { groupSchema } from './spec/group-schema';
import { groupsSchema } from './spec/groups-schema';
import { groupUserModelSchema } from './spec/group-user-model-schema';
// All schemas in `openapi/spec` should be listed here. // All schemas in `openapi/spec` should be listed here.
export const schemas = { export const schemas = {
@ -149,6 +152,9 @@ export const schemas = {
featureUsageSchema, featureUsageSchema,
featureVariantsSchema, featureVariantsSchema,
feedbackSchema, feedbackSchema,
groupSchema,
groupsSchema,
groupUserModelSchema,
healthCheckSchema, healthCheckSchema,
healthOverviewSchema, healthOverviewSchema,
healthReportSchema, healthReportSchema,

View File

@ -0,0 +1,50 @@
import { FromSchema } from 'json-schema-to-ts';
import { groupUserModelSchema } from './group-user-model-schema';
import { userSchema } from './user-schema';
export const groupSchema = {
$id: '#/components/schemas/groupSchema',
type: 'object',
additionalProperties: false,
required: ['name', 'users'],
properties: {
id: {
type: 'number',
},
name: {
type: 'string',
},
description: {
type: 'string',
},
createdBy: {
type: 'string',
nullable: true,
},
createdAt: {
type: 'string',
format: 'date-time',
nullable: true,
},
users: {
type: 'array',
items: {
$ref: '#/components/schemas/groupUserModelSchema',
},
},
projects: {
type: 'array',
items: {
type: 'string',
},
},
},
components: {
schemas: {
groupUserModelSchema,
userSchema,
},
},
} as const;
export type GroupSchema = FromSchema<typeof groupSchema>;

View File

@ -0,0 +1,28 @@
import { FromSchema } from 'json-schema-to-ts';
import { userSchema } from './user-schema';
export const groupUserModelSchema = {
$id: '#/components/schemas/groupUserModelSchema',
type: 'object',
additionalProperties: false,
required: ['role', 'user'],
properties: {
joinedAt: {
type: 'string',
format: 'date-time',
},
role: {
type: 'string',
},
user: {
$ref: '#/components/schemas/userSchema',
},
},
components: {
schemas: {
userSchema,
},
},
} as const;
export type GroupUserModelSchema = FromSchema<typeof groupUserModelSchema>;

View File

@ -0,0 +1,25 @@
import { validateSchema } from '../validate';
import { GroupsSchema } from './groups-schema';
test('groupsSchema', () => {
const data: GroupsSchema = {
groups: [
{
id: 1,
name: 'Group',
users: [
{
role: 'Owner',
user: {
id: 3,
},
},
],
},
],
};
expect(
validateSchema('#/components/schemas/groupsSchema', data),
).toBeUndefined();
});

View File

@ -0,0 +1,27 @@
import { FromSchema } from 'json-schema-to-ts';
import { groupSchema } from './group-schema';
import { groupUserModelSchema } from './group-user-model-schema';
import { userSchema } from './user-schema';
export const groupsSchema = {
$id: '#/components/schemas/groupsSchema',
type: 'object',
additionalProperties: false,
properties: {
groups: {
type: 'array',
items: {
$ref: '#/components/schemas/groupSchema',
},
},
},
components: {
schemas: {
groupSchema,
groupUserModelSchema,
userSchema,
},
},
} as const;
export type GroupsSchema = FromSchema<typeof groupsSchema>;

View File

@ -1,6 +1,7 @@
import * as permissions from '../types/permissions'; import * as permissions from '../types/permissions';
import User, { IUser } from '../types/user'; import User, { IProjectUser, IUser } from '../types/user';
import { import {
IAccessInfo,
IAccessStore, IAccessStore,
IRole, IRole,
IRoleWithPermissions, IRoleWithPermissions,
@ -28,6 +29,8 @@ import { CUSTOM_ROLE_TYPE, ALL_PROJECTS, ALL_ENVS } from '../util/constants';
import { DEFAULT_PROJECT } from '../types/project'; import { DEFAULT_PROJECT } from '../types/project';
import InvalidOperationError from '../error/invalid-operation-error'; import InvalidOperationError from '../error/invalid-operation-error';
import BadDataError from '../error/bad-data-error'; import BadDataError from '../error/bad-data-error';
import { IGroupModelWithProjectRole } from '../types/group';
import { GroupService } from './group-service';
const { ADMIN } = permissions; const { ADMIN } = permissions;
@ -61,6 +64,8 @@ export class AccessService {
private roleStore: IRoleStore; private roleStore: IRoleStore;
private groupService: GroupService;
private environmentStore: IEnvironmentStore; private environmentStore: IEnvironmentStore;
private logger: Logger; private logger: Logger;
@ -76,10 +81,12 @@ export class AccessService {
'accessStore' | 'userStore' | 'roleStore' | 'environmentStore' 'accessStore' | 'userStore' | 'roleStore' | 'environmentStore'
>, >,
{ getLogger }: { getLogger: Function }, { getLogger }: { getLogger: Function },
groupService: GroupService,
) { ) {
this.store = accessStore; this.store = accessStore;
this.userStore = userStore; this.userStore = userStore;
this.roleStore = roleStore; this.roleStore = roleStore;
this.groupService = groupService;
this.environmentStore = environmentStore; this.environmentStore = environmentStore;
this.logger = getLogger('/services/access-service.ts'); this.logger = getLogger('/services/access-service.ts');
} }
@ -174,6 +181,31 @@ export class AccessService {
return this.store.addUserToRole(userId, roleId, projectId); return this.store.addUserToRole(userId, roleId, projectId);
} }
async addGroupToRole(
groupId: number,
roleId: number,
createdBy: string,
projectId: string,
): Promise<void> {
return this.store.addGroupToRole(groupId, roleId, createdBy, projectId);
}
async addAccessToProject(
users: IAccessInfo[],
groups: IAccessInfo[],
projectId: string,
roleId: number,
createdBy: string,
): Promise<void> {
return this.store.addAccessToProject(
users,
groups,
projectId,
roleId,
createdBy,
);
}
async getRoleByName(roleName: string): Promise<IRole> { async getRoleByName(roleName: string): Promise<IRole> {
return this.roleStore.getRoleByName(roleName); return this.roleStore.getRoleByName(roleName);
} }
@ -218,6 +250,14 @@ export class AccessService {
return this.store.removeUserFromRole(userId, roleId, projectId); return this.store.removeUserFromRole(userId, roleId, projectId);
} }
async removeGroupFromRole(
groupId: number,
roleId: number,
projectId: string,
): Promise<void> {
return this.store.removeGroupFromRole(groupId, roleId, projectId);
}
async updateUserProjectRole( async updateUserProjectRole(
userId: number, userId: number,
roleId: number, roleId: number,
@ -226,6 +266,14 @@ export class AccessService {
return this.store.updateUserProjectRole(userId, roleId, projectId); return this.store.updateUserProjectRole(userId, roleId, projectId);
} }
async updateGroupProjectRole(
userId: number,
roleId: number,
projectId: string,
): Promise<void> {
return this.store.updateGroupProjectRole(userId, roleId, projectId);
}
//This actually only exists for testing purposes //This actually only exists for testing purposes
async addPermissionToRole( async addPermissionToRole(
roleId: number, roleId: number,
@ -311,21 +359,28 @@ export class AccessService {
async getProjectUsersForRole( async getProjectUsersForRole(
roleId: number, roleId: number,
projectId?: string, projectId?: string,
): Promise<IUser[]> { ): Promise<IProjectUser[]> {
const userIdList = await this.store.getProjectUserIdsForRole( const userRoleList = await this.store.getProjectUsersForRole(
roleId, roleId,
projectId, projectId,
); );
if (userIdList.length > 0) { if (userRoleList.length > 0) {
return this.userStore.getAllWithId(userIdList); const userIdList = userRoleList.map((u) => u.userId);
const users = await this.userStore.getAllWithId(userIdList);
return users.map((user) => {
const role = userRoleList.find((r) => r.userId == user.id);
return {
...user,
addedAt: role.addedAt,
};
});
} }
return []; return [];
} }
// Move to project-service? async getProjectRoleAccess(
async getProjectRoleUsers(
projectId: string, projectId: string,
): Promise<[IRole[], IUserWithRole[]]> { ): Promise<[IRole[], IUserWithRole[], IGroupModelWithProjectRole[]]> {
const roles = await this.roleStore.getProjectRoles(); const roles = await this.roleStore.getProjectRoles();
const users = await Promise.all( const users = await Promise.all(
@ -337,7 +392,8 @@ export class AccessService {
return projectUsers.map((u) => ({ ...u, roleId: role.id })); return projectUsers.map((u) => ({ ...u, roleId: role.id }));
}), }),
); );
return [roles, users.flat()]; const groups = await this.groupService.getProjectGroups(projectId);
return [roles, users.flat(), groups];
} }
async createDefaultProjectRoles( async createDefaultProjectRoles(

View File

@ -0,0 +1,218 @@
import {
IGroup,
IGroupModel,
IGroupModelWithProjectRole,
IGroupProject,
IGroupUser,
} from '../types/group';
import { IUnleashConfig, IUnleashStores } from '../types';
import { IGroupStore } from '../types/stores/group-store';
import { Logger } from '../logger';
import BadDataError from '../error/bad-data-error';
import { GROUP_CREATED, GROUP_UPDATED } from '../types/events';
import { IEventStore } from '../types/stores/event-store';
import NameExistsError from '../error/name-exists-error';
import { IUserStore } from '../types/stores/user-store';
import { IUser } from '../types/user';
export class GroupService {
private groupStore: IGroupStore;
private eventStore: IEventStore;
private userStore: IUserStore;
private logger: Logger;
constructor(
stores: Pick<IUnleashStores, 'groupStore' | 'eventStore' | 'userStore'>,
{ getLogger }: Pick<IUnleashConfig, 'getLogger'>,
) {
this.logger = getLogger('service/group-service.js');
this.groupStore = stores.groupStore;
this.eventStore = stores.eventStore;
this.userStore = stores.userStore;
}
async getAll(): Promise<IGroupModel[]> {
const groups = await this.groupStore.getAll();
const allGroupUsers = await this.groupStore.getAllUsersByGroups(
groups.map((g) => g.id),
);
const users = await this.userStore.getAllWithId(
allGroupUsers.map((u) => u.userId),
);
const groupProjects = await this.groupStore.getGroupProjects(
groups.map((g) => g.id),
);
return groups.map((group) => {
const mappedGroup = this.mapGroupWithUsers(
group,
allGroupUsers,
users,
);
return this.mapGroupWithProjects(groupProjects, mappedGroup);
});
}
mapGroupWithProjects(
groupProjects: IGroupProject[],
group: IGroupModel,
): IGroupModel {
return {
...group,
projects: groupProjects
.filter((project) => project.groupId === group.id)
.map((project) => project.project),
};
}
async getGroup(id: number): Promise<IGroupModel> {
const group = await this.groupStore.get(id);
const groupUsers = await this.groupStore.getAllUsersByGroups([id]);
const users = await this.userStore.getAllWithId(
groupUsers.map((u) => u.userId),
);
return this.mapGroupWithUsers(group, groupUsers, users);
}
async createGroup(group: IGroupModel, userName: string): Promise<IGroup> {
await this.validateGroup(group);
const newGroup = await this.groupStore.create(group);
await this.groupStore.addNewUsersToGroup(
newGroup.id,
group.users,
userName,
);
await this.eventStore.store({
type: GROUP_CREATED,
createdBy: userName,
data: group,
});
return newGroup;
}
async updateGroup(group: IGroupModel, userName: string): Promise<IGroup> {
const preData = await this.groupStore.get(group.id);
await this.validateGroup(group, preData);
const newGroup = await this.groupStore.update(group);
const existingUsers = await this.groupStore.getAllUsersByGroups([
group.id,
]);
const existingUserIds = existingUsers.map((g) => g.userId);
const deletableUsers = existingUsers.filter(
(existingUser) =>
!group.users.some(
(groupUser) => groupUser.user.id == existingUser.userId,
),
);
const deletableUserIds = deletableUsers.map((g) => g.userId);
await this.groupStore.updateGroupUsers(
newGroup.id,
group.users.filter(
(user) => !existingUserIds.includes(user.user.id),
),
group.users.filter(
(user) =>
existingUserIds.includes(user.user.id) &&
!deletableUserIds.includes(user.user.id),
),
deletableUsers,
userName,
);
await this.eventStore.store({
type: GROUP_UPDATED,
createdBy: userName,
data: newGroup,
preData,
});
return newGroup;
}
async getProjectGroups(
projectId?: string,
): Promise<IGroupModelWithProjectRole[]> {
const groupRoles = await this.groupStore.getProjectGroupRoles(
projectId,
);
if (groupRoles.length > 0) {
const groups = await this.groupStore.getAllWithId(
groupRoles.map((a) => a.groupId),
);
const groupUsers = await this.groupStore.getAllUsersByGroups(
groups.map((g) => g.id),
);
const users = await this.userStore.getAllWithId(
groupUsers.map((u) => u.userId),
);
return groups.map((group) => {
const groupRole = groupRoles.find((g) => g.groupId == group.id);
return {
...this.mapGroupWithUsers(group, groupUsers, users),
roleId: groupRole.roleId,
addedAt: groupRole.createdAt,
};
});
}
return [];
}
async deleteGroup(id: number): Promise<void> {
return this.groupStore.delete(id);
}
async validateGroup(
{ name, users }: IGroupModel,
existingGroup?: IGroup,
): Promise<void> {
if (!name) {
throw new BadDataError('Group name cannot be empty');
}
if (!existingGroup || existingGroup.name != name) {
if (await this.groupStore.existsWithName(name)) {
throw new NameExistsError('Group name already exists');
}
}
if (users.length == 0 || !users.some((u) => u.role == 'Owner')) {
throw new BadDataError('Group needs to have at least one Owner');
}
}
private mapGroupWithUsers(
group: IGroup,
allGroupUsers: IGroupUser[],
allUsers: IUser[],
): IGroupModel {
const groupUsers = allGroupUsers.filter(
(user) => user.groupId == group.id,
);
const groupUsersId = groupUsers.map((user) => user.userId);
const selectedUsers = allUsers.filter((user) =>
groupUsersId.includes(user.id),
);
const finalUsers = selectedUsers.map((user) => {
const roleUser = groupUsers.find((gu) => gu.userId == user.id);
return {
user: user,
joinedAt: roleUser.joinedAt,
role: roleUser.role,
};
});
return { ...group, users: finalUsers };
}
}

View File

@ -32,12 +32,13 @@ import { SegmentService } from './segment-service';
import { OpenApiService } from './openapi-service'; import { OpenApiService } from './openapi-service';
import { ClientSpecService } from './client-spec-service'; import { ClientSpecService } from './client-spec-service';
import { PlaygroundService } from './playground-service'; import { PlaygroundService } from './playground-service';
import { GroupService } from './group-service';
export const createServices = ( export const createServices = (
stores: IUnleashStores, stores: IUnleashStores,
config: IUnleashConfig, config: IUnleashConfig,
): IUnleashServices => { ): IUnleashServices => {
const accessService = new AccessService(stores, config); const groupService = new GroupService(stores, config);
const accessService = new AccessService(stores, config, groupService);
const apiTokenService = new ApiTokenService(stores, config); const apiTokenService = new ApiTokenService(stores, config);
const clientInstanceService = new ClientInstanceService(stores, config); const clientInstanceService = new ClientInstanceService(stores, config);
const clientMetricsServiceV2 = new ClientMetricsServiceV2(stores, config); const clientMetricsServiceV2 = new ClientMetricsServiceV2(stores, config);
@ -81,6 +82,7 @@ export const createServices = (
config, config,
accessService, accessService,
featureToggleServiceV2, featureToggleServiceV2,
groupService,
); );
const userSplashService = new UserSplashService(stores, config); const userSplashService = new UserSplashService(stores, config);
const openApiService = new OpenApiService(config); const openApiService = new OpenApiService(config);
@ -121,6 +123,7 @@ export const createServices = (
openApiService, openApiService,
clientSpecService, clientSpecService,
playgroundService, playgroundService,
groupService,
}; };
}; };

View File

@ -12,6 +12,8 @@ import {
ProjectUserAddedEvent, ProjectUserAddedEvent,
ProjectUserRemovedEvent, ProjectUserRemovedEvent,
ProjectUserUpdateRoleEvent, ProjectUserUpdateRoleEvent,
ProjectGroupAddedEvent,
ProjectGroupRemovedEvent,
} from '../types/events'; } from '../types/events';
import { IUnleashStores } from '../types'; import { IUnleashStores } from '../types';
import { IUnleashConfig } from '../types/option'; import { IUnleashConfig } from '../types/option';
@ -28,7 +30,10 @@ import { IFeatureTypeStore } from '../types/stores/feature-type-store';
import { IFeatureToggleStore } from '../types/stores/feature-toggle-store'; import { IFeatureToggleStore } from '../types/stores/feature-toggle-store';
import { IFeatureEnvironmentStore } from '../types/stores/feature-environment-store'; import { IFeatureEnvironmentStore } from '../types/stores/feature-environment-store';
import { IProjectQuery, IProjectStore } from '../types/stores/project-store'; import { IProjectQuery, IProjectStore } from '../types/stores/project-store';
import { IRoleDescriptor } from '../types/stores/access-store'; import {
IProjectAccessModel,
IRoleDescriptor,
} from '../types/stores/access-store';
import { IEventStore } from '../types/stores/event-store'; import { IEventStore } from '../types/stores/event-store';
import FeatureToggleService from './feature-toggle-service'; import FeatureToggleService from './feature-toggle-service';
import { MOVE_FEATURE_TOGGLE } from '../types/permissions'; import { MOVE_FEATURE_TOGGLE } from '../types/permissions';
@ -39,12 +44,15 @@ import { IFeatureTagStore } from 'lib/types/stores/feature-tag-store';
import ProjectWithoutOwnerError from '../error/project-without-owner-error'; import ProjectWithoutOwnerError from '../error/project-without-owner-error';
import { IUserStore } from 'lib/types/stores/user-store'; import { IUserStore } from 'lib/types/stores/user-store';
import { arraysHaveSameItems } from '../util/arraysHaveSameItems'; import { arraysHaveSameItems } from '../util/arraysHaveSameItems';
import { GroupService } from './group-service';
import { IGroupModelWithProjectRole } from 'lib/types/group';
const getCreatedBy = (user: User) => user.email || user.username; const getCreatedBy = (user: User) => user.email || user.username;
export interface UsersWithRoles { export interface AccessWithRoles {
users: IUserWithRole[]; users: IUserWithRole[];
roles: IRoleDescriptor[]; roles: IRoleDescriptor[];
groups: IGroupModelWithProjectRole[];
} }
export default class ProjectService { export default class ProjectService {
@ -62,6 +70,8 @@ export default class ProjectService {
private environmentStore: IEnvironmentStore; private environmentStore: IEnvironmentStore;
private groupService: GroupService;
private logger: any; private logger: any;
private featureToggleService: FeatureToggleService; private featureToggleService: FeatureToggleService;
@ -94,6 +104,7 @@ export default class ProjectService {
config: IUnleashConfig, config: IUnleashConfig,
accessService: AccessService, accessService: AccessService,
featureToggleService: FeatureToggleService, featureToggleService: FeatureToggleService,
groupService: GroupService,
) { ) {
this.store = projectStore; this.store = projectStore;
this.environmentStore = environmentStore; this.environmentStore = environmentStore;
@ -105,6 +116,7 @@ export default class ProjectService {
this.featureToggleService = featureToggleService; this.featureToggleService = featureToggleService;
this.tagStore = featureTagStore; this.tagStore = featureTagStore;
this.userStore = userStore; this.userStore = userStore;
this.groupService = groupService;
this.logger = config.getLogger('services/project-service.js'); this.logger = config.getLogger('services/project-service.js');
} }
@ -270,14 +282,14 @@ export default class ProjectService {
} }
// RBAC methods // RBAC methods
async getUsersWithAccess(projectId: string): Promise<UsersWithRoles> { async getAccessToProject(projectId: string): Promise<AccessWithRoles> {
const [roles, users] = await this.accessService.getProjectRoleUsers( const [roles, users, groups] =
projectId, await this.accessService.getProjectRoleAccess(projectId);
);
return { return {
roles, roles,
users, users,
groups,
}; };
} }
@ -288,7 +300,7 @@ export default class ProjectService {
userId: number, userId: number,
createdBy?: string, createdBy?: string,
): Promise<void> { ): Promise<void> {
const [roles, users] = await this.accessService.getProjectRoleUsers( const [roles, users] = await this.accessService.getProjectRoleAccess(
projectId, projectId,
); );
const user = await this.userStore.get(userId); const user = await this.userStore.get(userId);
@ -350,6 +362,80 @@ export default class ProjectService {
); );
} }
async addGroup(
projectId: string,
roleId: number,
groupId: number,
modifiedBy?: string,
): Promise<void> {
const role = await this.accessService.getRole(roleId);
const group = await this.groupService.getGroup(groupId);
const project = await this.getProject(projectId);
await this.accessService.addGroupToRole(
group.id,
role.id,
modifiedBy,
project.id,
);
await this.eventStore.store(
new ProjectGroupAddedEvent({
project: project.id,
createdBy: modifiedBy,
data: {
groupId: group.id,
projectId: project.id,
roleName: role.name,
},
}),
);
}
async removeGroup(
projectId: string,
roleId: number,
groupId: number,
modifiedBy?: string,
): Promise<void> {
const group = await this.groupService.getGroup(groupId);
const role = await this.accessService.getRole(roleId);
const project = await this.getProject(projectId);
await this.accessService.removeGroupFromRole(
group.id,
role.id,
project.id,
);
await this.eventStore.store(
new ProjectGroupRemovedEvent({
project: projectId,
createdBy: modifiedBy,
preData: {
groupId: group.id,
projectId: project.id,
roleName: role.name,
},
}),
);
}
async addAccess(
projectId: string,
roleId: number,
usersAndGroups: IProjectAccessModel,
createdBy: string,
): Promise<void> {
return this.accessService.addAccessToProject(
usersAndGroups.users,
usersAndGroups.groups,
projectId,
roleId,
createdBy,
);
}
async findProjectRole( async findProjectRole(
projectId: string, projectId: string,
roleId: number, roleId: number,
@ -373,7 +459,9 @@ export default class ProjectService {
currentRole.id, currentRole.id,
projectId, projectId,
); );
if (users.length < 2) { const groups = await this.groupService.getProjectGroups(projectId);
const roleGroups = groups.filter((g) => g.roleId == currentRole.id);
if (users.length + roleGroups.length < 2) {
throw new ProjectWithoutOwnerError(); throw new ProjectWithoutOwnerError();
} }
} }
@ -385,7 +473,7 @@ export default class ProjectService {
userId: number, userId: number,
createdBy: string, createdBy: string,
): Promise<void> { ): Promise<void> {
const usersWithRoles = await this.getUsersWithAccess(projectId); const usersWithRoles = await this.getAccessToProject(projectId);
const user = usersWithRoles.users.find((u) => u.id === userId); const user = usersWithRoles.users.find((u) => u.id === userId);
const currentRole = usersWithRoles.roles.find( const currentRole = usersWithRoles.roles.find(
(r) => r.id === user.roleId, (r) => r.id === user.roleId,

View File

@ -42,6 +42,9 @@ export const PROJECT_IMPORT = 'project-import';
export const PROJECT_USER_ADDED = 'project-user-added'; export const PROJECT_USER_ADDED = 'project-user-added';
export const PROJECT_USER_REMOVED = 'project-user-removed'; export const PROJECT_USER_REMOVED = 'project-user-removed';
export const PROJECT_USER_ROLE_CHANGED = 'project-user-role-changed'; export const PROJECT_USER_ROLE_CHANGED = 'project-user-role-changed';
export const PROJECT_GROUP_ADDED = 'project-group-added';
export const PROJECT_GROUP_REMOVED = 'project-group-removed';
export const PROJECT_GROUP_ROLE_CHANGED = 'project-group-role-changed';
export const DROP_PROJECTS = 'drop-projects'; export const DROP_PROJECTS = 'drop-projects';
export const TAG_CREATED = 'tag-created'; export const TAG_CREATED = 'tag-created';
export const TAG_DELETED = 'tag-deleted'; export const TAG_DELETED = 'tag-deleted';
@ -64,6 +67,8 @@ export const ENVIRONMENT_IMPORT = 'environment-import';
export const SEGMENT_CREATED = 'segment-created'; export const SEGMENT_CREATED = 'segment-created';
export const SEGMENT_UPDATED = 'segment-updated'; export const SEGMENT_UPDATED = 'segment-updated';
export const SEGMENT_DELETED = 'segment-deleted'; export const SEGMENT_DELETED = 'segment-deleted';
export const GROUP_CREATED = 'group-created';
export const GROUP_UPDATED = 'group-updated';
export const SETTING_CREATED = 'setting-created'; export const SETTING_CREATED = 'setting-created';
export const SETTING_UPDATED = 'setting-updated'; export const SETTING_UPDATED = 'setting-updated';
export const SETTING_DELETED = 'setting-deleted'; export const SETTING_DELETED = 'setting-deleted';
@ -441,6 +446,59 @@ export class ProjectUserUpdateRoleEvent extends BaseEvent {
} }
} }
export class ProjectGroupAddedEvent extends BaseEvent {
readonly project: string;
readonly data: any;
readonly preData: any;
constructor(p: { project: string; createdBy: string; data: any }) {
super(PROJECT_GROUP_ADDED, p.createdBy);
const { project, data } = p;
this.project = project;
this.data = data;
this.preData = null;
}
}
export class ProjectGroupRemovedEvent extends BaseEvent {
readonly project: string;
readonly data: any;
readonly preData: any;
constructor(p: { project: string; createdBy: string; preData: any }) {
super(PROJECT_GROUP_REMOVED, p.createdBy);
const { project, preData } = p;
this.project = project;
this.data = null;
this.preData = preData;
}
}
export class ProjectGroupUpdateRoleEvent extends BaseEvent {
readonly project: string;
readonly data: any;
readonly preData: any;
constructor(eventData: {
project: string;
createdBy: string;
data: any;
preData: any;
}) {
super(PROJECT_GROUP_ROLE_CHANGED, eventData.createdBy);
const { project, data, preData } = eventData;
this.project = project;
this.data = data;
this.preData = preData;
}
}
export class SettingCreatedEvent extends BaseEvent { export class SettingCreatedEvent extends BaseEvent {
readonly data: any; readonly data: any;

72
src/lib/types/group.ts Normal file
View File

@ -0,0 +1,72 @@
import Joi from 'joi';
import { IUser } from './user';
export interface IGroup {
id?: number;
name: string;
description: string;
createdAt?: Date;
createdBy?: string;
}
export interface IGroupUser {
groupId: number;
userId: number;
role: string;
joinedAt: Date;
}
export interface IGroupRole {
groupId: number;
roleId: number;
createdAt: Date;
}
export interface IGroupModel extends IGroup {
users: IGroupUserModel[];
projects?: string[];
}
export interface IGroupProject {
groupId: number;
project: string;
}
export interface IGroupUserModel {
user: IUser;
role: string;
joinedAt?: Date;
}
export interface IGroupModelWithProjectRole extends IGroupModel {
roleId: number;
addedAt: Date;
}
export default class Group implements IGroup {
type: string;
createdAt: Date;
createdBy: string;
id: number;
name: string;
description: string;
constructor({ id, name, description, createdBy, createdAt }: IGroup) {
if (!id) {
throw new TypeError('Id is required');
}
Joi.assert(name, Joi.string(), 'Name');
this.id = id;
this.name = name;
this.description = description;
this.createdBy = createdBy;
this.createdAt = createdAt;
}
}

View File

@ -220,6 +220,7 @@ export interface IUserWithRole {
username?: string; username?: string;
email?: string; email?: string;
imageUrl?: string; imageUrl?: string;
addedAt: Date;
} }
export interface IRoleData { export interface IRoleData {

View File

@ -28,6 +28,7 @@ import { SegmentService } from '../services/segment-service';
import { OpenApiService } from '../services/openapi-service'; import { OpenApiService } from '../services/openapi-service';
import { ClientSpecService } from '../services/client-spec-service'; import { ClientSpecService } from '../services/client-spec-service';
import { PlaygroundService } from 'lib/services/playground-service'; import { PlaygroundService } from 'lib/services/playground-service';
import { GroupService } from '../services/group-service';
export interface IUnleashServices { export interface IUnleashServices {
accessService: AccessService; accessService: AccessService;
@ -43,6 +44,7 @@ export interface IUnleashServices {
featureToggleService: FeatureToggleService; featureToggleService: FeatureToggleService;
featureToggleServiceV2: FeatureToggleService; // deprecated featureToggleServiceV2: FeatureToggleService; // deprecated
featureTypeService: FeatureTypeService; featureTypeService: FeatureTypeService;
groupService: GroupService;
healthService: HealthService; healthService: HealthService;
projectHealthService: ProjectHealthService; projectHealthService: ProjectHealthService;
projectService: ProjectService; projectService: ProjectService;

View File

@ -25,6 +25,7 @@ import { IClientMetricsStoreV2 } from './stores/client-metrics-store-v2';
import { IUserSplashStore } from './stores/user-splash-store'; import { IUserSplashStore } from './stores/user-splash-store';
import { IRoleStore } from './stores/role-store'; import { IRoleStore } from './stores/role-store';
import { ISegmentStore } from './stores/segment-store'; import { ISegmentStore } from './stores/segment-store';
import { IGroupStore } from './stores/group-store';
export interface IUnleashStores { export interface IUnleashStores {
accessStore: IAccessStore; accessStore: IAccessStore;
@ -42,6 +43,7 @@ export interface IUnleashStores {
featureToggleStore: IFeatureToggleStore; featureToggleStore: IFeatureToggleStore;
featureToggleClientStore: IFeatureToggleClientStore; featureToggleClientStore: IFeatureToggleClientStore;
featureTypeStore: IFeatureTypeStore; featureTypeStore: IFeatureTypeStore;
groupStore: IGroupStore;
projectStore: IProjectStore; projectStore: IProjectStore;
resetTokenStore: IResetTokenStore; resetTokenStore: IResetTokenStore;
sessionStore: ISessionStore; sessionStore: ISessionStore;

View File

@ -25,44 +25,99 @@ export interface IRoleDescriptor {
type: string; type: string;
} }
export interface IUserRole { export interface IProjectAccessModel {
roleId: number; users: IAccessInfo[];
userId: number; groups: IAccessInfo[];
} }
export interface IAccessInfo {
id: number;
}
export interface IUserRole {
roleId?: number;
userId: number;
addedAt?: Date;
}
export interface IAccessStore extends Store<IRole, number> { export interface IAccessStore extends Store<IRole, number> {
getAvailablePermissions(): Promise<IPermission[]>; getAvailablePermissions(): Promise<IPermission[]>;
getPermissionsForUser(userId: number): Promise<IUserPermission[]>; getPermissionsForUser(userId: number): Promise<IUserPermission[]>;
getPermissionsForRole(roleId: number): Promise<IPermission[]>; getPermissionsForRole(roleId: number): Promise<IPermission[]>;
unlinkUserRoles(userId: number): Promise<void>; unlinkUserRoles(userId: number): Promise<void>;
getRolesForUserId(userId: number): Promise<IRole[]>; getRolesForUserId(userId: number): Promise<IRole[]>;
getProjectUserIdsForRole(roleId: number, projectId?: string);
getProjectUsersForRole(
roleId: number,
projectId?: string,
): Promise<IUserRole[]>;
getUserIdsForRole(roleId: number, projectId?: string): Promise<number[]>; getUserIdsForRole(roleId: number, projectId?: string): Promise<number[]>;
wipePermissionsFromRole(role_id: number): Promise<void>; wipePermissionsFromRole(role_id: number): Promise<void>;
addEnvironmentPermissionsToRole( addEnvironmentPermissionsToRole(
role_id: number, role_id: number,
permissions: IPermission[], permissions: IPermission[],
): Promise<void>; ): Promise<void>;
addUserToRole( addUserToRole(
userId: number, userId: number,
roleId: number, roleId: number,
projectId?: string, projectId?: string,
): Promise<void>; ): Promise<void>;
addAccessToProject(
users: IAccessInfo[],
groups: IAccessInfo[],
projectId: string,
roleId: number,
createdBy: string,
): Promise<void>;
removeUserFromRole( removeUserFromRole(
userId: number, userId: number,
roleId: number, roleId: number,
projectId?: string, projectId?: string,
): Promise<void>; ): Promise<void>;
addGroupToRole(
groupId: number,
roleId: number,
created_by: string,
projectId?: string,
): Promise<void>;
removeGroupFromRole(
groupId: number,
roleId: number,
projectId?: string,
): Promise<void>;
updateUserProjectRole( updateUserProjectRole(
userId: number, userId: number,
roleId: number, roleId: number,
projectId: string, projectId: string,
): Promise<void>; ): Promise<void>;
updateGroupProjectRole(
userId: number,
roleId: number,
projectId: string,
): Promise<void>;
removeRolesOfTypeForUser(userId: number, roleType: string): Promise<void>; removeRolesOfTypeForUser(userId: number, roleType: string): Promise<void>;
addPermissionsToRole( addPermissionsToRole(
role_id: number, role_id: number,
permissions: string[], permissions: string[],
environment?: string, environment?: string,
): Promise<void>; ): Promise<void>;
removePermissionFromRole( removePermissionFromRole(
roleId: number, roleId: number,
permission: string, permission: string,

View File

@ -0,0 +1,51 @@
import { Store } from './store';
import {
IGroup,
IGroupModel,
IGroupProject,
IGroupRole,
IGroupUser,
IGroupUserModel,
} from '../group';
export interface IStoreGroup {
name: string;
description: string;
}
export interface IGroupStore extends Store<IGroup, number> {
getGroupProjects(groupIds: number[]): Promise<IGroupProject[]>;
getProjectGroupRoles(projectId: string): Promise<IGroupRole[]>;
getAllWithId(ids: number[]): Promise<IGroup[]>;
updateGroupUsers(
groupId: number,
newUsers: IGroupUserModel[],
existingUsers: IGroupUserModel[],
deletableUsers: IGroupUser[],
userName: string,
): Promise<void>;
deleteOldUsersFromGroup(deletableUsers: IGroupUser[]): Promise<void>;
update(group: IGroupModel): Promise<IGroup>;
getAllUsersByGroups(groupIds: number[]): Promise<IGroupUser[]>;
addNewUsersToGroup(
groupId: number,
users: IGroupUserModel[],
userName: string,
): Promise<void>;
updateExistingUsersInGroup(
groupId: number,
users: IGroupUserModel[],
): Promise<void>;
existsWithName(name: string): Promise<boolean>;
create(group: IStoreGroup): Promise<IGroup>;
}

View File

@ -26,6 +26,10 @@ export interface IUser {
imageUrl: string; imageUrl: string;
} }
export interface IProjectUser extends IUser {
addedAt: Date;
}
export default class User implements IUser { export default class User implements IUser {
isAPI: boolean = false; isAPI: boolean = false;

View File

@ -0,0 +1,47 @@
'use strict';
exports.up = function (db, callback) {
db.runSql(
`
create table IF NOT EXISTS groups
(
id serial primary key,
name text not null,
description text,
created_by text,
created_at timestamp with time zone not null default now()
);
create table IF NOT EXISTS group_user
(
group_id integer not null references groups (id) on DELETE CASCADE,
user_id integer not null references users (id) ON DELETE CASCADE,
role text check(role in ('Owner', 'Member')),
created_by text,
created_at timestamp with time zone not null default now(),
primary key (group_id, user_id)
);
CREATE TABLE IF NOT EXISTS group_role
(
group_id integer not null references groups (id) ON DELETE CASCADE,
role_id integer not null references roles (id) ON DELETE CASCADE,
created_by text,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
project text,
PRIMARY KEY (group_id, role_id, project)
);
`,
callback,
);
};
exports.down = function (db, callback) {
db.runSql(
`
drop table group_role;
drop table group_user;
drop table groups;
`,
callback,
);
};

View File

@ -33,6 +33,7 @@ process.nextTick(async () => {
experimental: { experimental: {
metricsV2: { enabled: true }, metricsV2: { enabled: true },
anonymiseEventLog: false, anonymiseEventLog: false,
userGroups: true,
}, },
authentication: { authentication: {
initApiTokens: [ initApiTokens: [

View File

@ -22,6 +22,9 @@ export function createTestConfig(config?: IUnleashOptions): IUnleashConfig {
clientFeatureCaching: { clientFeatureCaching: {
enabled: false, enabled: false,
}, },
experimental: {
userGroups: true,
},
}; };
const options = mergeAll<IUnleashOptions>([testConfig, config]); const options = mergeAll<IUnleashOptions>([testConfig, config]);
return createConfig(options); return createConfig(options);

View File

@ -15,6 +15,7 @@ import SessionService from '../../../../lib/services/session-service';
import { RoleName } from '../../../../lib/types/model'; import { RoleName } from '../../../../lib/types/model';
import SettingService from '../../../../lib/services/setting-service'; import SettingService from '../../../../lib/services/setting-service';
import FakeSettingStore from '../../../fixtures/fake-setting-store'; import FakeSettingStore from '../../../fixtures/fake-setting-store';
import { GroupService } from '../../../../lib/services/group-service';
import FakeEventStore from '../../../fixtures/fake-event-store'; import FakeEventStore from '../../../fixtures/fake-event-store';
let app; let app;
@ -48,7 +49,8 @@ beforeAll(async () => {
db = await dbInit('reset_password_api_serial', getLogger); db = await dbInit('reset_password_api_serial', getLogger);
stores = db.stores; stores = db.stores;
app = await setupApp(stores); app = await setupApp(stores);
accessService = new AccessService(stores, config); const groupService = new GroupService(stores, config);
accessService = new AccessService(stores, config, groupService);
const emailService = new EmailService(config.email, config.getLogger); const emailService = new EmailService(config.email, config.getLogger);
const sessionStore = new SessionStore( const sessionStore = new SessionStore(
db, db,

View File

@ -1468,6 +1468,78 @@ Object {
"required": Array [], "required": Array [],
"type": "object", "type": "object",
}, },
"groupSchema": Object {
"additionalProperties": false,
"properties": Object {
"createdAt": Object {
"format": "date-time",
"nullable": true,
"type": "string",
},
"createdBy": Object {
"nullable": true,
"type": "string",
},
"description": Object {
"type": "string",
},
"id": Object {
"type": "number",
},
"name": Object {
"type": "string",
},
"projects": Object {
"items": Object {
"type": "string",
},
"type": "array",
},
"users": Object {
"items": Object {
"$ref": "#/components/schemas/groupUserModelSchema",
},
"type": "array",
},
},
"required": Array [
"name",
"users",
],
"type": "object",
},
"groupUserModelSchema": Object {
"additionalProperties": false,
"properties": Object {
"joinedAt": Object {
"format": "date-time",
"type": "string",
},
"role": Object {
"type": "string",
},
"user": Object {
"$ref": "#/components/schemas/userSchema",
},
},
"required": Array [
"role",
"user",
],
"type": "object",
},
"groupsSchema": Object {
"additionalProperties": false,
"properties": Object {
"groups": Object {
"items": Object {
"$ref": "#/components/schemas/groupSchema",
},
"type": "array",
},
},
"type": "object",
},
"healthCheckSchema": Object { "healthCheckSchema": Object {
"additionalProperties": false, "additionalProperties": false,
"properties": Object { "properties": Object {

View File

@ -13,10 +13,12 @@ import { createTestConfig } from '../../config/test-config';
import { DEFAULT_PROJECT } from '../../../lib/types/project'; import { DEFAULT_PROJECT } from '../../../lib/types/project';
import { ALL_PROJECTS } from '../../../lib/util/constants'; import { ALL_PROJECTS } from '../../../lib/util/constants';
import { SegmentService } from '../../../lib/services/segment-service'; import { SegmentService } from '../../../lib/services/segment-service';
import { GroupService } from '../../../lib/services/group-service';
let db: ITestDb; let db: ITestDb;
let stores: IUnleashStores; let stores: IUnleashStores;
let accessService; let accessService;
let groupService;
let featureToggleService; let featureToggleService;
let projectService; let projectService;
let editorUser; let editorUser;
@ -184,7 +186,7 @@ const hasFullProjectAccess = async (user, projectName, condition) => {
projectName, projectName,
), ),
); );
hasCommonProjectAccess(user, projectName, condition); await hasCommonProjectAccess(user, projectName, condition);
}; };
const createSuperUser = async () => { const createSuperUser = async () => {
@ -206,7 +208,8 @@ beforeAll(async () => {
// @ts-ignore // @ts-ignore
experimental: { environments: { enabled: true } }, experimental: { environments: { enabled: true } },
}); });
accessService = new AccessService(stores, { getLogger }); groupService = new GroupService(stores, { getLogger });
accessService = new AccessService(stores, { getLogger }, groupService);
const roles = await accessService.getRootRoles(); const roles = await accessService.getRootRoles();
editorRole = roles.find((r) => r.name === RoleName.EDITOR); editorRole = roles.find((r) => r.name === RoleName.EDITOR);
adminRole = roles.find((r) => r.name === RoleName.ADMIN); adminRole = roles.find((r) => r.name === RoleName.ADMIN);
@ -221,6 +224,7 @@ beforeAll(async () => {
config, config,
accessService, accessService,
featureToggleService, featureToggleService,
groupService,
); );
editorUser = await createUserEditorAccess('Bob Test', 'bob@getunleash.io'); editorUser = await createUserEditorAccess('Bob Test', 'bob@getunleash.io');
@ -286,13 +290,13 @@ test('should have project admin to default project as editor', async () => {
const projectName = 'default'; const projectName = 'default';
const user = editorUser; const user = editorUser;
hasFullProjectAccess(user, projectName, true); await hasFullProjectAccess(user, projectName, true);
}); });
test('should not have project admin to other projects as editor', async () => { test('should not have project admin to other projects as editor', async () => {
const projectName = 'unusedprojectname'; const projectName = 'unusedprojectname';
const user = editorUser; const user = editorUser;
hasFullProjectAccess(user, projectName, false); await hasFullProjectAccess(user, projectName, false);
}); });
test('cannot add CREATE_FEATURE without defining project', async () => { test('cannot add CREATE_FEATURE without defining project', async () => {
@ -371,7 +375,7 @@ test('should create default roles to project', async () => {
const project = 'some-project'; const project = 'some-project';
const user = editorUser; const user = editorUser;
await accessService.createDefaultProjectRoles(user, project); await accessService.createDefaultProjectRoles(user, project);
hasFullProjectAccess(user, project, true); await hasFullProjectAccess(user, project, true);
}); });
test('should require name when create default roles to project', async () => { test('should require name when create default roles to project', async () => {
@ -394,7 +398,7 @@ test('should grant user access to project', async () => {
await accessService.addUserToRole(sUser.id, projectRole.id, project); await accessService.addUserToRole(sUser.id, projectRole.id, project);
// // Should be able to update feature toggles inside the project // // Should be able to update feature toggles inside the project
hasCommonProjectAccess(sUser, project, true); await hasCommonProjectAccess(sUser, project, true);
// Should not be able to admin the project itself. // Should not be able to admin the project itself.
expect( expect(
@ -419,7 +423,7 @@ test('should not get access if not specifying project', async () => {
await accessService.addUserToRole(sUser.id, projectRole.id, project); await accessService.addUserToRole(sUser.id, projectRole.id, project);
// Should not be able to update feature toggles outside project // Should not be able to update feature toggles outside project
hasCommonProjectAccess(sUser, undefined, false); await hasCommonProjectAccess(sUser, undefined, false);
}); });
test('should remove user from role', async () => { test('should remove user from role', async () => {
@ -856,3 +860,172 @@ test('Should not be allowed to delete a project role', async () => {
); );
} }
}); });
test('Should be allowed move feature toggle to project when given access through group', async () => {
const project = 'yet-another-project';
const groupStore = stores.groupStore;
const viewerUser = await createUserViewerAccess(
'Victoria Viewer',
'vickyv@getunleash.io',
);
const groupWithProjectAccess = await groupStore.create({
name: 'Project Editors',
description: '',
});
await groupStore.addNewUsersToGroup(
groupWithProjectAccess.id,
[{ user: viewerUser, role: 'Owner' }],
'Admin',
);
const projectRole = await accessService.getRoleByName(RoleName.MEMBER);
await hasCommonProjectAccess(viewerUser, project, false);
await accessService.addGroupToRole(
groupWithProjectAccess.id,
projectRole.id,
'SomeAdminUser',
project,
);
await hasCommonProjectAccess(viewerUser, project, true);
});
test('Should not lose user role access when given permissions from a group', async () => {
const project = 'yet-another-project';
const user = editorUser;
const groupStore = stores.groupStore;
await accessService.createDefaultProjectRoles(user, project);
const groupWithNoAccess = await groupStore.create({
name: 'ViewersOnly',
description: '',
});
await groupStore.addNewUsersToGroup(
groupWithNoAccess.id,
[{ user: editorUser, role: 'Owner' }],
'Admin',
);
const viewerRole = await accessService.getRoleByName(RoleName.VIEWER);
await accessService.addGroupToRole(
groupWithNoAccess.id,
viewerRole.id,
'SomeAdminUser',
project,
);
await hasFullProjectAccess(editorUser, project, true);
});
test('Should allow user to take multiple group roles and have expected permissions on each project', async () => {
const projectForCreate =
'project-that-should-have-create-toggle-permission';
const projectForDelete =
'project-that-should-have-delete-toggle-permission';
const groupStore = stores.groupStore;
const viewerUser = await createUserViewerAccess(
'Victor Viewer',
'victore@getunleash.io',
);
const groupWithCreateAccess = await groupStore.create({
name: 'ViewersOnly',
description: '',
});
const groupWithDeleteAccess = await groupStore.create({
name: 'ViewersOnly',
description: '',
});
await groupStore.addNewUsersToGroup(
groupWithCreateAccess.id,
[{ user: viewerUser, role: 'Owner' }],
'Admin',
);
await groupStore.addNewUsersToGroup(
groupWithDeleteAccess.id,
[{ user: viewerUser, role: 'Owner' }],
'Admin',
);
const createFeatureRole = await accessService.createRole({
name: 'CreateRole',
description: '',
permissions: [
{
id: 2,
name: 'CREATE_FEATURE',
environment: null,
displayName: 'Create Feature Toggles',
type: 'project',
},
],
});
const deleteFeatureRole = await accessService.createRole({
name: 'DeleteRole',
description: '',
permissions: [
{
id: 8,
name: 'DELETE_FEATURE',
environment: null,
displayName: 'Delete Feature Toggles',
type: 'project',
},
],
});
await accessService.addGroupToRole(
groupWithCreateAccess.id,
deleteFeatureRole.id,
'SomeAdminUser',
projectForDelete,
);
await accessService.addGroupToRole(
groupWithDeleteAccess.id,
createFeatureRole.id,
'SomeAdminUser',
projectForCreate,
);
expect(
await accessService.hasPermission(
viewerUser,
permissions.CREATE_FEATURE,
projectForCreate,
),
).toBe(true);
expect(
await accessService.hasPermission(
viewerUser,
permissions.DELETE_FEATURE,
projectForCreate,
),
).toBe(false);
expect(
await accessService.hasPermission(
viewerUser,
permissions.CREATE_FEATURE,
projectForDelete,
),
).toBe(false);
expect(
await accessService.hasPermission(
viewerUser,
permissions.DELETE_FEATURE,
projectForDelete,
),
).toBe(true);
});

View File

@ -9,6 +9,7 @@ import ProjectService from '../../../lib/services/project-service';
import FeatureToggleService from '../../../lib/services/feature-toggle-service'; import FeatureToggleService from '../../../lib/services/feature-toggle-service';
import { AccessService } from '../../../lib/services/access-service'; import { AccessService } from '../../../lib/services/access-service';
import { SegmentService } from '../../../lib/services/segment-service'; import { SegmentService } from '../../../lib/services/segment-service';
import { GroupService } from '../../../lib/services/group-service';
let db; let db;
let stores; let stores;
@ -21,7 +22,8 @@ beforeAll(async () => {
}); });
db = await dbInit('api_token_service_serial', getLogger); db = await dbInit('api_token_service_serial', getLogger);
stores = db.stores; stores = db.stores;
const accessService = new AccessService(stores, config); const groupService = new GroupService(stores, config);
const accessService = new AccessService(stores, config, groupService);
const featureToggleService = new FeatureToggleService( const featureToggleService = new FeatureToggleService(
stores, stores,
config, config,
@ -42,6 +44,7 @@ beforeAll(async () => {
config, config,
accessService, accessService,
featureToggleService, featureToggleService,
groupService,
); );
await projectService.createProject(project, user); await projectService.createProject(project, user);

View File

@ -8,10 +8,12 @@ import { createTestConfig } from '../../config/test-config';
import { IUnleashStores } from '../../../lib/types'; import { IUnleashStores } from '../../../lib/types';
import { IUser } from '../../../lib/server-impl'; import { IUser } from '../../../lib/server-impl';
import { SegmentService } from '../../../lib/services/segment-service'; import { SegmentService } from '../../../lib/services/segment-service';
import { GroupService } from '../../../lib/services/group-service';
let stores: IUnleashStores; let stores: IUnleashStores;
let db: ITestDb; let db: ITestDb;
let projectService; let projectService;
let groupService;
let accessService; let accessService;
let projectHealthService; let projectHealthService;
let featureToggleService; let featureToggleService;
@ -25,7 +27,8 @@ beforeAll(async () => {
name: 'Some Name', name: 'Some Name',
email: 'test@getunleash.io', email: 'test@getunleash.io',
}); });
accessService = new AccessService(stores, config); groupService = new GroupService(stores, config);
accessService = new AccessService(stores, config, groupService);
featureToggleService = new FeatureToggleService( featureToggleService = new FeatureToggleService(
stores, stores,
config, config,
@ -36,6 +39,7 @@ beforeAll(async () => {
config, config,
accessService, accessService,
featureToggleService, featureToggleService,
groupService,
); );
projectHealthService = new ProjectHealthService( projectHealthService = new ProjectHealthService(
stores, stores,

View File

@ -10,11 +10,13 @@ import { randomId } from '../../../lib/util/random-id';
import EnvironmentService from '../../../lib/services/environment-service'; import EnvironmentService from '../../../lib/services/environment-service';
import IncompatibleProjectError from '../../../lib/error/incompatible-project-error'; import IncompatibleProjectError from '../../../lib/error/incompatible-project-error';
import { SegmentService } from '../../../lib/services/segment-service'; import { SegmentService } from '../../../lib/services/segment-service';
import { GroupService } from '../../../lib/services/group-service';
let stores; let stores;
let db: ITestDb; let db: ITestDb;
let projectService: ProjectService; let projectService: ProjectService;
let groupService: GroupService;
let accessService: AccessService; let accessService: AccessService;
let environmentService: EnvironmentService; let environmentService: EnvironmentService;
let featureToggleService: FeatureToggleService; let featureToggleService: FeatureToggleService;
@ -32,7 +34,8 @@ beforeAll(async () => {
// @ts-ignore // @ts-ignore
experimental: { environments: { enabled: true } }, experimental: { environments: { enabled: true } },
}); });
accessService = new AccessService(stores, config); groupService = new GroupService(stores, config);
accessService = new AccessService(stores, config, groupService);
featureToggleService = new FeatureToggleService( featureToggleService = new FeatureToggleService(
stores, stores,
config, config,
@ -44,6 +47,7 @@ beforeAll(async () => {
config, config,
accessService, accessService,
featureToggleService, featureToggleService,
groupService,
); );
}); });
@ -219,7 +223,7 @@ test('should get list of users with access to project', async () => {
description: 'Blah', description: 'Blah',
}; };
await projectService.createProject(project, user); await projectService.createProject(project, user);
const { users } = await projectService.getUsersWithAccess(project.id); const { users } = await projectService.getAccessToProject(project.id);
const member = await stores.roleStore.getRoleByName(RoleName.MEMBER); const member = await stores.roleStore.getRoleByName(RoleName.MEMBER);
const owner = await stores.roleStore.getRoleByName(RoleName.OWNER); const owner = await stores.roleStore.getRoleByName(RoleName.OWNER);
@ -253,7 +257,7 @@ test('should add a member user to the project', async () => {
await projectService.addUser(project.id, memberRole.id, projectMember1.id); await projectService.addUser(project.id, memberRole.id, projectMember1.id);
await projectService.addUser(project.id, memberRole.id, projectMember2.id); await projectService.addUser(project.id, memberRole.id, projectMember2.id);
const { users } = await projectService.getUsersWithAccess(project.id); const { users } = await projectService.getAccessToProject(project.id);
const memberUsers = users.filter((u) => u.roleId === memberRole.id); const memberUsers = users.filter((u) => u.roleId === memberRole.id);
expect(memberUsers).toHaveLength(2); expect(memberUsers).toHaveLength(2);
@ -285,7 +289,7 @@ test('should add admin users to the project', async () => {
await projectService.addUser(project.id, ownerRole.id, projectAdmin1.id); await projectService.addUser(project.id, ownerRole.id, projectAdmin1.id);
await projectService.addUser(project.id, ownerRole.id, projectAdmin2.id); await projectService.addUser(project.id, ownerRole.id, projectAdmin2.id);
const { users } = await projectService.getUsersWithAccess(project.id); const { users } = await projectService.getAccessToProject(project.id);
const adminUsers = users.filter((u) => u.roleId === ownerRole.id); const adminUsers = users.filter((u) => u.roleId === ownerRole.id);
@ -342,7 +346,7 @@ test('should remove user from the project', async () => {
projectMember1.id, projectMember1.id,
); );
const { users } = await projectService.getUsersWithAccess(project.id); const { users } = await projectService.getAccessToProject(project.id);
const memberUsers = users.filter((u) => u.roleId === memberRole.id); const memberUsers = users.filter((u) => u.roleId === memberRole.id);
expect(memberUsers).toHaveLength(0); expect(memberUsers).toHaveLength(0);
@ -615,7 +619,7 @@ test('should add a user to the project with a custom role', async () => {
await projectService.addUser(project.id, customRole.id, projectMember1.id); await projectService.addUser(project.id, customRole.id, projectMember1.id);
const { users } = await projectService.getUsersWithAccess(project.id); const { users } = await projectService.getAccessToProject(project.id);
const customRoleMember = users.filter((u) => u.roleId === customRole.id); const customRoleMember = users.filter((u) => u.roleId === customRole.id);
@ -712,7 +716,7 @@ test('should change a users role in the project', async () => {
const member = await stores.roleStore.getRoleByName(RoleName.MEMBER); const member = await stores.roleStore.getRoleByName(RoleName.MEMBER);
await projectService.addUser(project.id, member.id, projectUser.id); await projectService.addUser(project.id, member.id, projectUser.id);
const { users } = await projectService.getUsersWithAccess(project.id); const { users } = await projectService.getAccessToProject(project.id);
let memberUser = users.filter((u) => u.roleId === member.id); let memberUser = users.filter((u) => u.roleId === member.id);
expect(memberUser).toHaveLength(1); expect(memberUser).toHaveLength(1);
@ -721,7 +725,7 @@ test('should change a users role in the project', async () => {
await projectService.removeUser(project.id, member.id, projectUser.id); await projectService.removeUser(project.id, member.id, projectUser.id);
await projectService.addUser(project.id, customRole.id, projectUser.id); await projectService.addUser(project.id, customRole.id, projectUser.id);
let { users: updatedUsers } = await projectService.getUsersWithAccess( let { users: updatedUsers } = await projectService.getAccessToProject(
project.id, project.id,
); );
const customUser = updatedUsers.filter((u) => u.roleId === customRole.id); const customUser = updatedUsers.filter((u) => u.roleId === customRole.id);
@ -755,7 +759,7 @@ test('should update role for user on project', async () => {
'test', 'test',
); );
const { users } = await projectService.getUsersWithAccess(project.id); const { users } = await projectService.getAccessToProject(project.id);
const memberUsers = users.filter((u) => u.roleId === memberRole.id); const memberUsers = users.filter((u) => u.roleId === memberRole.id);
const ownerUsers = users.filter((u) => u.roleId === ownerRole.id); const ownerUsers = users.filter((u) => u.roleId === ownerRole.id);
@ -792,7 +796,7 @@ test('should able to assign role without existing members', async () => {
'test', 'test',
); );
const { users } = await projectService.getUsersWithAccess(project.id); const { users } = await projectService.getAccessToProject(project.id);
const memberUsers = users.filter((u) => u.roleId === memberRole.id); const memberUsers = users.filter((u) => u.roleId === memberRole.id);
const testUsers = users.filter((u) => u.roleId === testRole.id); const testUsers = users.filter((u) => u.roleId === testRole.id);
@ -828,3 +832,109 @@ test('should not update role for user on project when she is the owner', async (
new Error('A project must have at least one owner'), new Error('A project must have at least one owner'),
); );
}); });
test('Should allow bulk update of group permissions', async () => {
const project = 'bulk-update-project';
const groupStore = stores.groupStore;
const user1 = await stores.userStore.insert({
name: 'Vanessa Viewer',
email: 'vanv@getunleash.io',
});
const group1 = await groupStore.create({
name: 'ViewersOnly',
description: '',
});
const createFeatureRole = await accessService.createRole({
name: 'CreateRole',
description: '',
permissions: [
{
id: 2,
name: 'CREATE_FEATURE',
environment: null,
displayName: 'Create Feature Toggles',
type: 'project',
},
],
});
await projectService.addAccess(
project,
createFeatureRole.id,
{
users: [{ id: user1.id }],
groups: [{ id: group1.id }],
},
'some-admin-user',
);
});
test('Should bulk update of only users', async () => {
const project = 'bulk-update-project-users';
const user1 = await stores.userStore.insert({
name: 'Van Viewer',
email: 'vv@getunleash.io',
});
const createFeatureRole = await accessService.createRole({
name: 'CreateRoleForUsers',
description: '',
permissions: [
{
id: 2,
name: 'CREATE_FEATURE',
environment: null,
displayName: 'Create Feature Toggles',
type: 'project',
},
],
});
await projectService.addAccess(
project,
createFeatureRole.id,
{
users: [{ id: user1.id }],
groups: [],
},
'some-admin-user',
);
});
test('Should allow bulk update of only groups', async () => {
const project = 'bulk-update-project';
const groupStore = stores.groupStore;
const group1 = await groupStore.create({
name: 'ViewersOnly',
description: '',
});
const createFeatureRole = await accessService.createRole({
name: 'CreateRoleForGroups',
description: '',
permissions: [
{
id: 2,
name: 'CREATE_FEATURE',
environment: null,
displayName: 'Create Feature Toggles',
type: 'project',
},
],
});
await projectService.addAccess(
project,
createFeatureRole.id,
{
users: [],
groups: [{ id: group1.id }],
},
'some-admin-user',
);
});

View File

@ -11,6 +11,7 @@ import InvalidTokenError from '../../../lib/error/invalid-token-error';
import { IUser } from '../../../lib/types/user'; import { IUser } from '../../../lib/types/user';
import SettingService from '../../../lib/services/setting-service'; import SettingService from '../../../lib/services/setting-service';
import FakeSettingStore from '../../fixtures/fake-setting-store'; import FakeSettingStore from '../../fixtures/fake-setting-store';
import { GroupService } from '../../../lib/services/group-service';
import FakeEventStore from '../../fixtures/fake-event-store'; import FakeEventStore from '../../fixtures/fake-event-store';
const config: IUnleashConfig = createTestConfig(); const config: IUnleashConfig = createTestConfig();
@ -27,7 +28,8 @@ let sessionService: SessionService;
beforeAll(async () => { beforeAll(async () => {
db = await dbInit('reset_token_service_serial', getLogger); db = await dbInit('reset_token_service_serial', getLogger);
stores = db.stores; stores = db.stores;
accessService = new AccessService(stores, config); const groupService = new GroupService(stores, config);
accessService = new AccessService(stores, config, groupService);
resetTokenService = new ResetTokenService(stores, config); resetTokenService = new ResetTokenService(stores, config);
sessionService = new SessionService(stores, config); sessionService = new SessionService(stores, config);
const emailService = new EmailService(undefined, config.getLogger); const emailService = new EmailService(undefined, config.getLogger);

View File

@ -13,6 +13,7 @@ import { RoleName } from '../../../lib/types/model';
import SettingService from '../../../lib/services/setting-service'; import SettingService from '../../../lib/services/setting-service';
import { simpleAuthKey } from '../../../lib/types/settings/simple-auth-settings'; import { simpleAuthKey } from '../../../lib/types/settings/simple-auth-settings';
import { addDays, minutesToMilliseconds } from 'date-fns'; import { addDays, minutesToMilliseconds } from 'date-fns';
import { GroupService } from '../../../lib/services/group-service';
let db; let db;
let stores; let stores;
@ -26,7 +27,8 @@ beforeAll(async () => {
db = await dbInit('user_service_serial', getLogger); db = await dbInit('user_service_serial', getLogger);
stores = db.stores; stores = db.stores;
const config = createTestConfig(); const config = createTestConfig();
const accessService = new AccessService(stores, config); const groupService = new GroupService(stores, config);
const accessService = new AccessService(stores, config, groupService);
const resetTokenService = new ResetTokenService(stores, config); const resetTokenService = new ResetTokenService(stores, config);
const emailService = new EmailService(undefined, config.getLogger); const emailService = new EmailService(undefined, config.getLogger);
sessionService = new SessionService(stores, config); sessionService = new SessionService(stores, config);

View File

@ -9,6 +9,7 @@ import {
IRoleData, IRoleData,
IUserWithRole, IUserWithRole,
} from '../../lib/types/model'; } from '../../lib/types/model';
import { IGroupModelWithProjectRole } from '../../lib/types/group';
class AccessServiceMock extends AccessService { class AccessServiceMock extends AccessService {
constructor() { constructor() {
@ -20,6 +21,7 @@ class AccessServiceMock extends AccessService {
environmentStore: undefined, environmentStore: undefined,
}, },
{ getLogger: noLoggerProvider }, { getLogger: noLoggerProvider },
undefined,
); );
} }
@ -75,9 +77,9 @@ class AccessServiceMock extends AccessService {
throw new Error('Method not implemented.'); throw new Error('Method not implemented.');
} }
getProjectRoleUsers( getProjectRoleAccess(
projectId: string, projectId: string,
): Promise<[IRole[], IUserWithRole[]]> { ): Promise<[IRole[], IUserWithRole[], IGroupModelWithProjectRole[]]> {
throw new Error('Method not implemented.'); throw new Error('Method not implemented.');
} }

View File

@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */
import noLoggerProvider from './no-logger'; import noLoggerProvider from './no-logger';
import { import {
IAccessInfo,
IAccessStore, IAccessStore,
IRole, IRole,
IUserPermission, IUserPermission,
@ -9,6 +10,41 @@ import {
import { IAvailablePermissions, IPermission } from 'lib/types/model'; import { IAvailablePermissions, IPermission } from 'lib/types/model';
class AccessStoreMock implements IAccessStore { class AccessStoreMock implements IAccessStore {
addAccessToProject(
users: IAccessInfo[],
groups: IAccessInfo[],
projectId: string,
roleId: number,
createdBy: string,
): Promise<void> {
throw new Error('Method not implemented.');
}
updateGroupProjectRole(
userId: number,
roleId: number,
projectId: string,
): Promise<void> {
throw new Error('Method not implemented.');
}
addGroupToRole(
groupId: number,
roleId: number,
created_by: string,
projectId?: string,
): Promise<void> {
throw new Error('Method not implemented.');
}
removeGroupFromRole(
groupId: number,
roleId: number,
projectId?: string,
): Promise<void> {
throw new Error('Method not implemented.');
}
updateUserProjectRole( updateUserProjectRole(
userId: number, userId: number,
roleId: number, roleId: number,
@ -37,10 +73,10 @@ class AccessStoreMock implements IAccessStore {
throw new Error('Method not implemented.'); throw new Error('Method not implemented.');
} }
getProjectUserIdsForRole( getProjectUsersForRole(
roleId: number, roleId: number,
projectId?: string, projectId?: string,
): Promise<number[]> { ): Promise<IUserRole[]> {
throw new Error('Method not implemented.'); throw new Error('Method not implemented.');
} }

93
src/test/fixtures/fake-group-store.ts vendored Normal file
View File

@ -0,0 +1,93 @@
import { IGroupStore, IStoreGroup } from '../../lib/types/stores/group-store';
import {
IGroup,
IGroupModel,
IGroupProject,
IGroupRole,
IGroupUser,
IGroupUserModel,
} from '../../lib/types/group';
/* eslint-disable @typescript-eslint/no-unused-vars */
export default class FakeGroupStore implements IGroupStore {
data: IGroup[];
async getAll(): Promise<IGroup[]> {
return Promise.resolve(this.data);
}
async delete(id: number): Promise<void> {
this.data = this.data.filter((item) => item.id !== id);
return Promise.resolve();
}
deleteAll(): Promise<void> {
return Promise.resolve(undefined);
}
destroy(): void {}
async exists(key: number): Promise<boolean> {
return this.data.some((u) => u.id === key);
}
async get(key: number): Promise<IGroup> {
return this.data.find((u) => u.id === key);
}
create(group: IStoreGroup): Promise<IGroup> {
throw new Error('Method not implemented.');
}
existsWithName(name: string): Promise<boolean> {
throw new Error('Method not implemented.');
}
addNewUsersToGroup(
id: number,
users: IGroupUserModel[],
userName: string,
): Promise<void> {
throw new Error('Method not implemented.');
}
updateExistingUsersInGroup(
id: number,
users: IGroupUserModel[],
): Promise<void> {
throw new Error('Method not implemented.');
}
getAllUsersByGroups(groupIds: number[]): Promise<IGroupUser[]> {
throw new Error('Method not implemented.');
}
deleteOldUsersFromGroup(deletableUsers: IGroupUser[]): Promise<void> {
throw new Error('Method not implemented.');
}
update(group: IGroupModel): Promise<IGroup> {
throw new Error('Method not implemented.');
}
updateGroupUsers(
groupId: number,
newUsers: IGroupUserModel[],
existingUsers: IGroupUserModel[],
deletableUsers: IGroupUser[],
userName: string,
): Promise<void> {
throw new Error('Method not implemented.');
}
getAllWithId(ids: number[]): Promise<IGroup[]> {
throw new Error('Method not implemented.');
}
getProjectGroupRoles(projectId: string): Promise<IGroupRole[]> {
throw new Error('Method not implemented.');
}
getGroupProjects(groupIds: number[]): Promise<IGroupProject[]> {
throw new Error('Method not implemented.');
}
}

View File

@ -8,6 +8,10 @@ import {
} from 'lib/types/stores/role-store'; } from 'lib/types/stores/role-store';
export default class FakeRoleStore implements IRoleStore { export default class FakeRoleStore implements IRoleStore {
getGroupRolesForProject(projectId: string): Promise<IRole[]> {
throw new Error('Method not implemented.');
}
nameInUse(name: string, existingId: number): Promise<boolean> { nameInUse(name: string, existingId: number): Promise<boolean> {
throw new Error('Method not implemented.'); throw new Error('Method not implemented.');
} }

View File

@ -26,6 +26,7 @@ import FakeClientMetricsStoreV2 from './fake-client-metrics-store-v2';
import FakeUserSplashStore from './fake-user-splash-store'; import FakeUserSplashStore from './fake-user-splash-store';
import FakeRoleStore from './fake-role-store'; import FakeRoleStore from './fake-role-store';
import FakeSegmentStore from './fake-segment-store'; import FakeSegmentStore from './fake-segment-store';
import FakeGroupStore from './fake-group-store';
const createStores: () => IUnleashStores = () => { const createStores: () => IUnleashStores = () => {
const db = { const db = {
@ -63,6 +64,7 @@ const createStores: () => IUnleashStores = () => {
userSplashStore: new FakeUserSplashStore(), userSplashStore: new FakeUserSplashStore(),
roleStore: new FakeRoleStore(), roleStore: new FakeRoleStore(),
segmentStore: new FakeSegmentStore(), segmentStore: new FakeSegmentStore(),
groupStore: new FakeGroupStore(),
}; };
}; };

View File

@ -0,0 +1,81 @@
---
title: How to create and manage user groups
---
:::info availability
User groups are available to Unleash Enterprise users since **Unleash 4.14**.
:::
This guide takes you through how to use user groups to manage permissions on your projects. User groups allow you to manage large groups of users more easily than assigning roles directly to those users. Refer to the section on [user groups](../user_guide/rbac.md#user-groups) in the RBAC documentation for more information.
## Creating user groups
1. Navigate to groups by using the admin menu (the gear icon) and selecting the groups option.
![The Unleash Admin UI with the steps highlighted to navigate to groups.](/img/create-ug-step-1.png)
2. Navigate to new group.
![The groups screen with the new group button highlighted.](/img/create-ug-step-2.png)
3. Give the group a name and an optional description and select the users you'd like to be in the group.
![The new group screen with the users drop down open and highlighted.](/img/create-ug-step-3.png)
4. Review the details of the group and save them if you're happy.
![The new group screen with the users selected and the save button highlighted.](/img/create-ug-step-4.png)
## Managing users within a group
1. Navigate to groups by using the admin menu (the gear icon) and selecting the groups option.
![The Unleash Admin UI with the steps highlighted to navigate to groups.](/img/create-ug-step-1.png)
2. Select the card of the group you want to edit.
![The manage groups with a pointer to a group card.](/img/edit-ug-step-2.png)
3. Remove users by using the remove user button (displayed as a bin).
![The manage group page with the remove user button highlighted.](/img/remove-user-from-group-step-1.png)
4. Confirm the remove.
![The manage group page with the confirm user removal dialog shown.](/img/remove-user-from-group-step-2.png)
5. Add users by selecting the add button.
![The groups page shown with the add user button highlighted.](/img/add-user-to-group-step-1.png)
6. Find the user you'd like to add to the group add them.
![The groups page shown with a user selected.](/img/add-user-to-group-step-2.png)
7. Assign the user a role in the group and save the group. Remember that every group needs to have _at least_ one owner.
![The groups page shown with the user role highlighted.](/img/add-user-to-group-step-3.png)
## Assigning groups to projects
1. Navigate to projects
![The landing page with the projects navigation link highlighted.](/img/add-group-to-project-step-1.png)
2. Select the project you want to manage.
![The projects page with a project highlighted.](/img/add-group-to-project-step-2.png)
3. Navigate to the access tab and then use the assign user/group button.
![The project page with the access tab and assign button highlighted.](/img/add-group-to-project-step-3.png)
4. Find your group in the drop down.
![The access sidepane for a project with a group selected.](/img/add-group-to-project-step-4.png)
5. Select the role that the group should have in this project. You can review the list of permissions that the group users will gain by having this role before confirming.
![The access sidepane for a project with a role selected.](/img/add-group-to-project-step-5.png)

View File

@ -5,11 +5,9 @@ title: Activation Strategies
It is powerful to be able to turn a feature on and off instantaneously, without redeploying the application. The next level of control comes when you are able to enable a feature for specific users or enable it for a small subset of users. We achieve this level of control with the help of activation strategies. The most straightforward strategy is the standard strategy, which basically means that the feature should be enabled to everyone. It is powerful to be able to turn a feature on and off instantaneously, without redeploying the application. The next level of control comes when you are able to enable a feature for specific users or enable it for a small subset of users. We achieve this level of control with the help of activation strategies. The most straightforward strategy is the standard strategy, which basically means that the feature should be enabled to everyone.
Unleash comes with a number of built-in strategies (described below) and also lets you add your own [custom activation strategies](../advanced/custom-activation-strategy.md) if you need more control. Unleash comes with a number of built-in strategies (described below) and also lets you add your own [custom activation strategies](../advanced/custom-activation-strategy.md) if you need more control. However, while activation strategies are _defined_ on the server, the server does not _implement_ the strategies. Instead, activation strategy implementation is done client-side. This means that it is _the client_ that decides whether a feature should be enabled or not.
However, while activation strategies are *defined* on the server, the server does not *implement* the strategies. Instead, activation strategy implementation is done client-side. This means that it is *the client* that decides whether a feature should be enabled or not.
All [server-side client SDKs](../sdks/index.md#server-side-sdks) and the [Unleash Proxy](../sdks/unleash-proxy.md) implement the default strategies (and allow you to add your own [custom strategy implementations](../advanced/custom-activation-strategy.md#implementation)). All [server-side client SDKs](../sdks/index.md#server-side-sdks) and the [Unleash Proxy](../sdks/unleash-proxy.md) implement the default strategies (and allow you to add your own [custom strategy implementations](../advanced/custom-activation-strategy.md#implementation)). The [front-end client SDKs](../sdks/index.md#front-end-sdks) do not do the evaluation themselves, instead relying on the [Unleash Proxy](../sdks/unleash-proxy.md) to take care of the implementation and evaluation.
The [front-end client SDKs](../sdks/index.md#front-end-sdks) do not do the evaluation themselves, instead relying on the [Unleash Proxy](../sdks/unleash-proxy.md) to take care of the implementation and evaluation.
Some activation strategies require the client to provide the current [Unleash context](unleash-context.md) to the toggle evaluation function for the evaluation to be done correctly. Some activation strategies require the client to provide the current [Unleash context](unleash-context.md) to the toggle evaluation function for the evaluation to be done correctly.
@ -61,13 +59,10 @@ This strategy has the following modelling name in the code:
### Custom stickiness (beta) {#custom-stickiness} ### Custom stickiness (beta) {#custom-stickiness}
:::note :::note This feature is currently in beta and is not yet supported by all our SDKs. Check out the [SDK compatibility table](../sdks/index.md#server-side-sdk-compatibility-table) to see what SDKs support it at the moment. :::
This feature is currently in beta and is not yet supported by all our SDKs. Check out the [SDK compatibility table](../sdks/index.md#server-side-sdk-compatibility-table) to see what SDKs support it at the moment.
:::
By enabling the stickiness option on a custom context field you can use the custom context field to group users with the gradual rollout strategy. This will guarantee a consistent behavior for specific values of this context field. By enabling the stickiness option on a custom context field you can use the custom context field to group users with the gradual rollout strategy. This will guarantee a consistent behavior for specific values of this context field.
## IPs {#ips} ## IPs {#ips}
The remote address strategy activates a feature toggle for remote addresses defined in the IP list. We occasionally use this strategy to enable a feature only for IPs in our office network. The remote address strategy activates a feature toggle for remote addresses defined in the IP list. We occasionally use this strategy to enable a feature only for IPs in our office network.
@ -92,7 +87,6 @@ This strategy has the following modelling name in the code:
- **applicationHostname** - **applicationHostname**
## Multiple activation strategies {#multiple-activation-strategies} ## Multiple activation strategies {#multiple-activation-strategies}
You can apply as many activation strategies to a toggle as you want. When a toggle has multiple strategies, Unleash will check each strategy in isolation. If any one of the strategies would enable the toggle for the current user, then the toggle is enabled. You can apply as many activation strategies to a toggle as you want. When a toggle has multiple strategies, Unleash will check each strategy in isolation. If any one of the strategies would enable the toggle for the current user, then the toggle is enabled.

View File

@ -7,9 +7,9 @@ This document forms the specifications for [Role-Based Access Control](https://e
## Core principles {#core-principles} ## Core principles {#core-principles}
Unleash has two levels in its hierarchy of resources: Unleash has two levels in its hierarchy of resources:
1. **Global resources** - Everything that lives across the entire Unleash instance. Examples of this includes: 1. **Global resources** - Everything that lives across the entire Unleash instance. Examples of this include:
- activation strategies - activation strategies
- context field definitions - context field definitions
- addon configurations - addon configurations
@ -27,28 +27,51 @@ Unleash comes with a set of built-in roles that you can use. The _global roles_
When you add a new user, you can assign them one of the global roles listed below. When you add a new user, you can assign them one of the global roles listed below.
| Role | Scope | Description | Availability | | Role | Scope | Description | Availability |
|------------|---------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------| | --- | --- | --- | --- |
| **Admin** | Global | Users with the global admin role have superuser access to Unleash and can perform any operation within the Unleash platform. | All versions | | **Admin** | Global | Users with the global admin role have superuser access to Unleash and can perform any operation within the Unleash platform. | All versions |
| **Editor** | Global | Users with the editor role have access to most features in Unleash but can not manage users and roles in the global scope. Editors will be added as project owners when creating projects and get superuser rights within the context of these projects. Users with the editor role will also get access to most permissions on the default project by default. | All versions | | **Editor** | Global | Users with the editor role have access to most features in Unleash but can not manage users and roles in the global scope. Editors will be added as project owners when creating projects and get superuser rights within the context of these projects. Users with the editor role will also get access to most permissions on the default project by default. | All versions |
| **Viewer** | Global | Users with the viewer role can read global resources in Unleash. | All versions | | **Viewer** | Global | Users with the viewer role can read global resources in Unleash. | All versions |
| **Owner** | Project | Users with this the project owner role have full control over the project, and can add and manage other users within the project context; manage feature toggles within the project; and control advanced project features like archiving and deleting the project. | Pro and Enterprise | | **Owner** | Project | Users with this the project owner role have full control over the project, and can add and manage other users within the project context; manage feature toggles within the project; and control advanced project features like archiving and deleting the project. | Pro and Enterprise |
| **Member** | Project | Users with the project member role are allowed to view, create, and update feature toggles within a project, but have limited permissions in regards to managing the project's user access and can not archive or delete the project. | Pro and Enterprise | | **Member** | Project | Users with the project member role are allowed to view, create, and update feature toggles within a project, but have limited permissions in regards to managing the project's user access and can not archive or delete the project. | Pro and Enterprise |
## Custom Project Roles ## Custom Project Roles
:::info availability :::info availability
Custom project roles were introduced in **Unleash 4.6** and are only available in Unleash Enterprise. Custom project roles were introduced in **Unleash 4.6** and are only available in Unleash Enterprise.
::: :::
Custom project roles let you define your own roles with a specific set of project permissions down to the environment level. The roles can then be assigned to users in specific projects. All users have viewer access to all projects and resources, but must be assigned a project role to be allowed to edit a project's resources. For a step-by-step walkthrough of how to create and assign custom project roles, see [_how to create and assign custom project roles_](../how-to/how-to-create-and-assign-custom-project-roles.md). Custom project roles let you define your own roles with a specific set of project permissions down to the environment level. The roles can then be assigned to users in specific projects. All users have viewer access to all projects and resources, but must be assigned a project role to be allowed to edit a project's resources. For a step-by-step walkthrough of how to create and assign custom project roles, see [_how to create and assign custom project roles_](../how-to/how-to-create-and-assign-custom-project-roles.md).
Each custom project role consists of: Each custom project role consists of:
- a **name** (required) - a **name** (required)
- a **role description** (optional) - a **role description** (optional)
- a set of **project permissions** (optional) - a set of **project permissions** (optional)
- a set of **environment permissions** (optional) - a set of **environment permissions** (optional)
## User Groups
:::info availability
User groups are available to Unleash Enterprise users since **Unleash 4.14**.
:::
User groups allow you to assign roles to a group of users within a project, rather than to a user directly. This allows you to manage your user permissions more easily when there's lots of users in the system. For a guide on how to create and manage user groups see [_how to create and manage user groups_](../how-to/how-to-create-and-manage-user-groups.md).
A user group consists of the following:
- a **name** (required)
- a **description** (optional)
- one or more users. At least one user must have the owner role
Groups do nothing on their own. They must be given a role on a project to assign permissions.
While a user can only have one role in a given project, a user may belong to multiple groups, and each of those groups may be given a role on a project. In the case where a given user is given permissions to a project through more than one group, the user will inherit most permissive permissions of all their groups in that project.
### Project permissions ### Project permissions
You can assign the following project permissions. The permissions will be valid across all of the project's environments. You can assign the following project permissions. The permissions will be valid across all of the project's environments.

View File

@ -85,6 +85,7 @@ module.exports = {
items: [ items: [
'user_guide/user-management', 'user_guide/user-management',
'how-to/how-to-create-and-assign-custom-project-roles', 'how-to/how-to-create-and-assign-custom-project-roles',
'how-to/how-to-create-and-manage-user-groups',
], ],
type: 'category', type: 'category',
link: { link: {

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB