1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01:00

feat: Adding Project access requires same role (#6270)

In order to prevent users from being able to assign roles/permissions
they don't have, this PR adds a check that the user performing the
action either is Admin, Project owner or has the same role they are
trying to grant/add.

This addAccess method is only used from Enterprise, so there will be a
separate PR there, updating how we return the roles list for a user, so
that our frontend can only present the roles a user is actually allowed
to grant.

This adds the validation to the backend to ensure that even if the
frontend thinks we're allowed to add any role to any user here, the
backend can be smart enough to stop it.

We should still update frontend as well, so that it doesn't look like we
can add roles we won't be allowed to.
This commit is contained in:
Christopher Kolstad 2024-02-20 15:56:53 +01:00 committed by GitHub
parent 4857a73621
commit e9d9db17fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 652 additions and 144 deletions

View File

@ -1,9 +1,7 @@
import { ComponentProps, useEffect, useMemo, useState, VFC } from 'react';
import { useMemo, useState, VFC } from 'react';
import {
Autocomplete,
Box,
styled,
TextField,
Typography,
useMediaQuery,
useTheme,

View File

@ -36,6 +36,7 @@ import {
import { caseInsensitiveSearch } from 'utils/search';
import { IServiceAccount } from 'interfaces/service-account';
import { MultipleRoleSelect } from 'component/common/MultipleRoleSelect/MultipleRoleSelect';
import { IUserProjectRole } from '../../../../interfaces/userProjectRoles';
const StyledForm = styled('form')(() => ({
display: 'flex',
@ -95,6 +96,7 @@ interface IProjectAccessAssignProps {
serviceAccounts: IServiceAccount[];
groups: IGroup[];
roles: IRole[];
userRoles: IUserProjectRole[];
}
export const ProjectAccessAssign = ({
@ -104,6 +106,7 @@ export const ProjectAccessAssign = ({
serviceAccounts,
groups,
roles,
userRoles,
}: IProjectAccessAssignProps) => {
const { uiConfig } = useUiConfig();
const { flags } = uiConfig;
@ -318,7 +321,13 @@ export const ProjectAccessAssign = ({
};
const isValid = selectedOptions.length > 0 && selectedRoles.length > 0;
const filteredRoles = userRoles.some(
(userrole) => userrole.name === 'Admin' || userrole.name === 'Owner',
)
? roles
: roles.filter((role) =>
userRoles.some((userrole) => role.id === userrole.id),
);
return (
<SidebarModal
open
@ -441,7 +450,7 @@ export const ProjectAccessAssign = ({
<StyledAutocompleteWrapper>
<MultipleRoleSelect
data-testid={PA_ROLE_ID}
roles={roles}
roles={filteredRoles}
value={selectedRoles}
setValue={setRoles}
/>

View File

@ -2,10 +2,12 @@ import { ProjectAccessAssign } from '../ProjectAccessAssign/ProjectAccessAssign'
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import useProjectAccess from 'hooks/api/getters/useProjectAccess/useProjectAccess';
import { useAccess } from 'hooks/api/getters/useAccess/useAccess';
import { useUserProjectRoles } from '../../../../hooks/api/getters/useUserProjectRoles/useUserProjectRoles';
export const ProjectAccessCreate = () => {
const projectId = useRequiredPathParam('projectId');
const { roles: userRoles } = useUserProjectRoles(projectId);
const { access } = useProjectAccess(projectId);
const { users, serviceAccounts, groups } = useAccess();
@ -20,6 +22,7 @@ export const ProjectAccessCreate = () => {
serviceAccounts={serviceAccounts}
groups={groups}
roles={access.roles}
userRoles={userRoles}
/>
);
};

View File

@ -4,10 +4,12 @@ import useProjectAccess, {
ENTITY_TYPE,
} from 'hooks/api/getters/useProjectAccess/useProjectAccess';
import { useAccess } from 'hooks/api/getters/useAccess/useAccess';
import { useUserProjectRoles } from '../../../../hooks/api/getters/useUserProjectRoles/useUserProjectRoles';
export const ProjectAccessEditGroup = () => {
const projectId = useRequiredPathParam('projectId');
const groupId = useRequiredPathParam('groupId');
const { roles: userRoles } = useUserProjectRoles(projectId);
const { access } = useProjectAccess(projectId);
const { users, serviceAccounts, groups } = useAccess();
@ -29,6 +31,7 @@ export const ProjectAccessEditGroup = () => {
serviceAccounts={serviceAccounts}
groups={groups}
roles={access.roles}
userRoles={userRoles}
/>
);
};

View File

@ -4,11 +4,12 @@ import useProjectAccess, {
ENTITY_TYPE,
} from 'hooks/api/getters/useProjectAccess/useProjectAccess';
import { useAccess } from 'hooks/api/getters/useAccess/useAccess';
import { useUserProjectRoles } from '../../../../hooks/api/getters/useUserProjectRoles/useUserProjectRoles';
export const ProjectAccessEditUser = () => {
const projectId = useRequiredPathParam('projectId');
const userId = useRequiredPathParam('userId');
const { roles: userRoles } = useUserProjectRoles(projectId);
const { access } = useProjectAccess(projectId);
const { users, serviceAccounts, groups } = useAccess();
@ -29,6 +30,7 @@ export const ProjectAccessEditUser = () => {
serviceAccounts={serviceAccounts}
groups={groups}
roles={access.roles}
userRoles={userRoles}
/>
);
};

View File

@ -0,0 +1,20 @@
import { formatApiPath } from '../../../../utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler';
export const getUserProjectRolesFetcher = (id: string) => {
const fetcher = () => {
const path = formatApiPath(`api/admin/user/roles?projectId=${id}`);
return fetch(path, {
method: 'GET',
})
.then(handleErrorResponses('User Project roles'))
.then((res) => res.json());
};
const KEY = `api/admin/projects/${id}/roles`;
return {
fetcher,
KEY,
};
};

View File

@ -0,0 +1,26 @@
import { getUserProjectRolesFetcher } from './getUserProjectRolesFetcher';
import useSWR, { SWRConfiguration } from 'swr';
import { useCallback } from 'react';
import { IUserProjectRoles } from '../../../../interfaces/userProjectRoles';
export const useUserProjectRoles = (
projectId: string,
options: SWRConfiguration = {},
) => {
const { KEY, fetcher } = getUserProjectRolesFetcher(projectId);
const { data, error, mutate } = useSWR<IUserProjectRoles>(
KEY,
fetcher,
options,
);
const refetch = useCallback(() => {
mutate();
}, [mutate]);
return {
roles: data?.roles || [],
loading: !error && !data,
error,
refetch,
};
};

View File

@ -0,0 +1,12 @@
export interface IUserProjectRole {
id: number;
name: string;
type: string;
project?: string;
description?: string;
}
export interface IUserProjectRoles {
version: number;
roles: IUserProjectRole[];
}

View File

@ -403,6 +403,42 @@ export class AccessStore implements IAccessStore {
.where('ru.user_id', '=', userId);
}
async getAllProjectRolesForUser(
userId: number,
project: string,
): Promise<IRoleWithProject[]> {
const stopTimer = this.timer('get_all_project_roles_for_user');
const roles = await this.db
.select(['id', 'name', 'type', 'project', 'description'])
.from<IRole[]>(T.ROLES)
.innerJoin(`${T.ROLE_USER} as ru`, 'ru.role_id', 'id')
.where('ru.user_id', '=', userId)
.andWhere((builder) => {
builder
.where('ru.project', '=', project)
.orWhere('type', '=', 'root');
})
.union([
this.db
.select(['id', 'name', 'type', 'project', 'description'])
.from<IRole[]>(T.ROLES)
.innerJoin(`${T.GROUP_ROLE} as gr`, 'gr.role_id', 'id')
.innerJoin(
`${T.GROUP_USER} as gu`,
'gu.group_id',
'gr.group_id',
)
.where('gu.user_id', '=', userId)
.andWhere((builder) => {
builder
.where('gr.project', '=', project)
.orWhere('type', '=', 'root');
}),
]);
stopTimer();
return roles;
}
async getRootRoleForUser(userId: number): Promise<IRole | undefined> {
return this.db
.select(['id', 'name', 'type', 'description'])

View File

@ -271,7 +271,6 @@ test('should return 204 if metrics are disabled by feature flag', async () => {
describe('bulk metrics', () => {
test('filters out metrics for environments we do not have access for. No auth setup so we can only access default env', async () => {
const timer = new Date().valueOf();
await request
.post('/api/client/metrics/bulk')
.send({
@ -298,29 +297,17 @@ describe('bulk metrics', () => {
],
})
.expect(202);
console.log(
`Posting happened ${new Date().valueOf() - timer} ms after`,
);
await services.clientMetricsServiceV2.bulkAdd(); // Force bulk collection.
console.log(
`Bulk add happened ${new Date().valueOf() - timer} ms after`,
);
const developmentReport =
await services.clientMetricsServiceV2.getClientMetricsForToggle(
'test_feature_two',
1,
);
console.log(
`Getting for toggle two ${new Date().valueOf() - timer} ms after`,
);
const defaultReport =
await services.clientMetricsServiceV2.getClientMetricsForToggle(
'test_feature_one',
1,
);
console.log(
`Getting for toggle one ${new Date().valueOf() - timer} ms after`,
);
expect(developmentReport).toHaveLength(0);
expect(defaultReport).toHaveLength(1);
expect(defaultReport[0].yes).toBe(1000);

View File

@ -16,16 +16,16 @@ import ProjectService from './project-service';
import VariantsController from '../../routes/admin-api/project/variants';
import {
createResponseSchema,
ProjectDoraMetricsSchema,
projectDoraMetricsSchema,
DeprecatedProjectOverviewSchema,
deprecatedProjectOverviewSchema,
projectsSchema,
ProjectsSchema,
ProjectDoraMetricsSchema,
projectDoraMetricsSchema,
projectOverviewSchema,
ProjectsSchema,
projectsSchema,
} from '../../openapi';
import { getStandardResponses } from '../../openapi/util/standard-responses';
import { OpenApiService, SettingService } from '../../services';
import { AccessService, OpenApiService, SettingService } from '../../services';
import { IAuthRequest } from '../../routes/unleash-types';
import { ProjectApiTokenController } from '../../routes/admin-api/project/api-token';
import ProjectArchiveController from '../../routes/admin-api/project/project-archive';
@ -45,6 +45,8 @@ export default class ProjectController extends Controller {
private settingService: SettingService;
private accessService: AccessService;
private openApiService: OpenApiService;
private flagResolver: IFlagResolver;
@ -54,6 +56,7 @@ export default class ProjectController extends Controller {
this.projectService = services.projectService;
this.openApiService = services.openApiService;
this.settingService = services.settingService;
this.accessService = services.accessService;
this.flagResolver = config.flagResolver;
this.route({
@ -62,7 +65,7 @@ export default class ProjectController extends Controller {
handler: this.getProjects,
permission: NONE,
middleware: [
services.openApiService.validPath({
this.openApiService.validPath({
tags: ['Projects'],
operationId: 'getProjects',
summary: 'Get a list of all projects.',
@ -82,7 +85,7 @@ export default class ProjectController extends Controller {
handler: this.getDeprecatedProjectOverview,
permission: NONE,
middleware: [
services.openApiService.validPath({
this.openApiService.validPath({
tags: ['Projects'],
operationId: 'getDeprecatedProjectOverview',
summary: 'Get an overview of a project. (deprecated)',
@ -105,7 +108,7 @@ export default class ProjectController extends Controller {
handler: this.getProjectOverview,
permission: NONE,
middleware: [
services.openApiService.validPath({
this.openApiService.validPath({
tags: ['Projects'],
operationId: 'getProjectOverview',
summary: 'Get an overview of a project.',
@ -125,7 +128,7 @@ export default class ProjectController extends Controller {
handler: this.getProjectDora,
permission: NONE,
middleware: [
services.openApiService.validPath({
this.openApiService.validPath({
tags: ['Projects'],
operationId: 'getProjectDora',
summary: 'Get an overview project dora metrics.',
@ -145,7 +148,7 @@ export default class ProjectController extends Controller {
handler: this.getProjectApplications,
permission: NONE,
middleware: [
services.openApiService.validPath({
this.openApiService.validPath({
tags: ['Unstable'],
operationId: 'getProjectApplications',
summary: 'Get a list of all applications for a project.',

View File

@ -8,15 +8,23 @@ import { nameType } from '../../routes/util';
import { projectSchema } from '../../services/project-schema';
import NotFoundError from '../../error/notfound-error';
import {
CreateProject,
DEFAULT_PROJECT,
FeatureToggle,
IAccountStore,
IEnvironmentStore,
IEventStore,
IFeatureEnvironmentStore,
IFeatureNaming,
IFeatureToggleStore,
IFlagResolver,
IProject,
IProjectApplications,
IProjectHealth,
IProjectOverview,
IProjectRoleUsage,
IProjectStore,
IProjectUpdate,
IProjectWithCount,
IUnleashConfig,
IUnleashStores,
@ -24,6 +32,10 @@ import {
PROJECT_CREATED,
PROJECT_DELETED,
PROJECT_UPDATED,
ProjectAccessAddedEvent,
ProjectAccessGroupRolesUpdated,
ProjectAccessUserRolesDeleted,
ProjectAccessUserRolesUpdated,
ProjectGroupAddedEvent,
ProjectGroupRemovedEvent,
ProjectGroupUpdateRoleEvent,
@ -31,23 +43,12 @@ import {
ProjectUserRemovedEvent,
ProjectUserUpdateRoleEvent,
RoleName,
IFlagResolver,
ProjectAccessAddedEvent,
ProjectAccessUserRolesUpdated,
ProjectAccessGroupRolesUpdated,
IProjectRoleUsage,
ProjectAccessUserRolesDeleted,
IFeatureNaming,
CreateProject,
IProjectUpdate,
IProjectHealth,
SYSTEM_USER,
IProjectStore,
IProjectApplications,
} from '../../types';
import {
IProjectAccessModel,
IRoleDescriptor,
IRoleWithProject,
} from '../../types/stores/access-store';
import FeatureToggleService from '../feature-toggle/feature-toggle-service';
import IncompatibleProjectError from '../../error/incompatible-project-error';
@ -700,6 +701,37 @@ export default class ProjectService {
);
}
private isAdmin(roles: IRoleWithProject[]): boolean {
return roles.some((r) => r.name === RoleName.ADMIN);
}
private isProjectOwner(
roles: IRoleWithProject[],
project: string,
): boolean {
return roles.some(
(r) => r.project === project && r.name === RoleName.OWNER,
);
}
private async isAllowedToAddAccess(
userAddingAccess: number,
projectId: string,
rolesBeingAdded: number[],
): Promise<boolean> {
const userRoles = await this.accessService.getAllProjectRolesForUser(
userAddingAccess,
projectId,
);
if (
this.isAdmin(userRoles) ||
this.isProjectOwner(userRoles, projectId)
) {
return true;
}
return rolesBeingAdded.every((role) =>
userRoles.some((userRole) => userRole.id === role),
);
}
async addAccess(
projectId: string,
roles: number[],
@ -708,30 +740,38 @@ export default class ProjectService {
createdBy: string,
createdByUserId: number,
): Promise<void> {
await this.accessService.addAccessToProject(
roles,
groups,
users,
projectId,
createdBy,
);
await this.eventService.storeEvent(
new ProjectAccessAddedEvent({
project: projectId,
if (
await this.isAllowedToAddAccess(createdByUserId, projectId, roles)
) {
await this.accessService.addAccessToProject(
roles,
groups,
users,
projectId,
createdBy,
createdByUserId,
data: {
roles: roles.map((roleId) => {
return {
roleId,
groupIds: groups,
userIds: users,
};
}),
},
}),
);
);
await this.eventService.storeEvent(
new ProjectAccessAddedEvent({
project: projectId,
createdBy,
createdByUserId,
data: {
roles: roles.map((roleId) => {
return {
roleId,
groupIds: groups,
userIds: users,
};
}),
},
}),
);
} else {
throw new InvalidOperationError(
'User tried to grant role they did not have access to',
);
}
}
async setRolesForUser(

View File

@ -6,15 +6,16 @@ import {
addonsSchema,
addonTypeSchema,
adminCountSchema,
adminSegmentSchema,
adminFeaturesQuerySchema,
adminSegmentSchema,
advancedPlaygroundFeatureSchema,
advancedPlaygroundRequestSchema,
advancedPlaygroundResponseSchema,
apiTokenSchema,
apiTokensSchema,
applicationSchema,
applicationUsageSchema,
applicationsSchema,
applicationUsageSchema,
batchFeaturesSchema,
bulkToggleFeaturesSchema,
changePasswordSchema,
@ -23,16 +24,28 @@ import {
clientFeaturesQuerySchema,
clientFeaturesSchema,
clientMetricsSchema,
clientSegmentSchema,
cloneFeatureSchema,
constraintSchema,
contextFieldSchema,
contextFieldsSchema,
createApiTokenSchema,
createContextFieldSchema,
createDependentFeatureSchema,
createFeatureSchema,
createFeatureStrategySchema,
createGroupSchema,
createInvitedUserSchema,
createPatSchema,
createStrategySchema,
createStrategyVariantSchema,
createUserResponseSchema,
createUserSchema,
dateSchema,
dependenciesExistSchema,
dependentFeatureSchema,
deprecatedProjectOverviewSchema,
doraFeaturesSchema,
edgeTokenSchema,
emailSchema,
environmentProjectSchema,
@ -48,17 +61,19 @@ import {
featureEventsSchema,
featureMetricsSchema,
featureSchema,
featureSearchResponseSchema,
featuresSchema,
featureStrategySchema,
featureStrategySegmentSchema,
featureTagSchema,
featureTypeCountSchema,
featureTypeSchema,
featureTypesSchema,
featureUsageSchema,
featureVariantsSchema,
feedbackCreateSchema,
feedbackUpdateSchema,
feedbackResponseSchema,
feedbackUpdateSchema,
groupSchema,
groupsSchema,
groupUserModelSchema,
@ -66,9 +81,12 @@ import {
healthOverviewSchema,
healthReportSchema,
idSchema,
idsSchema,
importTogglesSchema,
importTogglesValidateItemSchema,
importTogglesValidateSchema,
inactiveUserSchema,
inactiveUsersSchema,
instanceAdminStatsSchema,
legalValueSchema,
loginSchema,
@ -76,10 +94,10 @@ import {
nameSchema,
overrideSchema,
parametersSchema,
parentFeatureOptionsSchema,
passwordSchema,
patchesSchema,
patchSchema,
createPatSchema,
patSchema,
patsSchema,
permissionSchema,
@ -90,9 +108,9 @@ import {
playgroundSegmentSchema,
playgroundStrategySchema,
profileSchema,
projectDoraMetricsSchema,
projectEnvironmentSchema,
projectOverviewSchema,
deprecatedProjectOverviewSchema,
projectSchema,
projectsSchema,
projectStatsSchema,
@ -104,6 +122,7 @@ import {
publicSignupTokensSchema,
publicSignupTokenUpdateSchema,
pushVariantsSchema,
recordUiErrorSchema,
requestsPerSecondSchema,
requestsPerSecondSegmentedSchema,
resetPasswordSchema,
@ -111,7 +130,9 @@ import {
sdkContextSchema,
sdkFlatContextSchema,
searchEventsSchema,
searchFeaturesSchema,
segmentSchema,
segmentsSchema,
setStrategySortOrderSchema,
setUiConfigSchema,
sortOrderSchema,
@ -120,61 +141,40 @@ import {
stateSchema,
strategiesSchema,
strategySchema,
strategyVariantSchema,
tagsBulkAddSchema,
tagSchema,
tagsSchema,
tagTypeSchema,
tagTypesSchema,
tagWithVersionSchema,
telemetrySettingsSchema,
tokenStringListSchema,
tokenUserSchema,
uiConfigSchema,
updateApiTokenSchema,
updateContextFieldSchema,
updateFeatureSchema,
updateFeatureStrategySchema,
updateFeatureStrategySegmentsSchema,
updateFeatureTypeLifetimeSchema,
updateStrategySchema,
updateTagTypeSchema,
updateUserSchema,
createContextFieldSchema,
updateContextFieldSchema,
upsertSegmentSchema,
createStrategySchema,
updateStrategySchema,
updateFeatureTypeLifetimeSchema,
userSchema,
usersGroupsBaseSchema,
usersSchema,
createUserResponseSchema,
usersSearchSchema,
validateArchiveFeaturesSchema,
validatedEdgeTokensSchema,
validateFeatureSchema,
validatePasswordSchema,
validateTagTypeSchema,
variantSchema,
variantFlagSchema,
variantSchema,
variantsSchema,
versionSchema,
advancedPlaygroundFeatureSchema,
telemetrySettingsSchema,
strategyVariantSchema,
createStrategyVariantSchema,
clientSegmentSchema,
createGroupSchema,
doraFeaturesSchema,
projectDoraMetricsSchema,
segmentsSchema,
updateFeatureStrategySegmentsSchema,
dependentFeatureSchema,
createDependentFeatureSchema,
parentFeatureOptionsSchema,
dependenciesExistSchema,
validateArchiveFeaturesSchema,
searchFeaturesSchema,
featureTypeCountSchema,
featureSearchResponseSchema,
inactiveUserSchema,
inactiveUsersSchema,
idsSchema,
recordUiErrorSchema,
} from './spec';
import { IServerOption } from '../types';
import { mapValues, omitKeys } from '../util';
@ -197,6 +197,7 @@ import { featureDependenciesSchema } from './spec/feature-dependencies-schema';
import { projectApplicationsSchema } from './spec/project-applications-schema';
import { projectApplicationSchema } from './spec/project-application-schema';
import { projectApplicationSdkSchema } from './spec/project-application-sdk-schema';
import { rolesSchema } from './spec/roles-schema';
// Schemas must have an $id property on the form "#/components/schemas/mySchema".
export type SchemaId = (typeof schemas)[keyof typeof schemas]['$id'];
@ -345,6 +346,7 @@ export const schemas: UnleashSchemas = {
requestsPerSecondSchema,
requestsPerSecondSegmentedSchema,
roleSchema,
rolesSchema,
sdkContextSchema,
sdkFlatContextSchema,
searchEventsSchema,

View File

@ -30,6 +30,12 @@ export const roleSchema = {
type: 'string',
example: `Users with the editor role have access to most features in Unleash but can not manage users and roles in the global scope. Editors will be added as project owners when creating projects and get superuser rights within the context of these projects. Users with the editor role will also get access to most permissions on the default project by default.`,
},
project: {
description: 'What project the role belongs to',
type: 'string',
nullable: true,
example: 'default',
},
},
components: {},
} as const;

View File

@ -0,0 +1,32 @@
import { roleSchema } from './role-schema';
import { FromSchema } from 'json-schema-to-ts';
export const rolesSchema = {
$id: '#/components/schemas/rolesSchema',
type: 'object',
description: 'A list of roles',
additionalProperties: false,
required: ['version', 'roles'],
properties: {
version: {
type: 'integer',
description: 'The version of the role schema used',
minimum: 1,
example: 1,
},
roles: {
type: 'array',
items: {
$ref: '#/components/schemas/roleSchema',
},
description: 'A list of roles',
},
},
components: {
schemas: {
roleSchema,
},
},
} as const;
export type RolesSchema = FromSchema<typeof rolesSchema>;

View File

@ -24,6 +24,7 @@ import {
ProfileSchema,
} from '../../../openapi/spec/profile-schema';
import ProjectService from '../../../features/project/project-service';
import { rolesSchema, RolesSchema } from '../../../openapi/spec/roles-schema';
class UserController extends Controller {
private accessService: AccessService;
@ -131,8 +132,61 @@ class UserController extends Controller {
}),
],
});
this.route({
method: 'get',
path: '/roles',
handler: this.getRoles,
permission: NONE,
middleware: [
this.openApiService.validPath({
tags: ['Users'],
operationId: 'getUserRoles',
summary: 'Get roles for currently logged in user',
parameters: [
{
name: 'projectId',
description:
'The id of the project you want to check permissions for',
schema: {
type: 'string',
},
in: 'query',
},
],
description:
'Gets roles assigned to currently logged in user. Both explicitly and transitively through group memberships',
responses: {
200: createResponseSchema('rolesSchema'),
...getStandardResponses(401, 403),
},
}),
],
});
}
async getRoles(
req: IAuthRequest,
res: Response<RolesSchema>,
): Promise<void> {
const { projectId } = req.query;
if (projectId) {
const roles = await this.accessService.getAllProjectRolesForUser(
req.user.id,
projectId,
);
this.openApiService.respondWithValidation(
200,
res,
rolesSchema.$id,
{
version: 1,
roles,
},
);
} else {
}
}
async getMe(req: IAuthRequest, res: Response<MeSchema>): Promise<void> {
res.setHeader('cache-control', 'no-store');
const { user } = req;

View File

@ -8,6 +8,7 @@ import {
IRole,
IRoleDescriptor,
IRoleWithPermissions,
IRoleWithProject,
IUserPermission,
IUserRole,
IUserWithProjectRoles,
@ -215,6 +216,19 @@ export class AccessService {
}
}
/**
* Returns all roles the user has in the project.
* Including roles via groups.
* In addition it includes root roles
* @param userId user to find roles for
* @param project project to find roles for
*/
async getAllProjectRolesForUser(
userId: number,
project: string,
): Promise<IRoleWithProject[]> {
return this.store.getAllProjectRolesForUser(userId, project);
}
/**
* Check a user against all available permissions.
* Provided a project, project permissions will be checked against that project.

View File

@ -87,6 +87,11 @@ export interface IAccessStore extends Store<IRole, number> {
projectId?: string,
): Promise<IUserRole[]>;
getAllProjectRolesForUser(
userId: number,
project: string,
): Promise<IRoleWithProject[]>;
getProjectUsers(projectId?: string): Promise<IUserWithProjectRoles[]>;
getUserIdsForRole(roleId: number, projectId?: string): Promise<number[]>;

View File

@ -20,6 +20,7 @@ import {
import {
IGroup,
IUnleashStores,
IUser,
SYSTEM_USER,
SYSTEM_USER_ID,
} from '../../../lib/types';
@ -34,8 +35,8 @@ let eventService: EventService;
let environmentService: EnvironmentService;
let featureToggleService: FeatureToggleService;
let user: User; // many methods in this test use User instead of IUser
let opsUser: IUser;
let group: IGroup;
const TEST_USER_ID = -9999;
const isProjectUser = async (
userId: number,
@ -59,6 +60,11 @@ beforeAll(async () => {
name: 'aTestGroup',
description: '',
});
opsUser = await stores.userStore.insert({
name: 'Test user',
email: 'test@example.com',
});
await stores.accessStore.addUserToRole(opsUser.id, 1, '');
const config = createTestConfig({
getLogger,
});
@ -70,6 +76,9 @@ beforeAll(async () => {
environmentService = new EnvironmentService(stores, config, eventService);
projectService = createProjectService(db.rawDatabase, config);
});
beforeEach(async () => {
await stores.accessStore.addUserToRole(opsUser.id, 1, '');
});
afterAll(async () => {
await db.destroy();
@ -379,6 +388,241 @@ test('should add a member user to the project', async () => {
]);
});
describe('Managing Project access', () => {
test('Admin users should be allowed to add any project role', async () => {
const project = {
id: 'admin-project-admin',
name: 'admin',
description: '',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user);
const customRole = await stores.roleStore.create({
name: 'my_custom_role_admin_user',
roleType: 'custom',
description:
'Used to prove that you can assign a role when you are admin',
});
const projectUserAdmin = await stores.userStore.insert({
name: 'Some project user',
email: 'user_admin@example.com',
});
const ownerRole = await stores.roleStore.getRoleByName(RoleName.OWNER);
expect(
projectService.addAccess(
project.id,
[customRole.id, ownerRole.id],
[],
[projectUserAdmin.id],
opsUser.username,
opsUser.id,
),
).resolves.not.toThrow();
});
test('Users with project owner should be allowed to add any project role', async () => {
const project = {
id: 'project-owner',
name: 'Owner',
description: '',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user);
const projectAdmin = await stores.userStore.insert({
name: 'Some project admin',
email: 'admin@example.com',
});
const projectCustomer = await stores.userStore.insert({
name: 'Some project customer',
email: 'customer@example.com',
});
const ownerRole = await stores.roleStore.getRoleByName(RoleName.OWNER);
await accessService.addUserToRole(
projectAdmin.id,
ownerRole.id,
project.id,
);
const customRole = await stores.roleStore.create({
name: 'my_custom_role',
roleType: 'custom',
description:
'Used to prove that you can assign a role the project owner does not have',
});
expect(
projectService.addAccess(
project.id,
[customRole.id],
[],
[projectCustomer.id],
projectAdmin.username,
projectAdmin.id,
),
).resolves;
});
test('Users with project role should only be allowed to grant same role to others', async () => {
const project = {
id: 'project_role',
name: 'custom_role',
description: '',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user);
const projectUser = await stores.userStore.insert({
name: 'Some project user',
email: 'user@example.com',
});
const secondUser = await stores.userStore.insert({
name: 'Some other user',
email: 'otheruser@example.com',
});
const customRole = await stores.roleStore.create({
name: 'my_custom_role_project_role',
roleType: 'custom',
description:
'Used to prove that you can assign a role the project owner does not have',
});
await accessService.addUserToRole(
projectUser.id,
customRole.id,
project.id,
);
const ownerRole = await stores.roleStore.getRoleByName(RoleName.OWNER);
expect(
projectService.addAccess(
project.id,
[customRole.id],
[],
[secondUser.id],
projectUser.username,
projectUser.id,
),
).resolves.not.toThrow();
expect(
projectService.addAccess(
project.id,
[ownerRole.id],
[],
[secondUser.id],
projectUser.username,
projectUser.id,
),
).rejects.toThrow();
});
test('Users that are members of a group with project role should only be allowed to grant same role to others', async () => {
const project = {
id: 'project_group_role',
name: 'custom_role',
description: '',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user);
const projectUser = await stores.userStore.insert({
name: 'Some project user',
email: 'user_with_group_membership@example.com',
});
const group = await stores.groupStore.create({
name: 'custom_group_for_role_access',
});
await stores.groupStore.addUsersToGroup(
group.id,
[{ user: { id: projectUser.id } }],
opsUser.username,
);
const secondUser = await stores.userStore.insert({
name: 'Some other user',
email: 'otheruser_from_group_members@example.com',
});
const customRole = await stores.roleStore.create({
name: 'my_custom_role_from_group_members',
roleType: 'custom',
description:
'Used to prove that you can assign a role via a group membership',
});
await accessService.addGroupToRole(
group.id,
customRole.id,
opsUser.username,
project.id,
);
const ownerRole = await stores.roleStore.getRoleByName(RoleName.OWNER);
const otherGroup = await stores.groupStore.create({
name: 'custom_group_to_receive_new_access',
});
expect(
projectService.addAccess(
project.id,
[customRole.id],
[],
[secondUser.id],
projectUser.username,
projectUser.id,
),
).resolves.not.toThrow();
expect(
projectService.addAccess(
project.id,
[customRole.id],
[otherGroup.id],
[],
projectUser.username,
projectUser.id,
),
).resolves.not.toThrow();
expect(
projectService.addAccess(
project.id,
[ownerRole.id],
[],
[secondUser.id],
projectUser.username,
projectUser.id,
),
).rejects.toThrow();
});
test('Users can assign roles they have to a group', async () => {
const project = {
id: 'user_assign_to_group',
name: 'user_assign_to_group',
description: '',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user);
const projectUser = await stores.userStore.insert({
name: 'Some project user',
email: 'assign_role_to_group@example.com',
});
const secondGroup = await stores.groupStore.create({
name: 'custom_group_awaiting_new_role',
});
const customRole = await stores.roleStore.create({
name: 'role_assigned_to_group',
roleType: 'custom',
description:
'Used to prove that you can assign a role via a group membership',
});
await accessService.addUserToRole(
projectUser.id,
customRole.id,
project.id,
);
expect(
projectService.addAccess(
project.id,
[customRole.id],
[secondGroup.id],
[],
projectUser.username,
projectUser.id,
),
).resolves.not.toThrow();
});
});
test('should add admin users to the project', async () => {
const project = {
id: 'add-admin-users',
@ -491,7 +735,7 @@ test('should remove user from the project', async () => {
memberRole.id,
projectMember1.id,
'test',
TEST_USER_ID,
opsUser.id,
);
const { users } = await projectService.getAccessToProject(project.id);
@ -516,7 +760,7 @@ test('should not change project if feature toggle project does not match current
project.id,
toggle,
user.email,
TEST_USER_ID,
opsUser.id,
);
try {
@ -548,7 +792,7 @@ test('should return 404 if no project is found with the project id', async () =>
project.id,
toggle,
user.email,
TEST_USER_ID,
opsUser.id,
);
try {
@ -592,7 +836,7 @@ test('should fail if user is not authorized', async () => {
project.id,
toggle,
user.email,
TEST_USER_ID,
opsUser.id,
);
try {
@ -629,7 +873,7 @@ test('should change project when checks pass', async () => {
projectA.id,
toggle,
user.email,
TEST_USER_ID,
opsUser.id,
);
await projectService.changeProject(
projectB.id,
@ -664,7 +908,7 @@ test('changing project should emit event even if user does not have a username s
projectA.id,
toggle,
user.email,
TEST_USER_ID,
opsUser.id,
);
const eventsBeforeChange = await stores.eventStore.getEvents();
await projectService.changeProject(
@ -699,14 +943,14 @@ test('should require equal project environments to move features', async () => {
projectA.id,
toggle,
user.email,
TEST_USER_ID,
opsUser.id,
);
await stores.environmentStore.create(environment);
await environmentService.addEnvironmentToProject(
environment.name,
projectB.id,
'test',
TEST_USER_ID,
opsUser.id,
);
await expect(() =>
@ -931,7 +1175,7 @@ test('should change a users role in the project', async () => {
member.id,
projectUser.id,
'test',
TEST_USER_ID,
opsUser.id,
);
await projectService.addUser(
project.id,
@ -979,7 +1223,7 @@ test('should update role for user on project', async () => {
ownerRole.id,
projectMember1.id,
'test',
TEST_USER_ID,
opsUser.id,
);
const { users } = await projectService.getAccessToProject(project.id);
@ -1024,7 +1268,7 @@ test('should able to assign role without existing members', async () => {
testRole.id,
projectMember1.id,
'test',
TEST_USER_ID,
opsUser.id,
);
const { users } = await projectService.getAccessToProject(project.id);
@ -1055,7 +1299,7 @@ describe('ensure project has at least one owner', () => {
ownerRole.id,
user.id,
'test',
TEST_USER_ID,
opsUser.id,
);
}).rejects.toThrowError(
new Error('A project must have at least one owner'),
@ -1066,7 +1310,7 @@ describe('ensure project has at least one owner', () => {
project.id,
user.id,
'test',
TEST_USER_ID,
opsUser.id,
);
}).rejects.toThrowError(
new Error('A project must have at least one owner'),
@ -1098,7 +1342,7 @@ describe('ensure project has at least one owner', () => {
[],
[memberUser.id],
'test',
TEST_USER_ID,
opsUser.id,
);
const usersBefore = await projectService.getProjectUsers(project.id);
@ -1106,7 +1350,7 @@ describe('ensure project has at least one owner', () => {
project.id,
memberUser.id,
'test',
TEST_USER_ID,
opsUser.id,
);
const usersAfter = await projectService.getProjectUsers(project.id);
expect(usersBefore).toHaveLength(2);
@ -1145,7 +1389,7 @@ describe('ensure project has at least one owner', () => {
memberRole.id,
user.id,
'test',
TEST_USER_ID,
opsUser.id,
);
}).rejects.toThrowError(
new Error('A project must have at least one owner'),
@ -1157,7 +1401,7 @@ describe('ensure project has at least one owner', () => {
user.id,
[memberRole.id],
'test',
TEST_USER_ID,
opsUser.id,
);
}).rejects.toThrowError(
new Error('A project must have at least one owner'),
@ -1182,7 +1426,7 @@ describe('ensure project has at least one owner', () => {
ownerRole.id,
group.id,
'test',
TEST_USER_ID,
opsUser.id,
);
// this should be fine, leaving the group as the only owner
@ -1192,7 +1436,7 @@ describe('ensure project has at least one owner', () => {
ownerRole.id,
user.id,
'test',
TEST_USER_ID,
opsUser.id,
);
return {
@ -1213,7 +1457,7 @@ describe('ensure project has at least one owner', () => {
ownerRole.id,
group.id,
'test',
TEST_USER_ID,
opsUser.id,
);
}).rejects.toThrowError(
new Error('A project must have at least one owner'),
@ -1224,7 +1468,7 @@ describe('ensure project has at least one owner', () => {
project.id,
group.id,
'test',
TEST_USER_ID,
opsUser.id,
);
}).rejects.toThrowError(
new Error('A project must have at least one owner'),
@ -1245,7 +1489,7 @@ describe('ensure project has at least one owner', () => {
memberRole.id,
group.id,
'test',
TEST_USER_ID,
opsUser.id,
);
}).rejects.toThrowError(
new Error('A project must have at least one owner'),
@ -1257,7 +1501,7 @@ describe('ensure project has at least one owner', () => {
group.id,
[memberRole.id],
'test',
TEST_USER_ID,
opsUser.id,
);
}).rejects.toThrowError(
new Error('A project must have at least one owner'),
@ -1295,6 +1539,11 @@ test('Should allow bulk update of group permissions', async () => {
],
createdByUserId: SYSTEM_USER_ID,
});
await stores.accessStore.addUserToRole(
opsUser.id,
createFeatureRole.id,
project.id,
);
await projectService.addAccess(
project.id,
@ -1302,7 +1551,7 @@ test('Should allow bulk update of group permissions', async () => {
[group1.id],
[user1.id],
'some-admin-user',
TEST_USER_ID,
opsUser.id,
);
});
@ -1331,7 +1580,7 @@ test('Should bulk update of only users', async () => {
[],
[user1.id],
'some-admin-user',
TEST_USER_ID,
opsUser.id,
);
});
@ -1368,7 +1617,7 @@ test('Should allow bulk update of only groups', async () => {
[group1.id],
[],
'some-admin-user',
TEST_USER_ID,
opsUser.id,
);
});
@ -1430,7 +1679,7 @@ test('Should allow permutations of roles, groups and users when adding a new acc
[group1.id, group2.id],
[user1.id, user2.id],
'some-admin-user',
TEST_USER_ID,
opsUser.id,
);
const { users, groups } = await projectService.getAccessToProject(
@ -1534,7 +1783,7 @@ test('should calculate average time to production', async () => {
project.id,
toggle,
user.email,
TEST_USER_ID,
opsUser.id,
);
}),
);
@ -1548,7 +1797,7 @@ test('should calculate average time to production', async () => {
featureName: toggle.name,
environment: 'default',
createdBy: 'Fredrik',
createdByUserId: TEST_USER_ID,
createdByUserId: opsUser.id,
}),
);
}),
@ -1583,7 +1832,7 @@ test('should calculate average time to production ignoring some items', async ()
featureName,
environment: 'default',
createdBy: 'Fredrik',
createdByUserId: TEST_USER_ID,
createdByUserId: opsUser.id,
tags: [],
});
@ -1605,7 +1854,7 @@ test('should calculate average time to production ignoring some items', async ()
project.id,
toggle,
user.email,
TEST_USER_ID,
opsUser.id,
);
await updateFeature(toggle.name, {
created_at: subDays(new Date(), 20),
@ -1625,7 +1874,7 @@ test('should calculate average time to production ignoring some items', async ()
project.id,
devToggle,
user.email,
TEST_USER_ID,
opsUser.id,
);
await eventService.storeEvent(
new FeatureEnvironmentEvent({
@ -1640,7 +1889,7 @@ test('should calculate average time to production ignoring some items', async ()
'default',
otherProjectToggle,
user.email,
TEST_USER_ID,
opsUser.id,
);
await eventService.storeEvent(
new FeatureEnvironmentEvent(makeEvent(otherProjectToggle.name)),
@ -1652,7 +1901,7 @@ test('should calculate average time to production ignoring some items', async ()
project.id,
nonReleaseToggle,
user.email,
TEST_USER_ID,
opsUser.id,
);
await eventService.storeEvent(
new FeatureEnvironmentEvent(makeEvent(nonReleaseToggle.name)),
@ -1664,7 +1913,7 @@ test('should calculate average time to production ignoring some items', async ()
project.id,
previouslyDeleteToggle,
user.email,
TEST_USER_ID,
opsUser.id,
);
await eventService.storeEvent(
new FeatureEnvironmentEvent(makeEvent(previouslyDeleteToggle.name)),
@ -1701,7 +1950,7 @@ test('should get correct amount of features created in current and past window',
project.id,
toggle,
user.email,
TEST_USER_ID,
opsUser.id,
);
}),
);
@ -1739,7 +1988,7 @@ test('should get correct amount of features archived in current and past window'
project.id,
toggle,
user.email,
TEST_USER_ID,
opsUser.id,
);
}),
);
@ -1832,7 +2081,7 @@ test('should return average time to production per toggle', async () => {
project.id,
toggle,
user.email,
TEST_USER_ID,
opsUser.id,
);
}),
);
@ -1846,7 +2095,7 @@ test('should return average time to production per toggle', async () => {
featureName: toggle.name,
environment: 'default',
createdBy: 'Fredrik',
createdByUserId: TEST_USER_ID,
createdByUserId: opsUser.id,
}),
);
}),
@ -1902,7 +2151,7 @@ test('should return average time to production per toggle for a specific project
project1.id,
toggle,
user.email,
TEST_USER_ID,
opsUser.id,
);
}),
);
@ -1913,7 +2162,7 @@ test('should return average time to production per toggle for a specific project
project2.id,
toggle,
user.email,
TEST_USER_ID,
opsUser.id,
);
}),
);
@ -1927,7 +2176,7 @@ test('should return average time to production per toggle for a specific project
featureName: toggle.name,
environment: 'default',
createdBy: 'Fredrik',
createdByUserId: TEST_USER_ID,
createdByUserId: opsUser.id,
}),
);
}),
@ -1942,7 +2191,7 @@ test('should return average time to production per toggle for a specific project
featureName: toggle.name,
environment: 'default',
createdBy: 'Fredrik',
createdByUserId: TEST_USER_ID,
createdByUserId: opsUser.id,
}),
);
}),
@ -1993,7 +2242,7 @@ test('should return average time to production per toggle and include archived t
project1.id,
toggle,
user.email,
TEST_USER_ID,
opsUser.id,
);
}),
);
@ -2007,7 +2256,7 @@ test('should return average time to production per toggle and include archived t
featureName: toggle.name,
environment: 'default',
createdBy: 'Fredrik',
createdByUserId: TEST_USER_ID,
createdByUserId: opsUser.id,
}),
);
}),

View File

@ -36,6 +36,13 @@ class AccessStoreMock implements IAccessStore {
throw new Error('Method not implemented.');
}
getAllProjectRolesForUser(
userId: number,
project: string,
): Promise<IRoleWithProject[]> {
throw new Error('Method not implemented.');
}
addRoleAccessToProject(
users: IAccessInfo[],
groups: IAccessInfo[],