From e9d9db17fe7273d5131d0f2206fd12a3b3fa0652 Mon Sep 17 00:00:00 2001 From: Christopher Kolstad Date: Tue, 20 Feb 2024 15:56:53 +0100 Subject: [PATCH] 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. --- .../executiveDashboard/ExecutiveDashboard.tsx | 4 +- .../ProjectAccessAssign.tsx | 13 +- .../ProjectAccessCreate.tsx | 3 + .../ProjectAccessEditGroup.tsx | 3 + .../ProjectAccessEditUser.tsx | 4 +- .../getUserProjectRolesFetcher.ts | 20 + .../useUserProjectRoles.ts | 26 ++ frontend/src/interfaces/userProjectRoles.ts | 12 + src/lib/db/access-store.ts | 36 ++ .../features/metrics/instance/metrics.test.ts | 13 - .../features/project/project-controller.ts | 23 +- src/lib/features/project/project-service.ts | 110 ++++-- src/lib/openapi/index.ts | 70 ++-- src/lib/openapi/spec/role-schema.ts | 6 + src/lib/openapi/spec/roles-schema.ts | 32 ++ src/lib/routes/admin-api/user/user.ts | 54 +++ src/lib/services/access-service.ts | 14 + src/lib/types/stores/access-store.ts | 5 + .../e2e/services/project-service.e2e.test.ts | 341 +++++++++++++++--- src/test/fixtures/fake-access-store.ts | 7 + 20 files changed, 652 insertions(+), 144 deletions(-) create mode 100644 frontend/src/hooks/api/getters/useUserProjectRoles/getUserProjectRolesFetcher.ts create mode 100644 frontend/src/hooks/api/getters/useUserProjectRoles/useUserProjectRoles.ts create mode 100644 frontend/src/interfaces/userProjectRoles.ts create mode 100644 src/lib/openapi/spec/roles-schema.ts diff --git a/frontend/src/component/executiveDashboard/ExecutiveDashboard.tsx b/frontend/src/component/executiveDashboard/ExecutiveDashboard.tsx index bc9c3723bb..25c9e682f4 100644 --- a/frontend/src/component/executiveDashboard/ExecutiveDashboard.tsx +++ b/frontend/src/component/executiveDashboard/ExecutiveDashboard.tsx @@ -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, diff --git a/frontend/src/component/project/ProjectAccess/ProjectAccessAssign/ProjectAccessAssign.tsx b/frontend/src/component/project/ProjectAccess/ProjectAccessAssign/ProjectAccessAssign.tsx index 4964850b8d..586ff82ee5 100644 --- a/frontend/src/component/project/ProjectAccess/ProjectAccessAssign/ProjectAccessAssign.tsx +++ b/frontend/src/component/project/ProjectAccess/ProjectAccessAssign/ProjectAccessAssign.tsx @@ -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 ( diff --git a/frontend/src/component/project/ProjectAccess/ProjectAccessCreate/ProjectAccessCreate.tsx b/frontend/src/component/project/ProjectAccess/ProjectAccessCreate/ProjectAccessCreate.tsx index 90b1a86f8b..89c0454126 100644 --- a/frontend/src/component/project/ProjectAccess/ProjectAccessCreate/ProjectAccessCreate.tsx +++ b/frontend/src/component/project/ProjectAccess/ProjectAccessCreate/ProjectAccessCreate.tsx @@ -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} /> ); }; diff --git a/frontend/src/component/project/ProjectAccess/ProjectAccessEditGroup/ProjectAccessEditGroup.tsx b/frontend/src/component/project/ProjectAccess/ProjectAccessEditGroup/ProjectAccessEditGroup.tsx index 72426a4b23..4f69b31b0c 100644 --- a/frontend/src/component/project/ProjectAccess/ProjectAccessEditGroup/ProjectAccessEditGroup.tsx +++ b/frontend/src/component/project/ProjectAccess/ProjectAccessEditGroup/ProjectAccessEditGroup.tsx @@ -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} /> ); }; diff --git a/frontend/src/component/project/ProjectAccess/ProjectAccessEditUser/ProjectAccessEditUser.tsx b/frontend/src/component/project/ProjectAccess/ProjectAccessEditUser/ProjectAccessEditUser.tsx index 7577e336b5..82f3fc99ae 100644 --- a/frontend/src/component/project/ProjectAccess/ProjectAccessEditUser/ProjectAccessEditUser.tsx +++ b/frontend/src/component/project/ProjectAccess/ProjectAccessEditUser/ProjectAccessEditUser.tsx @@ -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} /> ); }; diff --git a/frontend/src/hooks/api/getters/useUserProjectRoles/getUserProjectRolesFetcher.ts b/frontend/src/hooks/api/getters/useUserProjectRoles/getUserProjectRolesFetcher.ts new file mode 100644 index 0000000000..be690fc032 --- /dev/null +++ b/frontend/src/hooks/api/getters/useUserProjectRoles/getUserProjectRolesFetcher.ts @@ -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, + }; +}; diff --git a/frontend/src/hooks/api/getters/useUserProjectRoles/useUserProjectRoles.ts b/frontend/src/hooks/api/getters/useUserProjectRoles/useUserProjectRoles.ts new file mode 100644 index 0000000000..f0115cfeae --- /dev/null +++ b/frontend/src/hooks/api/getters/useUserProjectRoles/useUserProjectRoles.ts @@ -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( + KEY, + fetcher, + options, + ); + + const refetch = useCallback(() => { + mutate(); + }, [mutate]); + return { + roles: data?.roles || [], + loading: !error && !data, + error, + refetch, + }; +}; diff --git a/frontend/src/interfaces/userProjectRoles.ts b/frontend/src/interfaces/userProjectRoles.ts new file mode 100644 index 0000000000..bb58684b33 --- /dev/null +++ b/frontend/src/interfaces/userProjectRoles.ts @@ -0,0 +1,12 @@ +export interface IUserProjectRole { + id: number; + name: string; + type: string; + project?: string; + description?: string; +} + +export interface IUserProjectRoles { + version: number; + roles: IUserProjectRole[]; +} diff --git a/src/lib/db/access-store.ts b/src/lib/db/access-store.ts index bdc4e70e76..c9ee8f950f 100644 --- a/src/lib/db/access-store.ts +++ b/src/lib/db/access-store.ts @@ -403,6 +403,42 @@ export class AccessStore implements IAccessStore { .where('ru.user_id', '=', userId); } + async getAllProjectRolesForUser( + userId: number, + project: string, + ): Promise { + const stopTimer = this.timer('get_all_project_roles_for_user'); + const roles = await this.db + .select(['id', 'name', 'type', 'project', 'description']) + .from(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(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 { return this.db .select(['id', 'name', 'type', 'description']) diff --git a/src/lib/features/metrics/instance/metrics.test.ts b/src/lib/features/metrics/instance/metrics.test.ts index ad86000788..ea6802f5ef 100644 --- a/src/lib/features/metrics/instance/metrics.test.ts +++ b/src/lib/features/metrics/instance/metrics.test.ts @@ -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); diff --git a/src/lib/features/project/project-controller.ts b/src/lib/features/project/project-controller.ts index af06abf315..8c48b68fd7 100644 --- a/src/lib/features/project/project-controller.ts +++ b/src/lib/features/project/project-controller.ts @@ -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.', diff --git a/src/lib/features/project/project-service.ts b/src/lib/features/project/project-service.ts index 27964e55e0..812f01fb50 100644 --- a/src/lib/features/project/project-service.ts +++ b/src/lib/features/project/project-service.ts @@ -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 { + 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 { - 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( diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index 8e90e6758f..fa12f347bc 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -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, diff --git a/src/lib/openapi/spec/role-schema.ts b/src/lib/openapi/spec/role-schema.ts index f4173e1235..a8b7854612 100644 --- a/src/lib/openapi/spec/role-schema.ts +++ b/src/lib/openapi/spec/role-schema.ts @@ -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; diff --git a/src/lib/openapi/spec/roles-schema.ts b/src/lib/openapi/spec/roles-schema.ts new file mode 100644 index 0000000000..719c180a09 --- /dev/null +++ b/src/lib/openapi/spec/roles-schema.ts @@ -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; diff --git a/src/lib/routes/admin-api/user/user.ts b/src/lib/routes/admin-api/user/user.ts index 1cc3a0cfb9..61899d662e 100644 --- a/src/lib/routes/admin-api/user/user.ts +++ b/src/lib/routes/admin-api/user/user.ts @@ -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, + ): Promise { + 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): Promise { res.setHeader('cache-control', 'no-store'); const { user } = req; diff --git a/src/lib/services/access-service.ts b/src/lib/services/access-service.ts index bce89f50c7..8f33a85e3e 100644 --- a/src/lib/services/access-service.ts +++ b/src/lib/services/access-service.ts @@ -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 { + return this.store.getAllProjectRolesForUser(userId, project); + } /** * Check a user against all available permissions. * Provided a project, project permissions will be checked against that project. diff --git a/src/lib/types/stores/access-store.ts b/src/lib/types/stores/access-store.ts index e0a081f1bd..bedb610272 100644 --- a/src/lib/types/stores/access-store.ts +++ b/src/lib/types/stores/access-store.ts @@ -87,6 +87,11 @@ export interface IAccessStore extends Store { projectId?: string, ): Promise; + getAllProjectRolesForUser( + userId: number, + project: string, + ): Promise; + getProjectUsers(projectId?: string): Promise; getUserIdsForRole(roleId: number, projectId?: string): Promise; diff --git a/src/test/e2e/services/project-service.e2e.test.ts b/src/test/e2e/services/project-service.e2e.test.ts index a8ac148ec3..c42b4ae4d9 100644 --- a/src/test/e2e/services/project-service.e2e.test.ts +++ b/src/test/e2e/services/project-service.e2e.test.ts @@ -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, }), ); }), diff --git a/src/test/fixtures/fake-access-store.ts b/src/test/fixtures/fake-access-store.ts index c0000a6c83..08876c923f 100644 --- a/src/test/fixtures/fake-access-store.ts +++ b/src/test/fixtures/fake-access-store.ts @@ -36,6 +36,13 @@ class AccessStoreMock implements IAccessStore { throw new Error('Method not implemented.'); } + getAllProjectRolesForUser( + userId: number, + project: string, + ): Promise { + throw new Error('Method not implemented.'); + } + addRoleAccessToProject( users: IAccessInfo[], groups: IAccessInfo[],