1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-18 13:48:58 +02:00

feat: custom project roles (#1220)

* wip: environment for permissions

* fix: add migration for roles

* fix: connect environment with access service

* feat: add tests

* chore: Implement scaffolding for new rbac

* fix: add fake store

* feat: Add api endpoints for roles and permissions list

* feat: Add ability to provide permissions when creating a role and rename environmentName to name in the list permissions datastructure

* fix: Make project roles resolve correctly against new environments permissions structure

* fix: Patch migration to also populate permission names

* fix: Make permissions actually work with new environments

* fix: Add back to get permissions working for editor role

* fix: Removed ability to set role type through api during creation - it's now always custom

* feat: Return permissions on get role endpoint

* feat: Add in support for updating roles

* fix: Get a bunch of tests working and delete a few that make no sense anymore

* chore: A few small cleanups - remove logging and restore default on dev server config

* chore: Refactor role/access stores into more logical domains

* feat: Add in validation for roles

* feat: Patch db migration to handle old stucture

* fix: migration for project roles

* fix: patch a few broken tests

* fix: add permissions to editor

* fix: update test name

* fix: update user permission mapping

* fix: create new user

* fix: update root role test

* fix: update tests

* feat: Validation now works when updating a role

* fix: Add in very barebones down migration for rbac so that tests work

* fix: Improve responses from role resolution - getting a non existant role will throw a NotFound error

* fix: remove unused permissions

* fix: add test for connecting roles and deleting project

* fix: add test for adding a project member with a custom role

* fix: add test for changing user role

* fix: add guard for deleting role if the role is in use

* fix: alter migration

* chore: Minor code cleanups

* chore: Small code cleanups

* chore: More minor cleanups of code

* chore: Trim some dead code to make the linter happy

* feat: Schema validation for roles

* fix: setup permission for variant

* fix: remove unused import

* feat: Add cascading delete for role_permissions when deleting a role

* feat: add configuration option for disabling legacy api

* chore: update frontend to beta version

* 4.6.0-beta.0

* fix: export default project constant

* fix: update snapshot

* fix: module pattern ../../lib

* fix: move DEFAULT_PROJECT to types

* fix: remove debug logging

* fix: remove debug log state

* fix: Change permission descriptions

* fix: roles should have unique name

* fix: root roles should be connected to the default project

* fix: typo in role-schema.ts

* fix: Role permission empty string for non environment type

* feat: new permission for moving project

* fix: add event for changeProject

* fix: Removing a user from a project will now check to see if that project has an owner, rather than checking if any project has an owner

* fix: add tests for move project

* fix: Add in missing create/delete tag permissions

* fix: Removed duplicate impl caused by multiple good samaritans putting it back in!

* fix: Trim out add tag permissions, for now at least

* chore: Trim out new add and delete tag permissions - we're going with update feature instead

* chore: update frontend

* 4.6.0-beta.1

* feat: Prevent editing of built in roles

* fix: Patch an issue where permissions for variants/environments didn't match the front end

* fix: lint

Co-authored-by: Ivar Conradi Østhus <ivarconr@gmail.com>
Co-authored-by: Fredrik Oseberg <fredrik.no@gmail.com>
This commit is contained in:
sighphyre 2022-01-13 12:14:17 +02:00 committed by GitHub
parent 18c87cedf6
commit 0c78980502
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 2327 additions and 670 deletions

View File

@ -1,7 +1,7 @@
{
"name": "unleash-server",
"description": "Unleash is an enterprise ready feature toggles service. It provides different strategies for handling feature toggles.",
"version": "4.5.1",
"version": "4.6.0-beta.1",
"keywords": [
"unleash",
"feature toggle",
@ -111,7 +111,7 @@
"response-time": "^2.3.2",
"serve-favicon": "^2.5.0",
"stoppable": "^1.1.0",
"unleash-frontend": "4.4.1",
"unleash-frontend": "4.6.0-beta.1",
"uuid": "^8.3.2"
},
"devDependencies": {

View File

@ -30,13 +30,14 @@ Object {
"user": "unleash",
"version": undefined,
},
"disableLegacyFeaturesApi": false,
"email": Object {
"host": undefined,
"host": "smtp.ethereal.email",
"port": 587,
"secure": false,
"sender": "noreply@unleash-hosted.com",
"smtppass": undefined,
"smtpuser": undefined,
"smtppass": "DtBAy8kzwhMjzbY5UJ",
"smtpuser": "maureen.heaney@ethereal.email",
},
"enableOAS": false,
"enterpriseVersion": undefined,

View File

@ -287,6 +287,10 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig {
const enableOAS =
options.enableOAS || safeBoolean(process.env.ENABLE_OAS, false);
const disableLegacyFeaturesApi =
options.disableLegacyFeaturesApi ||
safeBoolean(process.env.DISABLE_LEGACY_FEATURES_API, false);
return {
db,
session,
@ -301,6 +305,7 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig {
email,
secureHeaders,
enableOAS,
disableLegacyFeaturesApi,
preHook: options.preHook,
preRouterHook: options.preRouterHook,
eventHook: options.eventHook,

View File

@ -7,15 +7,32 @@ import {
IAccessStore,
IRole,
IUserPermission,
IUserRole,
} from '../types/stores/access-store';
import { IPermission } from '../types/model';
import NotFoundError from '../error/notfound-error';
import {
ENVIRONMENT_PERMISSION_TYPE,
ROOT_PERMISSION_TYPE,
} from '../util/constants';
const T = {
ROLE_USER: 'role_user',
ROLES: 'roles',
ROLE_PERMISSION: 'role_permission',
PERMISSIONS: 'permissions',
PERMISSION_TYPES: 'permission_types',
};
interface IPermissionRow {
id: number;
permission: string;
display_name: string;
environment?: string;
type: string;
project?: string;
role_id: number;
}
export class AccessStore implements IAccessStore {
private logger: Logger;
@ -53,75 +70,141 @@ export class AccessStore implements IAccessStore {
}
async get(key: number): Promise<IRole> {
return this.db
const role = await this.db
.select(['id', 'name', 'type', 'description'])
.where('id', key)
.first()
.from<IRole>(T.ROLES);
if (!role) {
throw new NotFoundError(`Could not find role with id: ${key}`);
}
return role;
}
async getAll(): Promise<IRole[]> {
return Promise.resolve([]);
}
async getAvailablePermissions(): Promise<IPermission[]> {
const rows = await this.db
.select(['id', 'permission', 'type', 'display_name'])
.where('type', 'project')
.orWhere('type', 'environment')
.from(`${T.PERMISSIONS} as p`);
return rows.map(this.mapPermission);
}
mapPermission(permission: IPermissionRow): IPermission {
return {
id: permission.id,
name: permission.permission,
displayName: permission.display_name,
type: permission.type,
};
}
async getPermissionsForUser(userId: number): Promise<IUserPermission[]> {
const stopTimer = this.timer('getPermissionsForUser');
const rows = await this.db
.select('project', 'permission')
.from<IUserPermission>(`${T.ROLE_PERMISSION} AS rp`)
.leftJoin(`${T.ROLE_USER} AS ur`, 'ur.role_id', 'rp.role_id')
.select(
'project',
'permission',
'environment',
'type',
'ur.role_id',
)
.from<IPermissionRow>(`${T.ROLE_PERMISSION} AS rp`)
.join(`${T.ROLE_USER} AS ur`, 'ur.role_id', 'rp.role_id')
.join(`${T.PERMISSIONS} AS p`, 'p.id', 'rp.permission_id')
.where('ur.user_id', '=', userId);
stopTimer();
return rows;
return rows.map(this.mapUserPermission);
}
async getPermissionsForRole(roleId: number): Promise<IUserPermission[]> {
mapUserPermission(row: IPermissionRow): IUserPermission {
let project: string = undefined;
// Since the editor should have access to the default project,
// we map the project to the project and environment specific
// permissions that are connected to the editor role.
if (row.type !== ROOT_PERMISSION_TYPE) {
project = row.project;
}
const environment =
row.type === ENVIRONMENT_PERMISSION_TYPE
? row.environment
: undefined;
const result = {
project,
environment,
permission: row.permission,
};
return result;
}
async getPermissionsForRole(roleId: number): Promise<IPermission[]> {
const stopTimer = this.timer('getPermissionsForRole');
const rows = await this.db
.select('project', 'permission')
.from<IUserPermission>(`${T.ROLE_PERMISSION}`)
.where('role_id', '=', roleId);
.select(
'p.id',
'p.permission',
'rp.environment',
'p.display_name',
'p.type',
)
.from<IPermission>(`${T.ROLE_PERMISSION} as rp`)
.join(`${T.PERMISSIONS} as p`, 'p.id', 'rp.permission_id')
.where('rp.role_id', '=', roleId);
stopTimer();
return rows;
return rows.map((permission) => {
return {
id: permission.id,
name: permission.permission,
environment: permission.environment,
displayName: permission.display_name,
type: permission.type,
};
});
}
async getRoles(): Promise<IRole[]> {
return this.db
.select(['id', 'name', 'type', 'description'])
.from<IRole>(T.ROLES);
async addEnvironmentPermissionsToRole(
role_id: number,
permissions: IPermission[],
): Promise<void> {
const rows = permissions.map((permission) => {
return {
role_id,
permission_id: permission.id,
environment: permission.environment,
};
});
this.db.batchInsert(T.ROLE_PERMISSION, rows);
}
async getRoleWithId(id: number): Promise<IRole> {
return this.db
.select(['id', 'name', 'type', 'description'])
.where('id', id)
.first()
.from<IRole>(T.ROLES);
}
async getRolesForProject(projectId: string): Promise<IRole[]> {
return this.db
.select(['id', 'name', 'type', 'project', 'description'])
.from<IRole>(T.ROLES)
.where('project', projectId)
.andWhere('type', 'project');
}
async getRootRoles(): Promise<IRole[]> {
return this.db
.select(['id', 'name', 'type', 'project', 'description'])
.from<IRole>(T.ROLES)
.andWhere('type', 'root');
}
async removeRolesForProject(projectId: string): Promise<void> {
return this.db(T.ROLES)
async unlinkUserRoles(userId: number): Promise<void> {
return this.db(T.ROLE_USER)
.where({
project: projectId,
user_id: userId,
})
.delete();
}
async getProjectUserIdsForRole(
roleId: number,
projectId?: string,
): Promise<number[]> {
const rows = await this.db
.select(['user_id'])
.from<IRole>(`${T.ROLE_USER} AS ru`)
.join(`${T.ROLES} as r`, 'ru.role_id', 'id')
.where('r.id', roleId)
.andWhere('ru.project', projectId);
return rows.map((r) => r.user_id);
}
async getRolesForUserId(userId: number): Promise<IRole[]> {
return this.db
.select(['id', 'name', 'type', 'project', 'description'])
@ -138,18 +221,28 @@ export class AccessStore implements IAccessStore {
return rows.map((r) => r.user_id);
}
async addUserToRole(userId: number, roleId: number): Promise<void> {
async addUserToRole(
userId: number,
roleId: number,
projecId?: string,
): Promise<void> {
return this.db(T.ROLE_USER).insert({
user_id: userId,
role_id: roleId,
project: projecId,
});
}
async removeUserFromRole(userId: number, roleId: number): Promise<void> {
async removeUserFromRole(
userId: number,
roleId: number,
projectId?: string,
): Promise<void> {
return this.db(T.ROLE_USER)
.where({
user_id: userId,
role_id: roleId,
project: projectId,
})
.delete();
}
@ -168,67 +261,51 @@ export class AccessStore implements IAccessStore {
.delete();
}
async createRole(
name: string,
type: string,
project?: string,
description?: string,
): Promise<IRole> {
const [id] = await this.db(T.ROLES)
.insert({
name,
description,
type,
project,
})
.returning('id');
return {
id,
name,
description,
type,
project,
};
}
async addPermissionsToRole(
role_id: number,
permissions: string[],
projectId?: string,
environment?: string,
): Promise<void> {
const rows = permissions.map((permission) => ({
const rows = await this.db
.select('id as permissionId')
.from<number>(T.PERMISSIONS)
.whereIn('permission', permissions);
const newRoles = rows.map((row) => ({
role_id,
project: projectId,
permission,
environment,
permission_id: row.permissionId,
}));
return this.db.batchInsert(T.ROLE_PERMISSION, rows);
return this.db.batchInsert(T.ROLE_PERMISSION, newRoles);
}
async removePermissionFromRole(
roleId: number,
role_id: number,
permission: string,
projectId?: string,
environment?: string,
): Promise<void> {
const rows = await this.db
.select('id as permissionId')
.from<number>(T.PERMISSIONS)
.where('permission', permission);
const permissionId = rows[0].permissionId;
return this.db(T.ROLE_PERMISSION)
.where({
role_id: roleId,
permission,
project: projectId,
role_id,
permission_id: permissionId,
environment,
})
.delete();
}
async getRootRoleForAllUsers(): Promise<IUserRole[]> {
const rows = await this.db
.select('id', 'user_id')
.distinctOn('user_id')
.from(`${T.ROLES} AS r`)
.leftJoin(`${T.ROLE_USER} AS ru`, 'r.id', 'ru.role_id')
.where('r.type', '=', 'root');
return rows.map((row) => ({
roleId: +row.id,
userId: +row.user_id,
}));
async wipePermissionsFromRole(role_id: number): Promise<void> {
return this.db(T.ROLE_PERMISSION)
.where({
role_id,
})
.delete();
}
}

View File

@ -27,6 +27,7 @@ import FeatureTagStore from './feature-tag-store';
import { FeatureEnvironmentStore } from './feature-environment-store';
import { ClientMetricsStoreV2 } from './client-metrics-store-v2';
import UserSplashStore from './user-splash-store';
import RoleStore from './role-store';
export const createStores = (
config: IUnleashConfig,
@ -77,6 +78,7 @@ export const createStores = (
getLogger,
),
userSplashStore: new UserSplashStore(db, eventBus, getLogger),
roleStore: new RoleStore(db, eventBus, getLogger),
};
};

View File

@ -88,7 +88,7 @@ class ProjectStore implements IProjectStore {
const row = await this.db(TABLE)
.insert(this.fieldToRow(project))
.returning('*');
return this.mapRow(row);
return this.mapRow(row[0]);
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types

185
src/lib/db/role-store.ts Normal file
View File

@ -0,0 +1,185 @@
import EventEmitter from 'events';
import { Knex } from 'knex';
import { Logger, LogProvider } from '../logger';
import NotFoundError from '../error/notfound-error';
import { ICustomRole } from 'lib/types/model';
import {
ICustomRoleInsert,
ICustomRoleUpdate,
IRoleStore,
} from 'lib/types/stores/role-store';
import { IRole, IUserRole } from 'lib/types/stores/access-store';
const T = {
ROLE_USER: 'role_user',
ROLES: 'roles',
};
const COLUMNS = ['id', 'name', 'description', 'type'];
interface IRoleRow {
id: number;
name: string;
description: string;
type: string;
}
export default class RoleStore implements IRoleStore {
private logger: Logger;
private eventBus: EventEmitter;
private db: Knex;
constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) {
this.db = db;
this.eventBus = eventBus;
this.logger = getLogger('lib/db/role-store.ts');
}
async getAll(): Promise<ICustomRole[]> {
const rows = await this.db
.select(COLUMNS)
.from(T.ROLES)
.orderBy('name', 'asc');
return rows.map(this.mapRow);
}
async create(role: ICustomRoleInsert): Promise<ICustomRole> {
const row = await this.db(T.ROLES)
.insert({
name: role.name,
description: role.description,
type: role.roleType,
})
.returning('*');
return this.mapRow(row[0]);
}
async delete(id: number): Promise<void> {
return this.db(T.ROLES).where({ id }).del();
}
async get(id: number): Promise<ICustomRole> {
const rows = await this.db.select(COLUMNS).from(T.ROLES).where({ id });
if (rows.length === 0) {
throw new NotFoundError(`Could not find role with id: ${id}`);
}
return this.mapRow(rows[0]);
}
async update(role: ICustomRoleUpdate): Promise<ICustomRole> {
const rows = await this.db(T.ROLES)
.where({
id: role.id,
})
.update({
id: role.id,
name: role.name,
description: role.description,
})
.returning('*');
return this.mapRow(rows[0]);
}
async exists(id: number): Promise<boolean> {
const result = await this.db.raw(
`SELECT EXISTS (SELECT 1 FROM ${T.ROLES} WHERE id = ?) AS present`,
[id],
);
const { present } = result.rows[0];
return present;
}
async nameInUse(name: string, existingId?: number): Promise<boolean> {
let query = this.db(T.ROLES).where({ name }).returning('id');
if (existingId) {
query = query.andWhereNot({ id: existingId });
}
const result = await query;
return result.length > 0;
}
async deleteAll(): Promise<void> {
return this.db(T.ROLES).del();
}
mapRow(row: IRoleRow): ICustomRole {
if (!row) {
throw new NotFoundError('No row');
}
return {
id: row.id,
name: row.name,
description: row.description,
type: row.type,
};
}
async getRoles(): Promise<IRole[]> {
return this.db
.select(['id', 'name', 'type', 'description'])
.from<IRole>(T.ROLES);
}
async getRoleWithId(id: number): Promise<IRole> {
return this.db
.select(['id', 'name', 'type', 'description'])
.where('id', id)
.first()
.from<IRole>(T.ROLES);
}
async getProjectRoles(): Promise<IRole[]> {
return this.db
.select(['id', 'name', 'type', 'description'])
.from<IRole>(T.ROLES)
.where('type', 'custom')
.orWhere('type', 'project');
}
async getRolesForProject(projectId: string): Promise<IRole[]> {
return this.db
.select(['r.id', 'r.name', 'r.type', 'ru.project', 'r.description'])
.from<IRole>(`${T.ROLE_USER} as ru`)
.innerJoin(`${T.ROLES} as r`, 'ru.role_id', 'r.id')
.where('project', projectId);
}
async getRootRoles(): Promise<IRole[]> {
return this.db
.select(['id', 'name', 'type', 'description'])
.from<IRole>(T.ROLES)
.where('type', 'root');
}
async removeRolesForProject(projectId: string): Promise<void> {
return this.db(T.ROLE_USER)
.where({
project: projectId,
})
.delete();
}
async getRootRoleForAllUsers(): Promise<IUserRole[]> {
const rows = await this.db
.select('id', 'user_id')
.distinctOn('user_id')
.from(`${T.ROLES} AS r`)
.leftJoin(`${T.ROLE_USER} AS ru`, 'r.id', 'ru.role_id')
.where('r.type', '=', 'root');
return rows.map((row) => ({
roleId: Number(row.id),
userId: Number(row.user_id),
}));
}
async getRoleByName(name: string): Promise<IRole> {
return this.db(T.ROLES).where({ name }).first();
}
destroy(): void {}
}

View File

@ -0,0 +1,23 @@
class RoleInUseError extends Error {
constructor(message: string) {
super();
Error.captureStackTrace(this, this.constructor);
this.name = this.constructor.name;
this.message = message;
}
toJSON(): object {
return {
isJoi: true,
name: this.constructor.name,
details: [
{
message: this.message,
},
],
};
}
}
export default RoleInUseError;

View File

@ -152,6 +152,7 @@ test('should verify permission for root resource', async () => {
req.user,
perms.ADMIN,
undefined,
undefined,
);
});
@ -181,6 +182,7 @@ test('should lookup projectId from params', async () => {
req.user,
perms.UPDATE_PROJECT,
req.params.projectId,
undefined,
);
});
@ -215,6 +217,7 @@ test('should lookup projectId from feature toggle', async () => {
req.user,
perms.UPDATE_FEATURE,
projectId,
undefined,
);
});
@ -249,6 +252,7 @@ test('should lookup projectId from data', async () => {
req.user,
perms.CREATE_FEATURE,
projectId,
undefined,
);
});
@ -275,6 +279,7 @@ test('Does not double check permission if not changing project when updating tog
req.user,
perms.UPDATE_FEATURE,
oldProjectId,
undefined,
);
});
@ -298,6 +303,7 @@ test('UPDATE_TAG_TYPE does not need projectId', async () => {
req.user,
perms.UPDATE_TAG_TYPE,
undefined,
undefined,
);
});
@ -321,5 +327,6 @@ test('DELETE_TAG_TYPE does not need projectId', async () => {
req.user,
perms.DELETE_TAG_TYPE,
undefined,
undefined,
);
});

View File

@ -14,6 +14,7 @@ interface PermissionChecker {
user: User,
permission: string,
projectId?: string,
environment?: string,
): Promise<boolean>;
}
@ -44,7 +45,7 @@ const rbacMiddleware = (
}
// For /api/admin/projects/:projectId we will find it as part of params
let { projectId } = params;
let { projectId, environment } = params;
// Temporary workaround to figure out projectId for feature toggle updates.
// will be removed in Unleash v5.0
@ -55,7 +56,12 @@ const rbacMiddleware = (
projectId = req.body.project || 'default';
}
return accessService.hasPermission(user, permission, projectId);
return accessService.hasPermission(
user,
permission,
projectId,
environment,
);
};
return next();
};

View File

@ -30,10 +30,14 @@ class AdminApi extends Controller {
super(config);
this.app.get('/', this.index);
this.app.use(
'/features',
new FeatureController(config, services).router,
);
if (!config.disableLegacyFeaturesApi) {
this.app.use(
'/features',
new FeatureController(config, services).router,
);
}
this.app.use(
'/feature-types',
new FeatureTypeController(config, services).router,

View File

@ -7,8 +7,12 @@ import FeatureToggleService from '../../../services/feature-toggle-service';
import { Logger } from '../../../logger';
import {
CREATE_FEATURE,
CREATE_FEATURE_STRATEGY,
DELETE_FEATURE,
DELETE_FEATURE_STRATEGY,
UPDATE_FEATURE,
UPDATE_FEATURE_ENVIRONMENT,
UPDATE_FEATURE_STRATEGY,
} from '../../../types/permissions';
import {
FeatureToggleDTO,
@ -70,16 +74,40 @@ export default class ProjectFeaturesController extends Controller {
// Environments
this.get(`${PATH_ENV}`, this.getEnvironment);
this.post(`${PATH_ENV}/on`, this.toggleEnvironmentOn, UPDATE_FEATURE);
this.post(`${PATH_ENV}/off`, this.toggleEnvironmentOff, UPDATE_FEATURE);
this.post(
`${PATH_ENV}/on`,
this.toggleEnvironmentOn,
UPDATE_FEATURE_ENVIRONMENT,
);
this.post(
`${PATH_ENV}/off`,
this.toggleEnvironmentOff,
UPDATE_FEATURE_ENVIRONMENT,
);
// activation strategies
this.get(`${PATH_STRATEGIES}`, this.getStrategies);
this.post(`${PATH_STRATEGIES}`, this.addStrategy, UPDATE_FEATURE);
this.post(
`${PATH_STRATEGIES}`,
this.addStrategy,
CREATE_FEATURE_STRATEGY,
);
this.get(`${PATH_STRATEGY}`, this.getStrategy);
this.put(`${PATH_STRATEGY}`, this.updateStrategy, UPDATE_FEATURE);
this.patch(`${PATH_STRATEGY}`, this.patchStrategy, UPDATE_FEATURE);
this.delete(`${PATH_STRATEGY}`, this.deleteStrategy, UPDATE_FEATURE);
this.put(
`${PATH_STRATEGY}`,
this.updateStrategy,
UPDATE_FEATURE_STRATEGY,
);
this.patch(
`${PATH_STRATEGY}`,
this.patchStrategy,
UPDATE_FEATURE_STRATEGY,
);
this.delete(
`${PATH_STRATEGY}`,
this.deleteStrategy,
DELETE_FEATURE_STRATEGY,
);
// feature toggles
this.get(PATH, this.getFeatures);

View File

@ -5,7 +5,7 @@ import { IUnleashConfig } from '../../../types/option';
import { IUnleashServices } from '../../../types';
import { Request, Response } from 'express';
import { Operation } from 'fast-json-patch';
import { UPDATE_FEATURE } from '../../../types/permissions';
import { UPDATE_FEATURE_VARIANTS } from '../../../types/permissions';
import { IVariant } from '../../../types/model';
import { extractUsername } from '../../../util/extract-user';
import { IAuthRequest } from '../../unleash-types';
@ -35,8 +35,8 @@ export default class VariantsController extends Controller {
this.logger = config.getLogger('admin-api/project/variants.ts');
this.featureService = featureToggleService;
this.get(PREFIX, this.getVariants);
this.patch(PREFIX, this.patchVariants, UPDATE_FEATURE);
this.put(PREFIX, this.overwriteVariants, UPDATE_FEATURE);
this.patch(PREFIX, this.patchVariants, UPDATE_FEATURE_VARIANTS);
this.put(PREFIX, this.overwriteVariants, UPDATE_FEATURE_VARIANTS);
}
async getVariants(

View File

@ -58,6 +58,8 @@ export const handleErrors: (
return res.status(409).json(error).end();
case 'FeatureHasTagError':
return res.status(409).json(error).end();
case 'RoleInUseError':
return res.status(400).json(error).end();
default:
logger.error('Server failed executing request', error);
return res.status(500).end();

View File

@ -0,0 +1,83 @@
import { roleSchema } from './role-schema';
test('role schema rejects a role without a name', async () => {
expect.assertions(1);
const role = {
permissions: [],
};
try {
await roleSchema.validateAsync(role);
} catch (error) {
expect(error.details[0].message).toBe('"name" is required');
}
});
test('role schema allows a role with an empty description', async () => {
const role = {
name: 'Brønsted',
description: '',
};
const value = await roleSchema.validateAsync(role);
expect(value.description).toEqual('');
});
test('role schema rejects a role with a broken permission list', async () => {
expect.assertions(1);
const role = {
name: 'Mendeleev',
permissions: [
{
aPropertyThatIsAproposToNothing: true,
},
],
};
try {
await roleSchema.validateAsync(role);
} catch (error) {
expect(error.details[0].message).toBe(
'"permissions[0].id" is required',
);
}
});
test('role schema allows a role with an empty permission list', async () => {
const role = {
name: 'Avogadro',
permissions: [],
};
const value = await roleSchema.validateAsync(role);
expect(value.permissions).toEqual([]);
});
test('role schema allows a role with a null list', async () => {
const role = {
name: 'Curie',
permissions: null,
};
const value = await roleSchema.validateAsync(role);
expect(value.permissions).toEqual(null);
});
test('role schema allows an undefined with a null list', async () => {
const role = {
name: 'Fischer',
};
const value = await roleSchema.validateAsync(role);
expect(value.permissions).toEqual(undefined);
});
test('role schema strips roleType if present', async () => {
const role = {
name: 'Grignard',
roleType: 'Organic Chemistry',
};
const value = await roleSchema.validateAsync(role);
expect(value.roleType).toEqual(undefined);
});

View File

@ -0,0 +1,22 @@
import joi from 'joi';
export const permissionRoleSchema = joi
.object()
.keys({
id: joi.number().required(),
environment: joi.string().optional().allow('').allow(null).default(''),
})
.options({ stripUnknown: true, allowUnknown: false, abortEarly: false });
export const roleSchema = joi
.object()
.keys({
name: joi.string().required(),
description: joi.string().optional().allow('').allow(null).default(''),
permissions: joi
.array()
.allow(null)
.optional()
.items(permissionRoleSchema),
})
.options({ stripUnknown: true, allowUnknown: false, abortEarly: false });

View File

@ -3,6 +3,7 @@ import User, { IUser } from '../types/user';
import {
IAccessStore,
IRole,
IRoleWithPermissions,
IUserPermission,
IUserRole,
} from '../types/stores/access-store';
@ -10,20 +11,25 @@ import { IUserStore } from '../types/stores/user-store';
import { Logger } from '../logger';
import { IUnleashStores } from '../types/stores';
import {
IAvailablePermissions,
ICustomRole,
IPermission,
IRoleData,
IUserWithRole,
PermissionType,
RoleName,
RoleType,
} from '../types/model';
import { IRoleStore } from 'lib/types/stores/role-store';
import NameExistsError from '../error/name-exists-error';
import { IEnvironmentStore } from 'lib/types/stores/environment-store';
import RoleInUseError from '../error/role-in-use-error';
import { roleSchema } from '../schema/role-schema';
import { CUSTOM_ROLE_TYPE } from '../util/constants';
import { DEFAULT_PROJECT } from '../types/project';
import InvalidOperationError from '../error/invalid-operation-error';
export const ALL_PROJECTS = '*';
const PROJECT_DESCRIPTION = {
OWNER: 'Users with this 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.',
MEMBER: 'Users with this role within a project are allowed to view, create and update feature toggles, but have limited permissions in regards to managing the projects user access and can not archive or delete the project.',
};
export const ALL_ENVS = '*';
const { ADMIN } = permissions;
@ -35,11 +41,18 @@ const PROJECT_ADMIN = [
permissions.DELETE_FEATURE,
];
const PROJECT_REGULAR = [
permissions.CREATE_FEATURE,
permissions.UPDATE_FEATURE,
permissions.DELETE_FEATURE,
];
interface IRoleCreation {
name: string;
description: string;
permissions?: IPermission[];
}
interface IRoleUpdate {
id: number;
name: string;
description: string;
permissions?: IPermission[];
}
const isProjectPermission = (permission) => PROJECT_ADMIN.includes(permission);
@ -48,26 +61,29 @@ export class AccessService {
private userStore: IUserStore;
private logger: Logger;
private roleStore: IRoleStore;
private permissions: IPermission[];
private environmentStore: IEnvironmentStore;
private logger: Logger;
constructor(
{
accessStore,
userStore,
}: Pick<IUnleashStores, 'accessStore' | 'userStore'>,
roleStore,
environmentStore,
}: Pick<
IUnleashStores,
'accessStore' | 'userStore' | 'roleStore' | 'environmentStore'
>,
{ getLogger }: { getLogger: Function },
) {
this.store = accessStore;
this.userStore = userStore;
this.roleStore = roleStore;
this.environmentStore = environmentStore;
this.logger = getLogger('/services/access-service.ts');
this.permissions = Object.values(permissions).map((p) => ({
name: p,
type: isProjectPermission(p)
? PermissionType.project
: PermissionType.root,
}));
}
/**
@ -81,9 +97,10 @@ export class AccessService {
user: User,
permission: string,
projectId?: string,
environment?: string,
): Promise<boolean> {
this.logger.info(
`Checking permission=${permission}, userId=${user.id} projectId=${projectId}`,
`Checking permission=${permission}, userId=${user.id}, projectId=${projectId}, environment=${environment}`,
);
try {
@ -96,6 +113,12 @@ export class AccessService {
p.project === projectId ||
p.project === ALL_PROJECTS,
)
.filter(
(p) =>
!p.environment ||
p.environment === environment ||
p.environment === ALL_ENVS,
)
.some(
(p) =>
p.permission === permission || p.permission === ADMIN,
@ -118,12 +141,43 @@ export class AccessService {
return this.store.getPermissionsForUser(user.id);
}
getPermissions(): IPermission[] {
return this.permissions;
async getPermissions(): Promise<IAvailablePermissions> {
const bindablePermissions = await this.store.getAvailablePermissions();
const environments = await this.environmentStore.getAll();
const projectPermissions = bindablePermissions.filter((x) => {
return x.type === 'project';
});
const environmentPermissions = bindablePermissions.filter((perm) => {
return perm.type === 'environment';
});
const allEnvironmentPermissions = environments.map((env) => {
return {
name: env.name,
permissions: environmentPermissions.map((permission) => {
return { environment: env.name, ...permission };
}),
};
});
return {
project: projectPermissions,
environments: allEnvironmentPermissions,
};
}
async addUserToRole(userId: number, roleId: number): Promise<void> {
return this.store.addUserToRole(userId, roleId);
async addUserToRole(
userId: number,
roleId: number,
projectId: string,
): Promise<void> {
return this.store.addUserToRole(userId, roleId, projectId);
}
async getRoleByName(roleName: string): Promise<IRole> {
return this.roleStore.getRoleByName(roleName);
}
async setUserRootRole(
@ -131,14 +185,18 @@ export class AccessService {
role: number | RoleName,
): Promise<void> {
const newRootRole = await this.resolveRootRole(role);
if (newRootRole) {
try {
await this.store.removeRolesOfTypeForUser(
userId,
RoleType.ROOT,
);
await this.store.addUserToRole(userId, newRootRole.id);
await this.store.addUserToRole(
userId,
newRootRole.id,
DEFAULT_PROJECT,
);
} catch (error) {
throw new Error(
`Could not add role=${newRootRole.name} to userId=${userId}`,
@ -154,29 +212,39 @@ export class AccessService {
return userRoles.filter((r) => r.type === RoleType.ROOT);
}
async removeUserFromRole(userId: number, roleId: number): Promise<void> {
return this.store.removeUserFromRole(userId, roleId);
async removeUserFromRole(
userId: number,
roleId: number,
projectId: string,
): Promise<void> {
return this.store.removeUserFromRole(userId, roleId, projectId);
}
//This actually only exists for testing purposes
async addPermissionToRole(
roleId: number,
permission: string,
projectId?: string,
environment?: string,
): Promise<void> {
if (isProjectPermission(permission) && !projectId) {
if (isProjectPermission(permission) && !environment) {
throw new Error(
`ProjectId cannot be empty for permission=${permission}`,
);
}
return this.store.addPermissionsToRole(roleId, [permission], projectId);
return this.store.addPermissionsToRole(
roleId,
[permission],
environment,
);
}
//This actually only exists for testing purposes
async removePermissionFromRole(
roleId: number,
permission: string,
projectId?: string,
environment?: string,
): Promise<void> {
if (isProjectPermission(permission) && !projectId) {
if (isProjectPermission(permission) && !environment) {
throw new Error(
`ProjectId cannot be empty for permission=${permission}`,
);
@ -184,15 +252,24 @@ export class AccessService {
return this.store.removePermissionFromRole(
roleId,
permission,
projectId,
environment,
);
}
async getRoles(): Promise<IRole[]> {
return this.store.getRoles();
return this.roleStore.getRoles();
}
async getRole(roleId: number): Promise<IRoleData> {
async getRole(id: number): Promise<IRoleWithPermissions> {
const role = await this.store.get(id);
const rolePermissions = await this.store.getPermissionsForRole(role.id);
return {
...role,
permissions: rolePermissions,
};
}
async getRoleData(roleId: number): Promise<IRoleData> {
const [role, rolePerms, users] = await Promise.all([
this.store.get(roleId),
this.store.getPermissionsForRole(roleId),
@ -201,14 +278,22 @@ export class AccessService {
return { role, permissions: rolePerms, users };
}
async getProjectRoles(): Promise<IRole[]> {
return this.roleStore.getProjectRoles();
}
async getRolesForProject(projectId: string): Promise<IRole[]> {
return this.store.getRolesForProject(projectId);
return this.roleStore.getRolesForProject(projectId);
}
async getRolesForUser(userId: number): Promise<IRole[]> {
return this.store.getRolesForUserId(userId);
}
async unlinkUserRoles(userId: number): Promise<void> {
return this.store.unlinkUserRoles(userId);
}
async getUsersForRole(roleId: number): Promise<IUser[]> {
const userIdList = await this.store.getUserIdsForRole(roleId);
if (userIdList.length > 0) {
@ -217,16 +302,33 @@ export class AccessService {
return [];
}
async getProjectUsersForRole(
roleId: number,
projectId?: string,
): Promise<IUser[]> {
const userIdList = await this.store.getProjectUserIdsForRole(
roleId,
projectId,
);
if (userIdList.length > 0) {
return this.userStore.getAllWithId(userIdList);
}
return [];
}
// Move to project-service?
async getProjectRoleUsers(
projectId: string,
): Promise<[IRole[], IUserWithRole[]]> {
const roles = await this.store.getRolesForProject(projectId);
const roles = await this.roleStore.getProjectRoles();
const users = await Promise.all(
roles.map(async (role) => {
const usrs = await this.getUsersForRole(role.id);
return usrs.map((u) => ({ ...u, roleId: role.id }));
const projectUsers = await this.getProjectUsersForRole(
role.id,
projectId,
);
return projectUsers.map((u) => ({ ...u, roleId: role.id }));
}),
);
return [roles, users.flat()];
@ -240,36 +342,15 @@ export class AccessService {
throw new Error('ProjectId cannot be empty');
}
const ownerRole = await this.store.createRole(
RoleName.OWNER,
RoleType.PROJECT,
projectId,
PROJECT_DESCRIPTION.OWNER,
);
await this.store.addPermissionsToRole(
ownerRole.id,
PROJECT_ADMIN,
projectId,
);
const ownerRole = await this.roleStore.getRoleByName(RoleName.OWNER);
// TODO: remove this when all users is guaranteed to have a unique id.
if (owner.id) {
this.logger.info(
`Making ${owner.id} admin of ${projectId} via roleId=${ownerRole.id}`,
);
await this.store.addUserToRole(owner.id, ownerRole.id);
await this.store.addUserToRole(owner.id, ownerRole.id, projectId);
}
const memberRole = await this.store.createRole(
RoleName.MEMBER,
RoleType.PROJECT,
projectId,
PROJECT_DESCRIPTION.MEMBER,
);
await this.store.addPermissionsToRole(
memberRole.id,
PROJECT_REGULAR,
projectId,
);
}
async removeDefaultProjectRoles(
@ -277,15 +358,15 @@ export class AccessService {
projectId: string,
): Promise<void> {
this.logger.info(`Removing project roles for ${projectId}`);
return this.store.removeRolesForProject(projectId);
return this.roleStore.removeRolesForProject(projectId);
}
async getRootRoleForAllUsers(): Promise<IUserRole[]> {
return this.store.getRootRoleForAllUsers();
return this.roleStore.getRootRoleForAllUsers();
}
async getRootRoles(): Promise<IRole[]> {
return this.store.getRootRoles();
return this.roleStore.getRootRoles();
}
public async resolveRootRole(rootRole: number | RoleName): Promise<IRole> {
@ -300,7 +381,94 @@ export class AccessService {
}
async getRootRole(roleName: RoleName): Promise<IRole> {
const roles = await this.store.getRootRoles();
const roles = await this.roleStore.getRootRoles();
return roles.find((r) => r.name === roleName);
}
async getAllRoles(): Promise<ICustomRole[]> {
return this.roleStore.getAll();
}
async createRole(role: IRoleCreation): Promise<ICustomRole> {
const baseRole = {
...(await this.validateRole(role)),
roleType: CUSTOM_ROLE_TYPE,
};
const rolePermissions = role.permissions;
const newRole = await this.roleStore.create(baseRole);
if (rolePermissions) {
this.store.addEnvironmentPermissionsToRole(
newRole.id,
rolePermissions,
);
}
return newRole;
}
async updateRole(role: IRoleUpdate): Promise<ICustomRole> {
await this.validateRole(role, role.id);
const baseRole = {
id: role.id,
name: role.name,
description: role.description,
roleType: CUSTOM_ROLE_TYPE,
};
const rolePermissions = role.permissions;
const newRole = await this.roleStore.update(baseRole);
if (rolePermissions) {
this.store.wipePermissionsFromRole(newRole.id);
this.store.addEnvironmentPermissionsToRole(
newRole.id,
rolePermissions,
);
}
return newRole;
}
async deleteRole(id: number): Promise<void> {
const roleUsers = await this.getUsersForRole(id);
if (roleUsers.length > 0) {
throw new RoleInUseError(
'Role is in use by more than one user. You cannot delete a role that is in use without first removing the role from the users.',
);
}
return this.roleStore.delete(id);
}
async validateRoleIsUnique(
roleName: string,
existingId?: number,
): Promise<void> {
const exists = await this.roleStore.nameInUse(roleName, existingId);
if (exists) {
throw new NameExistsError(
`There already exists a role with the name ${roleName}`,
);
}
return Promise.resolve();
}
async validateRoleIsNotBuiltIn(roleId: number): Promise<void> {
const role = await this.store.get(roleId);
if (role.type !== CUSTOM_ROLE_TYPE) {
throw new InvalidOperationError(
'You can not change built in roles.',
);
}
}
async validateRole(
role: IRoleCreation,
existingId?: number,
): Promise<IRoleCreation> {
const cleanedRole = await roleSchema.validateAsync(role);
if (existingId) {
await this.validateRoleIsNotBuiltIn(existingId);
}
await this.validateRoleIsUnique(role.name, existingId);
return cleanedRole;
}
}

View File

@ -25,20 +25,20 @@ import { IFeatureTypeStore } from '../types/stores/feature-type-store';
import { IFeatureToggleStore } from '../types/stores/feature-toggle-store';
import { IFeatureEnvironmentStore } from '../types/stores/feature-environment-store';
import { IProjectQuery, IProjectStore } from '../types/stores/project-store';
import { IRole } from '../types/stores/access-store';
import { IRoleDescriptor } from '../types/stores/access-store';
import { IEventStore } from '../types/stores/event-store';
import FeatureToggleService from './feature-toggle-service';
import { CREATE_FEATURE, UPDATE_FEATURE } from '../types/permissions';
import { MOVE_FEATURE_TOGGLE } from '../types/permissions';
import NoAccessError from '../error/no-access-error';
import IncompatibleProjectError from '../error/incompatible-project-error';
import { DEFAULT_PROJECT } from '../types/project';
import { IFeatureTagStore } from 'lib/types/stores/feature-tag-store';
const getCreatedBy = (user: User) => user.email || user.username;
const DEFAULT_PROJECT = 'default';
export interface UsersWithRoles {
users: IUserWithRole[];
roles: IRole[];
roles: IRoleDescriptor[];
}
export default class ProjectService {
@ -60,6 +60,8 @@ export default class ProjectService {
private featureToggleService: FeatureToggleService;
private tagStore: IFeatureTagStore;
constructor(
{
projectStore,
@ -68,6 +70,7 @@ export default class ProjectService {
featureTypeStore,
environmentStore,
featureEnvironmentStore,
featureTagStore,
}: Pick<
IUnleashStores,
| 'projectStore'
@ -76,6 +79,7 @@ export default class ProjectService {
| 'featureTypeStore'
| 'environmentStore'
| 'featureEnvironmentStore'
| 'featureTagStore'
>,
config: IUnleashConfig,
accessService: AccessService,
@ -89,6 +93,7 @@ export default class ProjectService {
this.featureToggleStore = featureToggleStore;
this.featureTypeStore = featureTypeStore;
this.featureToggleService = featureToggleService;
this.tagStore = featureTagStore;
this.logger = config.getLogger('services/project-service.js');
}
@ -188,7 +193,7 @@ export default class ProjectService {
const feature = await this.featureToggleStore.get(featureName);
if (feature.project !== currentProjectId) {
throw new NoAccessError(UPDATE_FEATURE);
throw new NoAccessError(MOVE_FEATURE_TOGGLE);
}
const project = await this.getProject(newProjectId);
@ -198,12 +203,12 @@ export default class ProjectService {
const authorized = await this.accessService.hasPermission(
user,
CREATE_FEATURE,
MOVE_FEATURE_TOGGLE,
newProjectId,
);
if (!authorized) {
throw new NoAccessError(CREATE_FEATURE);
throw new NoAccessError(MOVE_FEATURE_TOGGLE);
}
const isCompatibleWithTargetProject =
@ -297,10 +302,10 @@ export default class ProjectService {
const alreadyHasAccess = users.some((u) => u.id === userId);
if (alreadyHasAccess) {
throw new Error(`User already have access to project=${projectId}`);
throw new Error(`User already has access to project=${projectId}`);
}
await this.accessService.addUserToRole(userId, role.id);
await this.accessService.addUserToRole(userId, role.id, projectId);
}
// TODO: should be an event too
@ -318,13 +323,16 @@ export default class ProjectService {
}
if (role.name === RoleName.OWNER) {
const users = await this.accessService.getUsersForRole(role.id);
const users = await this.accessService.getProjectUsersForRole(
role.id,
projectId,
);
if (users.length < 2) {
throw new Error('A project must have at least one owner');
}
}
await this.accessService.removeUserFromRole(userId, role.id);
await this.accessService.removeUserFromRole(userId, role.id, projectId);
}
async getMembers(projectId: string): Promise<number> {

View File

@ -135,7 +135,6 @@ class UserService {
});
const passwordHash = await bcrypt.hash(pwd, saltRounds);
await this.store.setPasswordHash(user.id, passwordHash);
await this.accessService.setUserRootRole(
user.id,
RoleName.ADMIN,
@ -257,12 +256,7 @@ class UserService {
async deleteUser(userId: number, updatedBy?: User): Promise<void> {
const user = await this.store.get(userId);
const roles = await this.accessService.getRolesForUser(userId);
await Promise.all(
roles.map((role) =>
this.accessService.removeUserFromRole(userId, role.id),
),
);
await this.accessService.unlinkUserRoles(userId);
await this.sessionService.deleteSessionsForUser(userId);
await this.store.delete(userId);
@ -355,7 +349,7 @@ class UserService {
token,
);
const user = await this.getUser(userId);
const role = await this.accessService.getRole(user.rootRole);
const role = await this.accessService.getRoleData(user.rootRole);
return {
token,
createdBy,

View File

@ -1,6 +1,6 @@
import { ITagType } from './stores/tag-type-store';
import { LogProvider } from '../logger';
import { IRole, IUserPermission } from './stores/access-store';
import { IRole } from './stores/access-store';
import { IUser } from './user';
export interface IConstraint {
@ -212,12 +212,25 @@ export interface IUserWithRole {
export interface IRoleData {
role: IRole;
users: IUser[];
permissions: IUserPermission[];
permissions: IPermission[];
}
export interface IAvailablePermissions {
project: IPermission[];
environments: IEnvironmentPermission[];
}
export interface IPermission {
id: number;
name: string;
type: PermissionType;
displayName: string;
type: string;
environment?: string;
}
export interface IEnvironmentPermission {
name: string;
permissions: IPermission[];
}
export enum PermissionType {
@ -313,6 +326,13 @@ export interface IProject {
updatedAt?: Date;
}
export interface ICustomRole {
id: number;
name: string;
description: string;
type: string;
}
export interface IProjectWithCount extends IProject {
featureCount: number;
memberCount: number;

View File

@ -101,6 +101,7 @@ export interface IUnleashOptions {
preRouterHook?: Function;
eventHook?: EventHook;
enterpriseVersion?: string;
disableLegacyFeaturesApi?: boolean;
}
export interface IEmailOption {
@ -156,4 +157,5 @@ export interface IUnleashConfig {
eventHook?: EventHook;
enterpriseVersion?: string;
eventBus: EventEmitter;
disableLegacyFeaturesApi?: boolean;
}

View File

@ -1,4 +1,4 @@
// Special
//Special
export const ADMIN = 'ADMIN';
export const CLIENT = 'CLIENT';
export const NONE = 'NONE';
@ -6,6 +6,10 @@ export const NONE = 'NONE';
export const CREATE_FEATURE = 'CREATE_FEATURE';
export const UPDATE_FEATURE = 'UPDATE_FEATURE';
export const DELETE_FEATURE = 'DELETE_FEATURE';
export const CREATE_FEATURE_STRATEGY = 'CREATE_FEATURE_STRATEGY';
export const UPDATE_FEATURE_STRATEGY = 'UPDATE_FEATURE_STRATEGY';
export const DELETE_FEATURE_STRATEGY = 'DELETE_FEATURE_STRATEGY';
export const UPDATE_FEATURE_ENVIRONMENT = 'UPDATE_FEATURE_ENVIRONMENT';
export const CREATE_STRATEGY = 'CREATE_STRATEGY';
export const UPDATE_STRATEGY = 'UPDATE_STRATEGY';
export const DELETE_STRATEGY = 'DELETE_STRATEGY';
@ -26,3 +30,5 @@ export const CREATE_API_TOKEN = 'CREATE_API_TOKEN';
export const DELETE_API_TOKEN = 'DELETE_API_TOKEN';
export const UPDATE_TAG_TYPE = 'UPDATE_TAG_TYPE';
export const DELETE_TAG_TYPE = 'DELETE_TAG_TYPE';
export const UPDATE_FEATURE_VARIANTS = 'UPDATE_FEATURE_VARIANTS';
export const MOVE_FEATURE_TOGGLE = 'MOVE_FEATURE_TOGGLE';

1
src/lib/types/project.ts Normal file
View File

@ -0,0 +1 @@
export const DEFAULT_PROJECT = 'default';

View File

@ -23,6 +23,7 @@ import { IEnvironmentStore } from './stores/environment-store';
import { IFeatureToggleClientStore } from './stores/feature-toggle-client-store';
import { IClientMetricsStoreV2 } from './stores/client-metrics-store-v2';
import { IUserSplashStore } from './stores/user-splash-store';
import { IRoleStore } from './stores/role-store';
export interface IUnleashStores {
accessStore: IAccessStore;
@ -50,4 +51,5 @@ export interface IUnleashStores {
userFeedbackStore: IUserFeedbackStore;
userStore: IUserStore;
userSplashStore: IUserSplashStore;
roleStore: IRoleStore;
}

View File

@ -1,7 +1,9 @@
import { IPermission } from '../model';
import { Store } from './store';
export interface IUserPermission {
project?: string;
environment?: string;
permission: string;
}
@ -10,7 +12,16 @@ export interface IRole {
name: string;
description?: string;
type: string;
project?: string;
}
export interface IRoleWithPermissions extends IRole {
permissions: IPermission[];
}
export interface IRoleDescriptor {
name: string;
description?: string;
type: string;
}
export interface IUserRole {
@ -18,32 +29,37 @@ export interface IUserRole {
userId: number;
}
export interface IAccessStore extends Store<IRole, number> {
getAvailablePermissions(): Promise<IPermission[]>;
getPermissionsForUser(userId: number): Promise<IUserPermission[]>;
getPermissionsForRole(roleId: number): Promise<IUserPermission[]>;
getRoles(): Promise<IRole[]>;
getRolesForProject(projectId: string): Promise<IRole[]>;
getRootRoles(): Promise<IRole[]>;
removeRolesForProject(projectId: string): Promise<void>;
getPermissionsForRole(roleId: number): Promise<IPermission[]>;
unlinkUserRoles(userId: number): Promise<void>;
getRolesForUserId(userId: number): Promise<IRole[]>;
getUserIdsForRole(roleId: number): Promise<number[]>;
addUserToRole(userId: number, roleId: number): Promise<void>;
removeUserFromRole(userId: number, roleId: number): Promise<void>;
getProjectUserIdsForRole(roleId: number, projectId?: string);
getUserIdsForRole(roleId: number, projectId?: string): Promise<number[]>;
wipePermissionsFromRole(role_id: number): Promise<void>;
addEnvironmentPermissionsToRole(
role_id: number,
permissions: IPermission[],
): Promise<void>;
addUserToRole(
userId: number,
roleId: number,
projectId?: string,
): Promise<void>;
removeUserFromRole(
userId: number,
roleId: number,
projectId?: string,
): Promise<void>;
removeRolesOfTypeForUser(userId: number, roleType: string): Promise<void>;
createRole(
name: string,
type: string,
project?: string,
description?: string,
): Promise<IRole>;
addPermissionsToRole(
role_id: number,
permissions: string[],
projectId?: string,
environment?: string,
): Promise<void>;
removePermissionFromRole(
roleId: number,
permission: string,
projectId?: string,
): Promise<void>;
getRootRoleForAllUsers(): Promise<IUserRole[]>;
}

View File

@ -15,4 +15,5 @@ export interface IEnvironmentStore extends Store<IEnvironment, string> {
): Promise<void>;
updateSortOrder(id: string, value: number): Promise<void>;
importEnvironments(environments: IEnvironment[]): Promise<IEnvironment[]>;
delete(name: string): Promise<void>;
}

View File

@ -0,0 +1,31 @@
import { ICustomRole } from '../model';
import { IRole, IUserRole } from './access-store';
import { Store } from './store';
export interface ICustomRoleInsert {
name: string;
description: string;
roleType: string;
}
export interface ICustomRoleUpdate {
id: number;
name: string;
description: string;
roleType: string;
}
export interface IRoleStore extends Store<ICustomRole, number> {
getAll(): Promise<ICustomRole[]>;
create(role: ICustomRoleInsert): Promise<ICustomRole>;
update(role: ICustomRoleUpdate): Promise<ICustomRole>;
delete(id: number): Promise<void>;
getRoles(): Promise<IRole[]>;
getRoleByName(name: string): Promise<IRole>;
getRolesForProject(projectId: string): Promise<IRole[]>;
removeRolesForProject(projectId: string): Promise<void>;
getProjectRoles(): Promise<IRole[]>;
getRootRoles(): Promise<IRole[]>;
getRootRoleForAllUsers(): Promise<IUserRole[]>;
nameInUse(name: string, existingId: number): Promise<boolean>;
}

View File

@ -1 +1,7 @@
export const DEFAULT_ENV = 'default';
export const ROOT_PERMISSION_TYPE = 'root';
export const ENVIRONMENT_PERMISSION_TYPE = 'environment';
export const PROJECT_PERMISSION_TYPE = 'project';
export const CUSTOM_ROLE_TYPE = 'custom';

View File

@ -0,0 +1,205 @@
exports.up = function (db, cb) {
db.runSql(
`
CREATE TABLE IF NOT EXISTS permissions
(
id SERIAL PRIMARY KEY,
permission VARCHAR(255) NOT NULL,
display_name TEXT,
type VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
INSERT INTO permissions (permission, display_name, type) VALUES ('ADMIN', 'Admin', 'root');
INSERT INTO permissions (permission, display_name, type) VALUES ('CREATE_FEATURE', 'Create Feature Toggles', 'project');
INSERT INTO permissions (permission, display_name, type) VALUES ('CREATE_STRATEGY','Create Strategies', 'root');
INSERT INTO permissions (permission, display_name, type) VALUES ('CREATE_ADDON', 'Create Addons', 'root');
INSERT INTO permissions (permission, display_name, type) VALUES ('DELETE_ADDON', 'Delete Addons', 'root');
INSERT INTO permissions (permission, display_name, type) VALUES ('UPDATE_ADDON', 'Update Addons', 'root');
INSERT INTO permissions (permission, display_name, type) VALUES ('UPDATE_FEATURE', 'Update Feature Toggles', 'project');
INSERT INTO permissions (permission, display_name, type) VALUES ('DELETE_FEATURE', 'Delete Feature Toggles', 'project');
INSERT INTO permissions (permission, display_name, type) VALUES ('UPDATE_APPLICATION', 'Update Applications', 'root');
INSERT INTO permissions (permission, display_name, type) VALUES ('UPDATE_TAG_TYPE', 'Update Tag Types', 'root');
INSERT INTO permissions (permission, display_name, type) VALUES ('DELETE_TAG_TYPE', 'Delete Tag Types', 'root');
INSERT INTO permissions (permission, display_name, type) VALUES ('CREATE_PROJECT', 'Create Projects', 'root');
INSERT INTO permissions (permission, display_name, type) VALUES ('UPDATE_PROJECT', 'Update Projects', 'project');
INSERT INTO permissions (permission, display_name, type) VALUES ('DELETE_PROJECT', 'Delete Projects', 'project');
INSERT INTO permissions (permission, display_name, type) VALUES ('UPDATE_STRATEGY', 'Update Strategies', 'root');
INSERT INTO permissions (permission, display_name, type) VALUES ('DELETE_STRATEGY', 'Delete Strategies', 'root');
INSERT INTO permissions (permission, display_name, type) VALUES ('UPDATE_CONTEXT_FIELD', 'Update Context Fields', 'root');
INSERT INTO permissions (permission, display_name, type) VALUES ('CREATE_CONTEXT_FIELD', 'Create Context Fields', 'root');
INSERT INTO permissions (permission, display_name, type) VALUES ('DELETE_CONTEXT_FIELD', 'Delete Context Fields', 'root');
INSERT INTO permissions (permission, display_name, type) VALUES ('READ_ROLE', 'Read Roles', 'root');
INSERT INTO permissions (permission, display_name, type) VALUES ('UPDATE_ROLE', 'Update Roles', 'root');
INSERT INTO permissions (permission, display_name, type) VALUES ('UPDATE_API_TOKEN', 'Update API Tokens', 'root');
INSERT INTO permissions (permission, display_name, type) VALUES ('CREATE_API_TOKEN', 'Create API Tokens', 'root');
INSERT INTO permissions (permission, display_name, type) VALUES ('DELETE_API_TOKEN', 'Delete API Tokens', 'root');
INSERT INTO permissions (permission, display_name, type) VALUES ('CREATE_FEATURE_STRATEGY', 'Create Feature Strategies', 'environment');
INSERT INTO permissions (permission, display_name, type) VALUES ('UPDATE_FEATURE_STRATEGY', 'Update Feature Strategies', 'environment');
INSERT INTO permissions (permission, display_name, type) VALUES ('DELETE_FEATURE_STRATEGY', 'Delete Feature Strategies', 'environment');
INSERT INTO permissions (permission, display_name, type) VALUES ('UPDATE_FEATURE_ENVIRONMENT', 'Enable/disable Toggles in Environment', 'environment');
INSERT INTO permissions (permission, display_name, type) VALUES ('UPDATE_FEATURE_VARIANTS', 'Create/Edit variants', 'project');
ALTER TABLE role_user ADD COLUMN
project VARCHAR(255);
ALTER TABLE roles
ADD COLUMN
updated_at TIMESTAMP WITH TIME ZONE;
ALTER TABLE role_permission
ADD COLUMN
permission_id INTEGER,
ADD COLUMN
environment VARCHAR (100);
CREATE TEMPORARY TABLE temp_primary_roles
(
id INTEGER,
name TEXT,
description TEXT,
type TEXT,
project TEXT,
created_at DATE
)
ON COMMIT DROP;
CREATE TEMPORARY TABLE temp_discard_roles
(
id INTEGER,
name TEXT,
description TEXT,
type TEXT,
project TEXT,
created_at DATE
)
ON COMMIT DROP;
INSERT INTO temp_primary_roles select distinct on (name) id, name ,description, type, project, created_at from roles order by name, id;
INSERT INTO temp_discard_roles SELECT r.id, r.name, r.description, r.type, r.project, r.created_at FROM roles r
LEFT JOIN temp_primary_roles tpr ON r.id = tpr.id
WHERE tpr.id IS NULL;
UPDATE role_user
SET project = tpr.project
FROM temp_primary_roles tpr
WHERE tpr.id = role_user.role_id;
ALTER TABLE role_user DROP CONSTRAINT role_user_pkey;
WITH rtu as (
SELECT tdr.id as old_role_id, tpr.id as new_role_id, tdr.project as project FROM temp_discard_roles tdr
JOIN temp_primary_roles tpr ON tdr.name = tpr.name
)
UPDATE role_user
SET project = rtu.project, role_id = rtu.new_role_id
FROM rtu
WHERE rtu.old_role_id = role_user.role_id;
UPDATE role_user SET project = '*' WHERE project IS NULL;
ALTER TABLE role_user ADD PRIMARY KEY (role_id, user_id, project);
DELETE FROM roles WHERE EXISTS
(
SELECT 1 FROM temp_discard_roles tdr WHERE tdr.id = roles.id
);
DELETE FROM role_permission;
ALTER TABLE roles DROP COLUMN project;
ALTER TABLE role_permission
DROP COLUMN project,
DROP COLUMN permission;
INSERT INTO role_permission (role_id, permission_id, environment)
SELECT
(SELECT id as role_id from roles WHERE name = 'Editor' LIMIT 1),
p.id as permission_id,
'*' as environment
FROM permissions p
WHERE p.permission IN
('CREATE_STRATEGY',
'UPDATE_STRATEGY',
'DELETE_STRATEGY',
'UPDATE_APPLICATION',
'CREATE_CONTEXT_FIELD',
'UPDATE_CONTEXT_FIELD',
'DELETE_CONTEXT_FIELD',
'CREATE_PROJECT',
'CREATE_ADDON',
'UPDATE_ADDON',
'DELETE_ADDON',
'UPDATE_PROJECT',
'DELETE_PROJECT',
'CREATE_FEATURE',
'UPDATE_FEATURE',
'DELETE_FEATURE',
'UPDATE_TAG_TYPE',
'DELETE_TAG_TYPE',
'UPDATE_FEATURE_VARIANTS');
INSERT INTO role_permission (role_id, permission_id, environment)
SELECT
(SELECT id as role_id from roles WHERE name = 'Owner' LIMIT 1),
p.id as permission_id,
null as environment
FROM permissions p
WHERE p.permission IN
('UPDATE_PROJECT',
'DELETE_PROJECT',
'CREATE_FEATURE',
'UPDATE_FEATURE',
'DELETE_FEATURE',
'UPDATE_FEATURE_VARIANTS');
INSERT INTO role_permission (role_id, permission_id, environment)
SELECT
(SELECT id as role_id from roles WHERE name = 'Member' LIMIT 1),
p.id as permission_id,
null as environment
FROM permissions p
WHERE p.permission IN
('CREATE_FEATURE',
'UPDATE_FEATURE',
'DELETE_FEATURE',
'UPDATE_FEATURE_VARIANTS');
INSERT INTO role_permission (role_id, permission_id, environment)
SELECT
(SELECT id as role_id from roles WHERE name = 'Admin' LIMIT 1),
p.id as permission_id,
'*' environment
FROM permissions p
WHERE p.permission = 'ADMIN';
ALTER TABLE role_permission
ADD CONSTRAINT fk_role_permission
FOREIGN KEY(role_id)
REFERENCES roles(id) ON DELETE CASCADE;
`,
cb,
);
};
exports.down = function (db, cb) {
db.runSql(
`
ALTER TABLE role_user DROP COLUMN project;
ALTER TABLE roles DROP COLUMN updated_at;
ALTER TABLE role_permission
DROP COLUMN
permission_id,
DROP COLUMN
environment;
ALTER TABLE role_permission
ADD COLUMN project TEXT,
ADD COLUMN permission TEXT;
`,
cb,
);
};

View File

@ -0,0 +1,40 @@
exports.up = function (db, cb) {
db.runSql(
`
INSERT INTO role_permission (role_id, permission_id, environment)
SELECT
(SELECT id as role_id from roles WHERE name = 'Owner' LIMIT 1),
p.id as permission_id,
e.name as environment
FROM permissions p
CROSS JOIN environments e
WHERE p.permission IN
('CREATE_FEATURE_STRATEGY',
'UPDATE_FEATURE_STRATEGY',
'DELETE_FEATURE_STRATEGY',
'UPDATE_FEATURE_ENVIRONMENT');
INSERT INTO role_permission (role_id, permission_id, environment)
SELECT
(SELECT id as role_id from roles WHERE name = 'Member' LIMIT 1),
p.id as permission_id,
e.name as environment
FROM permissions p
CROSS JOIN environments e
WHERE p.permission IN
('CREATE_FEATURE_STRATEGY',
'UPDATE_FEATURE_STRATEGY',
'DELETE_FEATURE_STRATEGY',
'UPDATE_FEATURE_ENVIRONMENT');
`,
cb,
);
};
exports.down = function (db, cb) {
db.runSql(
`
`,
cb,
);
};

View File

@ -0,0 +1,27 @@
exports.up = function (db, cb) {
db.runSql(
`
INSERT INTO role_permission (role_id, permission_id, environment)
SELECT
(SELECT id as role_id from roles WHERE name = 'Editor' LIMIT 1),
p.id as permission_id,
e.name as environment
FROM permissions p
CROSS JOIN environments e
WHERE p.permission IN
('CREATE_FEATURE_STRATEGY',
'UPDATE_FEATURE_STRATEGY',
'DELETE_FEATURE_STRATEGY',
'UPDATE_FEATURE_ENVIRONMENT');
`,
cb,
);
};
exports.down = function (db, cb) {
db.runSql(
`
`,
cb,
);
};

View File

@ -0,0 +1,45 @@
exports.up = function (db, cb) {
db.runSql(
`
UPDATE permissions SET display_name = 'Admin' WHERE permission = 'ADMIN';
UPDATE permissions SET display_name = 'Create feature toggles' WHERE permission = 'CREATE_FEATURE';
UPDATE permissions SET display_name = 'Create activation strategies' WHERE permission = 'CREATE_STRATEGY';
UPDATE permissions SET display_name = 'Create addons' WHERE permission = 'CREATE_ADDON';
UPDATE permissions SET display_name = 'Delete addons' WHERE permission = 'DELETE_ADDON';
UPDATE permissions SET display_name = 'Update addons' WHERE permission = 'UPDATE_ADDON';
UPDATE permissions SET display_name = 'Update feature toggles' WHERE permission = 'UPDATE_FEATURE';
UPDATE permissions SET display_name = 'Delete feature toggles' WHERE permission = 'DELETE_FEATURE';
UPDATE permissions SET display_name = 'Update applications' WHERE permission = 'UPDATE_APPLICATION';
UPDATE permissions SET display_name = 'Update tag types' WHERE permission = 'UPDATE_TAG_TYPE';
UPDATE permissions SET display_name = 'Delete tag types' WHERE permission = 'DELETE_TAG_TYPE';
UPDATE permissions SET display_name = 'Create projects' WHERE permission = 'CREATE_PROJECT';
UPDATE permissions SET display_name = 'Update project' WHERE permission = 'UPDATE_PROJECT';
UPDATE permissions SET display_name = 'Delete project' WHERE permission = 'DELETE_PROJECT';
UPDATE permissions SET display_name = 'Update strategies' WHERE permission = 'UPDATE_STRATEGY';
UPDATE permissions SET display_name = 'Delete strategies' WHERE permission = 'DELETE_STRATEGY';
UPDATE permissions SET display_name = 'Update context fields' WHERE permission = 'UPDATE_CONTEXT_FIELD';
UPDATE permissions SET display_name = 'Create context fields' WHERE permission = 'CREATE_CONTEXT_FIELD';
UPDATE permissions SET display_name = 'Delete context fields' WHERE permission = 'DELETE_CONTEXT_FIELD';
UPDATE permissions SET display_name = 'Read roles' WHERE permission = 'READ_ROLE';
UPDATE permissions SET display_name = 'Update roles' WHERE permission = 'UPDATE_ROLE';
UPDATE permissions SET display_name = 'Update API tokens' WHERE permission = 'UPDATE_API_TOKEN';
UPDATE permissions SET display_name = 'Create API tokens' WHERE permission = 'CREATE_API_TOKEN';
UPDATE permissions SET display_name = 'Delete API tokens' WHERE permission = 'DELETE_API_TOKEN';
UPDATE permissions SET display_name = 'Create activation strategies' WHERE permission = 'CREATE_FEATURE_STRATEGY';
UPDATE permissions SET display_name = 'Update activation strategies' WHERE permission = 'UPDATE_FEATURE_STRATEGY';
UPDATE permissions SET display_name = 'Delete activation strategies' WHERE permission = 'DELETE_FEATURE_STRATEGY';
UPDATE permissions SET display_name = 'Enable/disable toggles in this environment' WHERE permission = 'UPDATE_FEATURE_ENVIRONMENT';
UPDATE permissions SET display_name = 'Create/edit variants' WHERE permission = 'UPDATE_FEATURE_VARIANTS';
`,
cb,
);
};
exports.down = function (db, cb) {
db.runSql(
`
`,
cb,
);
};

View File

@ -0,0 +1,35 @@
exports.up = function (db, cb) {
db.runSql(
`
INSERT INTO permissions (permission, display_name, type) VALUES ('MOVE_FEATURE_TOGGLE', 'Change feature toggle project', 'project');
INSERT INTO role_permission (role_id, permission_id, environment)
SELECT
(SELECT id as role_id from roles WHERE name = 'Editor' LIMIT 1),
p.id as permission_id,
'*' as environment
FROM permissions p
WHERE p.permission IN
('MOVE_FEATURE_TOGGLE');
INSERT INTO role_permission (role_id, permission_id, environment)
SELECT
(SELECT id as role_id from roles WHERE name = 'Owner' LIMIT 1),
p.id as permission_id,
'*' as environment
FROM permissions p
WHERE p.permission IN
('MOVE_FEATURE_TOGGLE');
`,
cb,
);
};
exports.down = function (db, cb) {
db.runSql(
`
DELETE FROM permissions WHERE permission = 'MOVE_FEATURE_TOGGLE';
`,
cb,
);
};

View File

@ -0,0 +1,17 @@
exports.up = function (db, cb) {
db.runSql(
`
ALTER TABLE roles ADD CONSTRAINT unique_name UNIQUE (name);
`,
cb,
);
};
exports.down = function (db, cb) {
db.runSql(
`
ALTER TABLE roles DROP CONSTRAINT unique_name;
`,
cb,
);
};

View File

@ -0,0 +1,19 @@
exports.up = function (db, cb) {
db.runSql(
`
UPDATE role_user set project = 'default' where role_id
IN (SELECT id as role_id from roles WHERE name in ('Admin', 'Editor', 'Viewer') LIMIT 3)
`,
cb,
);
};
exports.down = function (db, cb) {
db.runSql(
`
UPDATE role_user set project = '*' where role_id
IN (SELECT id as role_id from roles WHERE name in ('Admin', 'Editor', 'Viewer') LIMIT 3)
`,
cb,
);
};

View File

@ -0,0 +1,13 @@
exports.up = function (db, cb) {
db.runSql(
`
UPDATE role_permission SET environment = '' where permission_id NOT IN
(select id from permissions WHERE type = 'environment');
`,
cb,
);
};
exports.down = function (db, cb) {
cb();
};

View File

@ -1,7 +1,11 @@
import faker from 'faker';
import { FeatureToggleDTO, IStrategyConfig, IVariant } from 'lib/types/model';
import dbInit, { ITestDb } from '../../helpers/database-init';
import { IUnleashTest, setupApp } from '../../helpers/test-helper';
import {
IUnleashTest,
setupApp,
setupAppWithCustomConfig,
} from '../../helpers/test-helper';
import getLogger from '../../../fixtures/no-logger';
import { DEFAULT_ENV } from '../../../../lib/util/constants';
@ -680,3 +684,21 @@ test('marks feature toggle as stale', async () => {
expect(res.body.stale).toBe(true);
});
});
test('should not hit endpoints if disable configuration is set', async () => {
const appWithDisabledLegacyFeatures = await setupAppWithCustomConfig(
db.stores,
{
disableLegacyFeaturesApi: true,
},
);
await appWithDisabledLegacyFeatures.request
.get('/api/admin/features')
.expect(404);
return appWithDisabledLegacyFeatures.request
.get('/api/admin/features/featureX')
.expect('Content-Type', /json/)
.expect(404);
});

View File

@ -6,10 +6,11 @@ import {
USER_DELETED,
USER_UPDATED,
} from '../../../../lib/types/events';
import { IAccessStore, IRole } from '../../../../lib/types/stores/access-store';
import { IRole } from '../../../../lib/types/stores/access-store';
import { IEventStore } from '../../../../lib/types/stores/event-store';
import { IUserStore } from '../../../../lib/types/stores/user-store';
import { RoleName } from '../../../../lib/types/model';
import { IRoleStore } from 'lib/types/stores/role-store';
let stores;
let db;
@ -17,7 +18,7 @@ let app;
let userStore: IUserStore;
let eventStore: IEventStore;
let accessStore: IAccessStore;
let roleStore: IRoleStore;
let editorRole: IRole;
let adminRole: IRole;
@ -27,9 +28,9 @@ beforeAll(async () => {
app = await setupApp(stores);
userStore = stores.userStore;
accessStore = stores.accessStore;
eventStore = stores.eventStore;
const roles = await accessStore.getRootRoles();
roleStore = stores.roleStore;
const roles = await roleStore.getRootRoles();
editorRole = roles.find((r) => r.name === RoleName.EDITOR);
adminRole = roles.find((r) => r.name === RoleName.ADMIN);
});

View File

@ -1,4 +1,4 @@
import dbInit from '../helpers/database-init';
import dbInit, { ITestDb } from '../helpers/database-init';
import getLogger from '../../fixtures/no-logger';
// eslint-disable-next-line import/no-unresolved
@ -9,11 +9,16 @@ import {
import * as permissions from '../../../lib/types/permissions';
import { RoleName } from '../../../lib/types/model';
import { IUnleashStores } from '../../../lib/types';
import FeatureToggleService from '../../../lib/services/feature-toggle-service';
import ProjectService from '../../../lib/services/project-service';
import { createTestConfig } from '../../config/test-config';
let db;
let stores;
let db: ITestDb;
let stores: IUnleashStores;
let accessService;
let featureToggleService;
let projectService;
let editorUser;
let superUser;
let editorRole;
@ -23,17 +28,172 @@ let readRole;
const createUserEditorAccess = async (name, email) => {
const { userStore } = stores;
const user = await userStore.insert({ name, email });
await accessService.addUserToRole(user.id, editorRole.id);
await accessService.addUserToRole(user.id, editorRole.id, 'default');
return user;
};
const createUserViewerAccess = async (name, email) => {
const { userStore } = stores;
const user = await userStore.insert({ name, email });
await accessService.addUserToRole(user.id, readRole.id, ALL_PROJECTS);
return user;
};
const hasCommonProjectAccess = async (user, projectName, condition) => {
const defaultEnv = 'default';
const developmentEnv = 'development';
const productionEnv = 'production';
const {
CREATE_FEATURE,
UPDATE_FEATURE,
DELETE_FEATURE,
CREATE_FEATURE_STRATEGY,
UPDATE_FEATURE_STRATEGY,
DELETE_FEATURE_STRATEGY,
UPDATE_FEATURE_ENVIRONMENT,
UPDATE_FEATURE_VARIANTS,
} = permissions;
expect(
await accessService.hasPermission(user, CREATE_FEATURE, projectName),
).toBe(condition);
expect(
await accessService.hasPermission(user, UPDATE_FEATURE, projectName),
).toBe(condition);
expect(
await accessService.hasPermission(user, DELETE_FEATURE, projectName),
).toBe(condition);
expect(
await accessService.hasPermission(
user,
UPDATE_FEATURE_VARIANTS,
projectName,
),
).toBe(condition);
expect(
await accessService.hasPermission(
user,
CREATE_FEATURE_STRATEGY,
projectName,
defaultEnv,
),
).toBe(condition);
expect(
await accessService.hasPermission(
user,
UPDATE_FEATURE_STRATEGY,
projectName,
defaultEnv,
),
).toBe(condition);
expect(
await accessService.hasPermission(
user,
DELETE_FEATURE_STRATEGY,
projectName,
defaultEnv,
),
).toBe(condition);
expect(
await accessService.hasPermission(
user,
UPDATE_FEATURE_ENVIRONMENT,
projectName,
defaultEnv,
),
).toBe(condition);
expect(
await accessService.hasPermission(
user,
CREATE_FEATURE_STRATEGY,
projectName,
developmentEnv,
),
).toBe(condition);
expect(
await accessService.hasPermission(
user,
UPDATE_FEATURE_STRATEGY,
projectName,
developmentEnv,
),
).toBe(condition);
expect(
await accessService.hasPermission(
user,
DELETE_FEATURE_STRATEGY,
projectName,
developmentEnv,
),
).toBe(condition);
expect(
await accessService.hasPermission(
user,
UPDATE_FEATURE_ENVIRONMENT,
projectName,
developmentEnv,
),
).toBe(condition);
expect(
await accessService.hasPermission(
user,
CREATE_FEATURE_STRATEGY,
projectName,
productionEnv,
),
).toBe(condition);
expect(
await accessService.hasPermission(
user,
UPDATE_FEATURE_STRATEGY,
projectName,
productionEnv,
),
).toBe(condition);
expect(
await accessService.hasPermission(
user,
DELETE_FEATURE_STRATEGY,
projectName,
productionEnv,
),
).toBe(condition);
expect(
await accessService.hasPermission(
user,
UPDATE_FEATURE_ENVIRONMENT,
projectName,
productionEnv,
),
).toBe(condition);
};
const hasFullProjectAccess = async (user, projectName, condition) => {
const { DELETE_PROJECT, UPDATE_PROJECT, MOVE_FEATURE_TOGGLE } = permissions;
expect(
await accessService.hasPermission(user, DELETE_PROJECT, projectName),
).toBe(condition);
expect(
await accessService.hasPermission(user, UPDATE_PROJECT, projectName),
).toBe(condition);
expect(
await accessService.hasPermission(
user,
MOVE_FEATURE_TOGGLE,
projectName,
),
);
hasCommonProjectAccess(user, projectName, condition);
};
const createSuperUser = async () => {
const { userStore } = stores;
const user = await userStore.insert({
name: 'Alice Admin',
email: 'admin@getunleash.io',
});
await accessService.addUserToRole(user.id, adminRole.id);
await accessService.addUserToRole(user.id, adminRole.id, ALL_PROJECTS);
return user;
};
@ -41,11 +201,23 @@ beforeAll(async () => {
db = await dbInit('access_service_serial', getLogger);
stores = db.stores;
// projectStore = stores.projectStore;
const config = createTestConfig({
getLogger,
// @ts-ignore
experimental: { environments: { enabled: true } },
});
accessService = new AccessService(stores, { getLogger });
const roles = await accessService.getRootRoles();
editorRole = roles.find((r) => r.name === RoleName.EDITOR);
adminRole = roles.find((r) => r.name === RoleName.ADMIN);
readRole = roles.find((r) => r.name === RoleName.VIEWER);
featureToggleService = new FeatureToggleService(stores, config);
projectService = new ProjectService(
stores,
config,
accessService,
featureToggleService,
);
editorUser = await createUserEditorAccess('Bob Test', 'bob@getunleash.io');
superUser = await createSuperUser();
@ -106,45 +278,17 @@ test('should not have admin permission', async () => {
expect(await accessService.hasPermission(user, ADMIN)).toBe(false);
});
test('should have project admin to default project', async () => {
const {
DELETE_PROJECT,
UPDATE_PROJECT,
CREATE_FEATURE,
UPDATE_FEATURE,
DELETE_FEATURE,
} = permissions;
test('should have project admin to default project as editor', async () => {
const projectName = 'default';
const user = editorUser;
expect(
await accessService.hasPermission(user, DELETE_PROJECT, 'default'),
).toBe(true);
expect(
await accessService.hasPermission(user, UPDATE_PROJECT, 'default'),
).toBe(true);
expect(
await accessService.hasPermission(user, CREATE_FEATURE, 'default'),
).toBe(true);
expect(
await accessService.hasPermission(user, UPDATE_FEATURE, 'default'),
).toBe(true);
expect(
await accessService.hasPermission(user, DELETE_FEATURE, 'default'),
).toBe(true);
hasFullProjectAccess(user, projectName, true);
});
test('should grant member CREATE_FEATURE on all projects', async () => {
const { CREATE_FEATURE } = permissions;
test('should not have project admin to other projects as editor', async () => {
const projectName = 'unusedprojectname';
const user = editorUser;
await accessService.addPermissionToRole(
editorRole.id,
permissions.CREATE_FEATURE,
ALL_PROJECTS,
);
expect(
await accessService.hasPermission(user, CREATE_FEATURE, 'some-project'),
).toBe(true);
hasFullProjectAccess(user, projectName, false);
});
test('cannot add CREATE_FEATURE without defining project', async () => {
@ -169,20 +313,21 @@ test('cannot remove CREATE_FEATURE without defining project', async () => {
);
});
test('should remove CREATE_FEATURE on all projects', async () => {
test('should remove CREATE_FEATURE on default environment', async () => {
const { CREATE_FEATURE } = permissions;
const user = editorUser;
const editRole = await accessService.getRoleByName(RoleName.EDITOR);
await accessService.addPermissionToRole(
editorRole.id,
editRole.id,
permissions.CREATE_FEATURE,
ALL_PROJECTS,
'*',
);
await accessService.removePermissionFromRole(
editorRole.id,
editRole.id,
permissions.CREATE_FEATURE,
ALL_PROJECTS,
'*',
);
expect(
@ -219,31 +364,10 @@ test('admin should be admin', async () => {
});
test('should create default roles to project', async () => {
const {
DELETE_PROJECT,
UPDATE_PROJECT,
CREATE_FEATURE,
UPDATE_FEATURE,
DELETE_FEATURE,
} = permissions;
const project = 'some-project';
const user = editorUser;
await accessService.createDefaultProjectRoles(user, project);
expect(
await accessService.hasPermission(user, UPDATE_PROJECT, project),
).toBe(true);
expect(
await accessService.hasPermission(user, DELETE_PROJECT, project),
).toBe(true);
expect(
await accessService.hasPermission(user, CREATE_FEATURE, project),
).toBe(true);
expect(
await accessService.hasPermission(user, UPDATE_FEATURE, project),
).toBe(true);
expect(
await accessService.hasPermission(user, DELETE_FEATURE, project),
).toBe(true);
hasFullProjectAccess(user, project, true);
});
test('should require name when create default roles to project', async () => {
@ -253,38 +377,20 @@ test('should require name when create default roles to project', async () => {
});
test('should grant user access to project', async () => {
const {
DELETE_PROJECT,
UPDATE_PROJECT,
CREATE_FEATURE,
UPDATE_FEATURE,
DELETE_FEATURE,
} = permissions;
const { DELETE_PROJECT, UPDATE_PROJECT } = permissions;
const project = 'another-project';
const user = editorUser;
const sUser = await createUserEditorAccess(
const sUser = await createUserViewerAccess(
'Some Random',
'random@getunleash.io',
);
await accessService.createDefaultProjectRoles(user, project);
const roles = await accessService.getRolesForProject(project);
const projectRole = await accessService.getRoleByName(RoleName.MEMBER);
await accessService.addUserToRole(sUser.id, projectRole.id, project);
const projectRole = roles.find(
(r) => r.name === 'Member' && r.project === project,
);
await accessService.addUserToRole(sUser.id, projectRole.id);
// Should be able to update feature toggles inside the project
expect(
await accessService.hasPermission(sUser, CREATE_FEATURE, project),
).toBe(true);
expect(
await accessService.hasPermission(sUser, UPDATE_FEATURE, project),
).toBe(true);
expect(
await accessService.hasPermission(sUser, DELETE_FEATURE, project),
).toBe(true);
// // Should be able to update feature toggles inside the project
hasCommonProjectAccess(sUser, project, true);
// Should not be able to admin the project itself.
expect(
@ -296,32 +402,20 @@ test('should grant user access to project', async () => {
});
test('should not get access if not specifying project', async () => {
const { CREATE_FEATURE, UPDATE_FEATURE, DELETE_FEATURE } = permissions;
const project = 'another-project-2';
const user = editorUser;
const sUser = await createUserEditorAccess(
const sUser = await createUserViewerAccess(
'Some Random',
'random22@getunleash.io',
);
await accessService.createDefaultProjectRoles(user, project);
const roles = await accessService.getRolesForProject(project);
const projectRole = await accessService.getRoleByName(RoleName.MEMBER);
const projectRole = roles.find(
(r) => r.name === 'Member' && r.project === project,
);
await accessService.addUserToRole(sUser.id, projectRole.id);
await accessService.addUserToRole(sUser.id, projectRole.id, project);
// Should not be able to update feature toggles outside project
expect(await accessService.hasPermission(sUser, CREATE_FEATURE)).toBe(
false,
);
expect(await accessService.hasPermission(sUser, UPDATE_FEATURE)).toBe(
false,
);
expect(await accessService.hasPermission(sUser, DELETE_FEATURE)).toBe(
false,
);
hasCommonProjectAccess(sUser, undefined, false);
});
test('should remove user from role', async () => {
@ -331,14 +425,14 @@ test('should remove user from role', async () => {
email: 'random123@getunleash.io',
});
await accessService.addUserToRole(user.id, editorRole.id);
await accessService.addUserToRole(user.id, editorRole.id, 'default');
// check user has one role
const userRoles = await accessService.getRolesForUser(user.id);
expect(userRoles.length).toBe(1);
expect(userRoles[0].name).toBe(RoleName.EDITOR);
await accessService.removeUserFromRole(user.id, editorRole.id);
await accessService.removeUserFromRole(user.id, editorRole.id, 'default');
const userRolesAfterRemove = await accessService.getRolesForUser(user.id);
expect(userRolesAfterRemove.length).toBe(0);
});
@ -350,12 +444,11 @@ test('should return role with users', async () => {
email: 'random2223@getunleash.io',
});
await accessService.addUserToRole(user.id, editorRole.id);
const roleWithUsers = await accessService.getRole(editorRole.id);
await accessService.addUserToRole(user.id, editorRole.id, 'default');
const roleWithUsers = await accessService.getRoleData(editorRole.id);
expect(roleWithUsers.role.name).toBe(RoleName.EDITOR);
expect(roleWithUsers.users.length > 2).toBe(true);
expect(roleWithUsers.users.length >= 2).toBe(true);
expect(roleWithUsers.users.find((u) => u.id === user.id)).toBeTruthy();
expect(
roleWithUsers.users.find((u) => u.email === user.email),
@ -369,39 +462,20 @@ test('should return role with permissions and users', async () => {
email: 'random2244@getunleash.io',
});
await accessService.addUserToRole(user.id, editorRole.id);
await accessService.addUserToRole(user.id, editorRole.id, 'default');
const roleWithPermission = await accessService.getRole(editorRole.id);
const roleWithPermission = await accessService.getRoleData(editorRole.id);
expect(roleWithPermission.role.name).toBe(RoleName.EDITOR);
expect(roleWithPermission.permissions.length > 2).toBe(true);
expect(
roleWithPermission.permissions.find(
(p) => p.permission === permissions.CREATE_PROJECT,
(p) => p.name === permissions.CREATE_PROJECT,
),
).toBeTruthy();
expect(roleWithPermission.users.length > 2).toBe(true);
});
test('should return list of permissions', async () => {
const p = await accessService.getPermissions();
const findPerm = (perm) => p.find((_) => _.name === perm);
const {
DELETE_FEATURE,
UPDATE_FEATURE,
CREATE_FEATURE,
UPDATE_PROJECT,
CREATE_PROJECT,
} = permissions;
expect(p.length > 2).toBe(true);
expect(findPerm(CREATE_PROJECT).type).toBe('root');
expect(findPerm(UPDATE_PROJECT).type).toBe('project');
expect(findPerm(CREATE_FEATURE).type).toBe('project');
expect(findPerm(UPDATE_FEATURE).type).toBe('project');
expect(findPerm(DELETE_FEATURE).type).toBe('project');
//This assert requires other tests to have run in this pack before length > 2 resolves to true
// I've set this to be > 1, which allows us to run the test alone and should still satisfy the logic requirement
expect(roleWithPermission.users.length > 1).toBe(true);
});
test('should set root role for user', async () => {
@ -414,9 +488,10 @@ test('should set root role for user', async () => {
await accessService.setUserRootRole(user.id, editorRole.id);
const roles = await accessService.getRolesForUser(user.id);
//To have duplicated roles like this may not may not be a hack. Needs some thought
expect(roles[0].name).toBe(RoleName.EDITOR);
expect(roles.length).toBe(1);
expect(roles[0].name).toBe(RoleName.EDITOR);
});
test('should switch root role for user', async () => {
@ -453,3 +528,250 @@ test('should not crash if user does not have permission', async () => {
expect(hasAccess).toBe(false);
});
test('should support permission with "ALL" environment requirement', async () => {
const { userStore, roleStore, accessStore } = stores;
const user = await userStore.insert({
name: 'Some User',
email: 'randomEnv1@getunleash.io',
});
await accessService.setUserRootRole(user.id, readRole.id);
const customRole = await roleStore.create({
name: 'Power user',
roleType: 'custom',
description: 'Grants access to modify all environments',
});
const { CREATE_FEATURE_STRATEGY } = permissions;
await accessStore.addPermissionsToRole(
customRole.id,
[CREATE_FEATURE_STRATEGY],
'production',
);
await accessStore.addUserToRole(user.id, customRole.id, ALL_PROJECTS);
const hasAccess = await accessService.hasPermission(
user,
CREATE_FEATURE_STRATEGY,
'default',
'production',
);
expect(hasAccess).toBe(true);
const hasNotAccess = await accessService.hasPermission(
user,
CREATE_FEATURE_STRATEGY,
'default',
'development',
);
expect(hasNotAccess).toBe(false);
});
test('Should have access to create a strategy in an environment', async () => {
const { CREATE_FEATURE_STRATEGY } = permissions;
const user = editorUser;
expect(
await accessService.hasPermission(
user,
CREATE_FEATURE_STRATEGY,
'default',
'development',
),
).toBe(true);
});
test('Should be denied access to create a strategy in an environment the user does not have access to', async () => {
const { CREATE_FEATURE_STRATEGY } = permissions;
const user = editorUser;
expect(
await accessService.hasPermission(
user,
CREATE_FEATURE_STRATEGY,
'default',
'noaccess',
),
).toBe(false);
});
test('Should have access to edit a strategy in an environment', async () => {
const { UPDATE_FEATURE_STRATEGY } = permissions;
const user = editorUser;
expect(
await accessService.hasPermission(
user,
UPDATE_FEATURE_STRATEGY,
'default',
'development',
),
).toBe(true);
});
test('Should have access to delete a strategy in an environment', async () => {
const { DELETE_FEATURE_STRATEGY } = permissions;
const user = editorUser;
expect(
await accessService.hasPermission(
user,
DELETE_FEATURE_STRATEGY,
'default',
'development',
),
).toBe(true);
});
test('Should be denied access to delete a strategy in an environment the user does not have access to', async () => {
const { DELETE_FEATURE_STRATEGY } = permissions;
const user = editorUser;
expect(
await accessService.hasPermission(
user,
DELETE_FEATURE_STRATEGY,
'default',
'noaccess',
),
).toBe(false);
});
test('Should be denied access to delete a role that is in use', async () => {
const user = editorUser;
const project = {
id: 'projectToUseRole',
name: 'New project',
description: 'Blah',
};
await projectService.createProject(project, user.id);
const projectMember = await stores.userStore.insert({
name: 'CustomProjectMember',
email: 'custom@getunleash.io',
});
const customRole = await accessService.createRole({
name: 'RoleInUse',
description: '',
permissions: [
{
id: 2,
name: 'CREATE_FEATURE',
environment: null,
displayName: 'Create Feature Toggles',
type: 'project',
},
{
id: 8,
name: 'DELETE_FEATURE',
environment: null,
displayName: 'Delete Feature Toggles',
type: 'project',
},
],
});
await projectService.addUser(project.id, customRole.id, projectMember.id);
try {
await accessService.deleteRole(customRole.id);
} catch (e) {
expect(e.toString()).toBe(
'RoleInUseError: Role is in use by more than one user. You cannot delete a role that is in use without first removing the role from the users.',
);
}
});
test('Should be denied move feature toggle to project where the user does not have access', async () => {
const user = editorUser;
const editorUser2 = await createUserEditorAccess(
'seconduser',
'bob2@gmail.com',
);
const projectOrigin = {
id: 'projectOrigin',
name: 'New project',
description: 'Blah',
};
const projectDest = {
id: 'projectDest',
name: 'New project',
description: 'Blah',
};
await projectService.createProject(projectOrigin, user.id);
await projectService.createProject(projectDest, editorUser2.id);
const featureToggle = { name: 'moveableToggle' };
await featureToggleService.createFeatureToggle(
projectOrigin.id,
featureToggle,
user.username,
);
try {
await projectService.changeProject(
projectDest.id,
featureToggle.name,
user,
projectOrigin.id,
);
} catch (e) {
expect(e.toString()).toBe(
'NoAccessError: You need permission=MOVE_FEATURE_TOGGLE to perform this action',
);
}
});
test('Should be allowed move feature toggle to project when the user has access', async () => {
const user = editorUser;
const projectOrigin = {
id: 'projectOrigin1',
name: 'New project',
description: 'Blah',
};
const projectDest = {
id: 'projectDest2',
name: 'New project',
description: 'Blah',
};
await projectService.createProject(projectOrigin, user);
await projectService.createProject(projectDest, user);
const featureToggle = { name: 'moveableToggle2' };
await featureToggleService.createFeatureToggle(
projectOrigin.id,
featureToggle,
user.username,
);
await projectService.changeProject(
projectDest.id,
featureToggle.name,
user,
projectOrigin.id,
);
});
test('Should not be allowed to edit a built in role', async () => {
expect.assertions(1);
const editRole = await accessService.getRoleByName(RoleName.EDITOR);
const roleUpdate = {
id: editRole.id,
name: 'NoLongerTheEditor',
description: 'Ha!',
};
try {
await accessService.updateRole(roleUpdate);
} catch (e) {
expect(e.toString()).toBe(
'InvalidOperationError: You can not change built in roles.',
);
}
});

View File

@ -3,12 +3,7 @@ import getLogger from '../../fixtures/no-logger';
import FeatureToggleService from '../../../lib/services/feature-toggle-service';
import ProjectService from '../../../lib/services/project-service';
import { AccessService } from '../../../lib/services/access-service';
import {
CREATE_FEATURE,
UPDATE_FEATURE,
UPDATE_PROJECT,
} from '../../../lib/types/permissions';
import NotFoundError from '../../../lib/error/notfound-error';
import { MOVE_FEATURE_TOGGLE } from '../../../lib/types/permissions';
import { createTestConfig } from '../../config/test-config';
import { RoleName } from '../../../lib/types/model';
@ -53,7 +48,12 @@ afterEach(async () => {
.map(async (env) => {
await stores.environmentStore.delete(env.name);
});
const users = await stores.userStore.getAll();
const wipeUserPermissions = users.map(async (u) => {
await stores.accessStore.unlinkUserRoles(u.id);
});
await Promise.allSettled(deleteEnvs);
await Promise.allSettled(wipeUserPermissions);
});
test('should have default project', async () => {
@ -202,37 +202,6 @@ test('should give error when getting unknown project', async () => {
}
});
test('(TODO: v4): should create roles for new project if userId is missing', async () => {
const project = {
id: 'test-roles-no-id',
name: 'New project',
description: 'Blah',
};
await projectService.createProject(project, {
username: 'random-user',
});
const roles = await stores.accessStore.getRolesForProject(project.id);
expect(roles).toHaveLength(2);
expect(
await accessService.hasPermission(user, UPDATE_PROJECT, project.id),
).toBe(false);
});
test('should create roles when project is created', async () => {
const project = {
id: 'test-roles',
name: 'New project',
description: 'Blah',
};
await projectService.createProject(project, user);
const roles = await stores.accessStore.getRolesForProject(project.id);
expect(roles).toHaveLength(2);
expect(
await accessService.hasPermission(user, UPDATE_PROJECT, project.id),
).toBe(true);
});
test('should get list of users with access to project', async () => {
const project = {
id: 'test-roles-access',
@ -240,13 +209,10 @@ test('should get list of users with access to project', async () => {
description: 'Blah',
};
await projectService.createProject(project, user);
const { roles, users } = await projectService.getUsersWithAccess(
project.id,
user,
);
const { users } = await projectService.getUsersWithAccess(project.id, user);
const owner = roles.find((role) => role.name === RoleName.OWNER);
const member = roles.find((role) => role.name === RoleName.MEMBER);
const member = await stores.roleStore.getRoleByName(RoleName.MEMBER);
const owner = await stores.roleStore.getRoleByName(RoleName.OWNER);
expect(users).toHaveLength(1);
expect(users[0].id).toBe(user.id);
@ -272,8 +238,7 @@ test('should add a member user to the project', async () => {
email: 'member2@getunleash.io',
});
const roles = await stores.accessStore.getRolesForProject(project.id);
const memberRole = roles.find((r) => r.name === RoleName.MEMBER);
const memberRole = await stores.roleStore.getRoleByName(RoleName.MEMBER);
await projectService.addUser(project.id, memberRole.id, projectMember1.id);
await projectService.addUser(project.id, memberRole.id, projectMember2.id);
@ -305,10 +270,7 @@ test('should add admin users to the project', async () => {
email: 'admin2@getunleash.io',
});
const projectRoles = await stores.accessStore.getRolesForProject(
project.id,
);
const ownerRole = projectRoles.find((r) => r.name === RoleName.OWNER);
const ownerRole = await stores.roleStore.getRoleByName(RoleName.OWNER);
await projectService.addUser(project.id, ownerRole.id, projectAdmin1.id);
await projectService.addUser(project.id, ownerRole.id, projectAdmin2.id);
@ -324,15 +286,6 @@ test('should add admin users to the project', async () => {
expect(adminUsers[2].name).toBe(projectAdmin2.name);
});
test('add user only accept to add users to project roles', async () => {
const roles = await accessService.getRoles();
const memberRole = roles.find((r) => r.name === RoleName.MEMBER);
await expect(async () => {
await projectService.addUser('some-id', memberRole.id, user.id);
}).rejects.toThrowError(NotFoundError);
});
test('add user should fail if user already have access', async () => {
const project = {
id: 'add-users-twice',
@ -346,15 +299,14 @@ test('add user should fail if user already have access', async () => {
email: 'member42@getunleash.io',
});
const roles = await stores.accessStore.getRolesForProject(project.id);
const memberRole = roles.find((r) => r.name === RoleName.MEMBER);
const memberRole = await stores.roleStore.getRoleByName(RoleName.MEMBER);
await projectService.addUser(project.id, memberRole.id, projectMember1.id);
await expect(async () =>
projectService.addUser(project.id, memberRole.id, projectMember1.id),
).rejects.toThrow(
new Error('User already have access to project=add-users-twice'),
new Error('User already has access to project=add-users-twice'),
);
});
@ -371,8 +323,7 @@ test('should remove user from the project', async () => {
email: 'member99@getunleash.io',
});
const roles = await stores.accessStore.getRolesForProject(project.id);
const memberRole = roles.find((r) => r.name === RoleName.MEMBER);
const memberRole = await stores.roleStore.getRoleByName(RoleName.MEMBER);
await projectService.addUser(project.id, memberRole.id, projectMember1.id);
await projectService.removeUser(
@ -395,7 +346,7 @@ test('should not remove user from the project', async () => {
};
await projectService.createProject(project, user);
const roles = await stores.accessStore.getRolesForProject(project.id);
const roles = await stores.roleStore.getRolesForProject(project.id);
const ownerRole = roles.find((r) => r.name === RoleName.OWNER);
await expect(async () => {
@ -426,7 +377,7 @@ test('should not change project if feature toggle project does not match current
);
} catch (err) {
expect(err.message).toBe(
`You need permission=${UPDATE_FEATURE} to perform this action`,
`You need permission=${MOVE_FEATURE_TOGGLE} to perform this action`,
);
}
});
@ -487,7 +438,7 @@ test('should fail if user is not authorized', async () => {
);
} catch (err) {
expect(err.message).toBe(
`You need permission=${CREATE_FEATURE} to perform this action`,
`You need permission=${MOVE_FEATURE_TOGGLE} to perform this action`,
);
}
});
@ -548,3 +499,156 @@ test('A newly created project only gets connected to enabled environments', asyn
expect(connectedEnvs.some((e) => e === enabledEnv)).toBeTruthy();
expect(connectedEnvs.some((e) => e === disabledEnv)).toBeFalsy();
});
test('should add a user to the project with a custom role', async () => {
const project = {
id: 'add-users-custom-role',
name: 'New project',
description: 'Blah',
};
await projectService.createProject(project, user);
const projectMember1 = await stores.userStore.insert({
name: 'Custom',
email: 'custom@getunleash.io',
});
const customRole = await accessService.createRole({
name: 'Service Engineer2',
description: '',
permissions: [
{
id: 2,
name: 'CREATE_FEATURE',
environment: null,
displayName: 'Create Feature Toggles',
type: 'project',
},
{
id: 8,
name: 'DELETE_FEATURE',
environment: null,
displayName: 'Delete Feature Toggles',
type: 'project',
},
],
});
await projectService.addUser(project.id, customRole.id, projectMember1.id);
const { users } = await projectService.getUsersWithAccess(project.id, user);
const customRoleMember = users.filter((u) => u.roleId === customRole.id);
expect(customRoleMember).toHaveLength(1);
expect(customRoleMember[0].id).toBe(projectMember1.id);
expect(customRoleMember[0].name).toBe(projectMember1.name);
});
test('should delete role entries when deleting project', async () => {
const project = {
id: 'test-delete-users-1',
name: 'New project',
description: 'Blah',
};
await projectService.createProject(project, user);
const user1 = await stores.userStore.insert({
name: 'Projectuser1',
email: 'project1@getunleash.io',
});
const user2 = await stores.userStore.insert({
name: 'Projectuser2',
email: 'project2@getunleash.io',
});
const customRole = await accessService.createRole({
name: 'Service Engineer',
description: '',
permissions: [
{
id: 2,
name: 'CREATE_FEATURE',
environment: null,
displayName: 'Create Feature Toggles',
type: 'project',
},
{
id: 8,
name: 'DELETE_FEATURE',
environment: null,
displayName: 'Delete Feature Toggles',
type: 'project',
},
],
});
await projectService.addUser(project.id, customRole.id, user1.id);
await projectService.addUser(project.id, customRole.id, user2.id);
let usersForRole = await accessService.getUsersForRole(customRole.id);
expect(usersForRole.length).toBe(2);
await projectService.deleteProject(project.id, user);
usersForRole = await accessService.getUsersForRole(customRole.id);
expect(usersForRole.length).toBe(0);
});
test('should change a users role in the project', async () => {
const project = {
id: 'test-change-user-role',
name: 'New project',
description: 'Blah',
};
await projectService.createProject(project, user);
const projectUser = await stores.userStore.insert({
name: 'Projectuser3',
email: 'project3@getunleash.io',
});
const customRole = await accessService.createRole({
name: 'Service Engineer3',
description: '',
permissions: [
{
id: 2,
name: 'CREATE_FEATURE',
environment: null,
displayName: 'Create Feature Toggles',
type: 'project',
},
{
id: 8,
name: 'DELETE_FEATURE',
environment: null,
displayName: 'Delete Feature Toggles',
type: 'project',
},
],
});
const member = await stores.roleStore.getRoleByName(RoleName.MEMBER);
await projectService.addUser(project.id, member.id, projectUser.id);
const { users } = await projectService.getUsersWithAccess(project.id, user);
let memberUser = users.filter((u) => u.roleId === member.id);
expect(memberUser).toHaveLength(1);
expect(memberUser[0].id).toBe(projectUser.id);
expect(memberUser[0].name).toBe(projectUser.name);
await projectService.removeUser(project.id, member.id, projectUser.id);
await projectService.addUser(project.id, customRole.id, projectUser.id);
let { users: updatedUsers } = await projectService.getUsersWithAccess(
project.id,
user,
);
const customUser = updatedUsers.filter((u) => u.roleId === customRole.id);
expect(customUser).toHaveLength(1);
expect(customUser[0].id).toBe(projectUser.id);
expect(customUser[0].name).toBe(projectUser.name);
});

View File

@ -4,12 +4,21 @@ import { AccessService } from '../../lib/services/access-service';
import User from '../../lib/types/user';
import noLoggerProvider from './no-logger';
import { IRole } from '../../lib/types/stores/access-store';
import { IPermission, IRoleData, IUserWithRole } from '../../lib/types/model';
import {
IAvailablePermissions,
IRoleData,
IUserWithRole,
} from '../../lib/types/model';
class AccessServiceMock extends AccessService {
constructor() {
super(
{ accessStore: undefined, userStore: undefined },
{
accessStore: undefined,
userStore: undefined,
roleStore: undefined,
environmentStore: undefined,
},
{ getLogger: noLoggerProvider },
);
}
@ -22,7 +31,7 @@ class AccessServiceMock extends AccessService {
throw new Error('Method not implemented.');
}
getPermissions(): IPermission[] {
getPermissions(): Promise<IAvailablePermissions> {
throw new Error('Method not implemented.');
}
@ -34,10 +43,6 @@ class AccessServiceMock extends AccessService {
return Promise.resolve();
}
removeUserFromRole(userId: number, roleId: number): Promise<void> {
throw new Error('Method not implemented.');
}
addPermissionToRole(
roleId: number,
permission: string,
@ -58,10 +63,6 @@ class AccessServiceMock extends AccessService {
throw new Error('Method not implemented.');
}
getRole(roleId: number): Promise<IRoleData> {
throw new Error('Method not implemented.');
}
getRolesForProject(projectId: string): Promise<IRole[]> {
throw new Error('Method not implemented.');
}

View File

@ -6,17 +6,60 @@ import {
IUserPermission,
IUserRole,
} from '../../lib/types/stores/access-store';
import { IAvailablePermissions, IPermission } from 'lib/types/model';
class AccessStoreMock implements IAccessStore {
removeUserFromRole(
userId: number,
roleId: number,
projectId: string,
): Promise<void> {
throw new Error('Method not implemented.');
}
wipePermissionsFromRole(role_id: number): Promise<void> {
throw new Error('Method not implemented.');
}
unlinkUserRoles(userId: number): Promise<void> {
throw new Error('Method not implemented.');
}
getRoleByName(name: string): Promise<IRole> {
throw new Error('Method not implemented.');
}
getProjectUserIdsForRole(
roleId: number,
projectId?: string,
): Promise<number[]> {
throw new Error('Method not implemented.');
}
getProjectRoles(): Promise<IRole[]> {
throw new Error('Method not implemented.');
}
addEnvironmentPermissionsToRole(
role_id: number,
permissions: IPermission[],
): Promise<void> {
throw new Error('Method not implemented.');
}
userPermissions: IUserPermission[] = [];
roles: IRole[] = [];
getAvailablePermissions(): Promise<IPermission[]> {
throw new Error('Method not implemented.');
}
getPermissionsForUser(userId: Number): Promise<IUserPermission[]> {
return Promise.resolve([]);
}
getPermissionsForRole(roleId: number): Promise<IUserPermission[]> {
getPermissionsForRole(roleId: number): Promise<IPermission[]> {
throw new Error('Method not implemented.');
}
@ -40,7 +83,7 @@ class AccessStoreMock implements IAccessStore {
return Promise.resolve([]);
}
getUserIdsForRole(roleId: number): Promise<number[]> {
getUserIdsForRole(roleId: number, projectId: string): Promise<number[]> {
throw new Error('Method not implemented.');
}
@ -48,19 +91,6 @@ class AccessStoreMock implements IAccessStore {
throw new Error('Method not implemented.');
}
removeUserFromRole(userId: number, roleId: number): Promise<void> {
throw new Error('Method not implemented.');
}
createRole(
name: string,
type: string,
project?: string,
description?: string,
): Promise<IRole> {
throw new Error('Method not implemented.');
}
addPermissionsToRole(
role_id: number,
permissions: string[],

74
src/test/fixtures/fake-role-store.ts vendored Normal file
View File

@ -0,0 +1,74 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { ICustomRole } from 'lib/types/model';
import { IRole, IUserRole } from 'lib/types/stores/access-store';
import {
ICustomRoleInsert,
ICustomRoleUpdate,
IRoleStore,
} from 'lib/types/stores/role-store';
export default class FakeRoleStore implements IRoleStore {
nameInUse(name: string, existingId: number): Promise<boolean> {
throw new Error('Method not implemented.');
}
getAll(): Promise<ICustomRole[]> {
throw new Error('Method not implemented.');
}
create(role: ICustomRoleInsert): Promise<ICustomRole> {
throw new Error('Method not implemented.');
}
update(role: ICustomRoleUpdate): Promise<ICustomRole> {
throw new Error('Method not implemented.');
}
delete(id: number): Promise<void> {
throw new Error('Method not implemented.');
}
getRoles(): Promise<IRole[]> {
throw new Error('Method not implemented.');
}
getRoleByName(name: string): Promise<IRole> {
throw new Error('Method not implemented.');
}
getRolesForProject(projectId: string): Promise<IRole[]> {
throw new Error('Method not implemented.');
}
removeRolesForProject(projectId: string): Promise<void> {
throw new Error('Method not implemented.');
}
getProjectRoles(): Promise<IRole[]> {
throw new Error('Method not implemented.');
}
getRootRoles(): Promise<IRole[]> {
throw new Error('Method not implemented.');
}
getRootRoleForAllUsers(): Promise<IUserRole[]> {
throw new Error('Method not implemented.');
}
get(key: number): Promise<ICustomRole> {
throw new Error('Method not implemented.');
}
exists(key: number): Promise<boolean> {
throw new Error('Method not implemented.');
}
deleteAll(): Promise<void> {
throw new Error('Method not implemented.');
}
destroy(): void {
throw new Error('Method not implemented.');
}
}

View File

@ -24,6 +24,7 @@ import FakeResetTokenStore from './fake-reset-token-store';
import FakeFeatureToggleClientStore from './fake-feature-toggle-client-store';
import FakeClientMetricsStoreV2 from './fake-client-metrics-store-v2';
import FakeUserSplashStore from './fake-user-splash-store';
import FakeRoleStore from './fake-role-store';
const createStores: () => IUnleashStores = () => {
const db = {
@ -59,6 +60,7 @@ const createStores: () => IUnleashStores = () => {
resetTokenStore: new FakeResetTokenStore(),
sessionStore: new FakeSessionStore(),
userSplashStore: new FakeUserSplashStore(),
roleStore: new FakeRoleStore(),
};
};

430
yarn.lock

File diff suppressed because it is too large Load Diff