diff --git a/frontend/src/component/project/ProjectAccess/ProjectAccessTable/ProjectAccessTable.tsx b/frontend/src/component/project/ProjectAccess/ProjectAccessTable/ProjectAccessTable.tsx index fc5d16d0bf..d953a0bad2 100644 --- a/frontend/src/component/project/ProjectAccess/ProjectAccessTable/ProjectAccessTable.tsx +++ b/frontend/src/component/project/ProjectAccess/ProjectAccessTable/ProjectAccessTable.tsx @@ -232,12 +232,8 @@ export const ProjectAccessTable: VFC = () => { ? 'group' : 'user' }/${row.entity.id}`} - disabled={access?.rows.length === 1} tooltipProps={{ - title: - access?.rows.length === 1 - ? 'Cannot edit access. A project must have at least one owner' - : 'Edit access', + title: 'Edit access', }} > @@ -253,12 +249,8 @@ export const ProjectAccessTable: VFC = () => { setSelectedRow(row); setRemoveOpen(true); }} - disabled={access?.rows.length === 1} tooltipProps={{ - title: - access?.rows.length === 1 - ? 'Cannot remove access. A project must have at least one owner' - : 'Remove access', + title: 'Remove access', }} > diff --git a/src/lib/error/index.ts b/src/lib/error/index.ts index 6400ed93f2..ab80ac3d55 100644 --- a/src/lib/error/index.ts +++ b/src/lib/error/index.ts @@ -10,7 +10,6 @@ import PermissionError from './permission-error'; import { OperationDeniedError } from './operation-denied-error'; import UserTokenError from './used-token-error'; import RoleInUseError from './role-in-use-error'; -import ProjectWithoutOwnerError from './project-without-owner-error'; import PasswordUndefinedError from './password-undefined'; import PasswordMismatchError from './password-mismatch'; import PatternError from './pattern-error'; @@ -32,7 +31,6 @@ export { OperationDeniedError, UserTokenError, RoleInUseError, - ProjectWithoutOwnerError, PasswordUndefinedError, PatternError, PasswordMismatchError, diff --git a/src/lib/error/project-without-owner-error.ts b/src/lib/error/project-without-owner-error.ts deleted file mode 100644 index ad2bf6d19c..0000000000 --- a/src/lib/error/project-without-owner-error.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { type ApiErrorSchema, UnleashError } from './unleash-error'; - -export default class ProjectWithoutOwnerError extends UnleashError { - statusCode = 409; - - constructor() { - super('A project must have at least one owner'); - } - - toJSON(): ApiErrorSchema { - return { - ...super.toJSON(), - details: [ - { - message: this.message, - validationErrors: [], - }, - ], - }; - } -} diff --git a/src/lib/error/unleash-error.test.ts b/src/lib/error/unleash-error.test.ts index 4b6d328b4b..f0323621d3 100644 --- a/src/lib/error/unleash-error.test.ts +++ b/src/lib/error/unleash-error.test.ts @@ -10,7 +10,6 @@ import PermissionError from './permission-error'; import OwaspValidationError from './owasp-validation-error'; import IncompatibleProjectError from './incompatible-project-error'; import PasswordUndefinedError from './password-undefined'; -import ProjectWithoutOwnerError from './project-without-owner-error'; import NotFoundError from './notfound-error'; import { validateString } from '../util/validators/constraint-types'; import { fromLegacyError } from './from-legacy-error'; @@ -671,20 +670,6 @@ describe('Error serialization special cases', () => { ], }); }); - - it('ProjectWithoutOwnerError: adds `validationErrors: []` to the `details` list', () => { - const error = new ProjectWithoutOwnerError(); - const json = error.toJSON(); - - expect(json).toMatchObject({ - details: [ - { - validationErrors: [], - message: json.message, - }, - ], - }); - }); }); describe('Stack traces', () => { diff --git a/src/lib/features/project/project-service.e2e.test.ts b/src/lib/features/project/project-service.e2e.test.ts index 47ea9bc1d3..15656830dc 100644 --- a/src/lib/features/project/project-service.e2e.test.ts +++ b/src/lib/features/project/project-service.e2e.test.ts @@ -1579,225 +1579,6 @@ test('should able to assign role without existing members', async () => { expect(testUsers).toHaveLength(1); }); -describe('ensure project has at least one owner', () => { - test('should not remove user from the project', async () => { - const project = { - id: 'remove-users-not-allowed', - name: 'New project', - description: 'Blah', - mode: 'open' as const, - defaultStickiness: 'clientId', - }; - await projectService.createProject(project, user, auditUser); - - const roles = await stores.roleStore.getRolesForProject(project.id); - const ownerRole = roles.find((r) => r.name === RoleName.OWNER)!; - - await expect(async () => { - await projectService.removeUser( - project.id, - ownerRole.id, - user.id, - auditUser, - ); - }).rejects.toThrowError( - new Error('A project must have at least one owner'), - ); - - await expect(async () => { - await projectService.removeUserAccess( - project.id, - user.id, - auditUser, - ); - }).rejects.toThrowError( - new Error('A project must have at least one owner'), - ); - }); - - test('should be able to remove member user from the project when another is owner', async () => { - const project = { - id: 'remove-users-members-allowed', - name: 'New project', - description: 'Blah', - mode: 'open' as const, - defaultStickiness: 'clientId', - }; - await projectService.createProject(project, user, auditUser); - - const memberRole = await stores.roleStore.getRoleByName( - RoleName.MEMBER, - ); - - const memberUser = await stores.userStore.insert({ - name: 'Some Name', - email: 'member@getunleash.io', - }); - - await projectService.addAccess( - project.id, - [memberRole.id], - [], - [memberUser.id], - auditUser, - ); - - const usersBefore = await projectService.getProjectUsers(project.id); - await projectService.removeUserAccess( - project.id, - memberUser.id, - auditUser, - ); - const usersAfter = await projectService.getProjectUsers(project.id); - expect(usersBefore).toHaveLength(2); - expect(usersAfter).toHaveLength(1); - }); - - test('should not update role for user on project when she is the owner', async () => { - const project = { - id: 'update-users-not-allowed', - name: 'New project', - description: 'Blah', - mode: 'open' as const, - defaultStickiness: 'clientId', - }; - await projectService.createProject(project, user, auditUser); - - const projectMember1 = await stores.userStore.insert({ - name: 'Some Member', - email: 'update991@getunleash.io', - }); - - const memberRole = await stores.roleStore.getRoleByName( - RoleName.MEMBER, - ); - - await projectService.addAccess( - project.id, - [memberRole.id], - [], // no groups - [projectMember1.id], - auditUser, - ); - - await expect(async () => { - await projectService.changeRole( - project.id, - memberRole.id, - user.id, - auditUser, - ); - }).rejects.toThrowError( - new Error('A project must have at least one owner'), - ); - - await expect(async () => { - await projectService.setRolesForUser( - project.id, - user.id, - [memberRole.id], - auditUser, - ); - }).rejects.toThrowError( - new Error('A project must have at least one owner'), - ); - }); - - async function projectWithGroupOwner(projectId: string) { - const project = { - id: projectId, - name: 'New project', - description: 'Blah', - mode: 'open' as const, - defaultStickiness: 'clientId', - }; - await projectService.createProject(project, user, auditUser); - - const roles = await stores.roleStore.getRolesForProject(project.id); - const ownerRole = roles.find((r) => r.name === RoleName.OWNER)!; - - await projectService.addGroup( - project.id, - ownerRole.id, - group.id, - auditUser, - ); - - // this should be fine, leaving the group as the only owner - // note group has zero members, but it still acts as an owner - await projectService.removeUser( - project.id, - ownerRole.id, - user.id, - auditUser, - ); - - return { - project, - group, - ownerRole, - }; - } - - test('should not remove group from the project', async () => { - const { project, group, ownerRole } = await projectWithGroupOwner( - 'remove-group-not-allowed', - ); - - await expect(async () => { - await projectService.removeGroup( - project.id, - ownerRole.id, - group.id, - auditUser, - ); - }).rejects.toThrowError( - new Error('A project must have at least one owner'), - ); - - await expect(async () => { - await projectService.removeGroupAccess( - project.id, - group.id, - auditUser, - ); - }).rejects.toThrowError( - new Error('A project must have at least one owner'), - ); - }); - - test('should not update role for group on project when she is the owner', async () => { - const { project, group } = await projectWithGroupOwner( - 'update-group-not-allowed', - ); - const memberRole = await stores.roleStore.getRoleByName( - RoleName.MEMBER, - ); - - await expect(async () => { - await projectService.changeGroupRole( - project.id, - memberRole.id, - group.id, - auditUser, - ); - }).rejects.toThrowError( - new Error('A project must have at least one owner'), - ); - - await expect(async () => { - await projectService.setRolesForGroup( - project.id, - group.id, - [memberRole.id], - auditUser, - ); - }).rejects.toThrowError( - new Error('A project must have at least one owner'), - ); - }); -}); - test('Should allow bulk update of group permissions', async () => { const project = { id: 'bulk-update-project', diff --git a/src/lib/features/project/project-service.test.ts b/src/lib/features/project/project-service.test.ts index 34d9dc22e1..4ca7f09b58 100644 --- a/src/lib/features/project/project-service.test.ts +++ b/src/lib/features/project/project-service.test.ts @@ -2,7 +2,6 @@ import { createTestConfig } from '../../../test/config/test-config'; import { BadDataError } from '../../error'; import { type IBaseEvent, RoleName, TEST_AUDIT_USER } from '../../types'; import { createFakeProjectService } from './createProjectService'; -import ProjectService from './project-service'; describe('enterprise extension: enable change requests', () => { const createService = () => { @@ -301,44 +300,4 @@ describe('enterprise extension: enable change requests', () => { ), ).resolves.toBeTruthy(); }); - - test('has at least one owner after deletion when group has the role and a project user for role exists', async () => { - const config = createTestConfig(); - const projectId = 'fake-project-id'; - const service = new ProjectService( - { - projectStore: {} as any, - projectOwnersReadModel: {} as any, - projectFlagCreatorsReadModel: {} as any, - eventStore: {} as any, - featureToggleStore: {} as any, - environmentStore: {} as any, - featureEnvironmentStore: {} as any, - accountStore: {} as any, - projectStatsStore: {} as any, - projectReadModel: {} as any, - onboardingReadModel: {} as any, - }, - config, - { - getProjectUsersForRole: async () => - Promise.resolve([{ id: 1 } as any]), - } as any, - {} as any, - { - getProjectGroups: async () => - Promise.resolve([{ roles: [2, 5] } as any]), - } as any, - {} as any, - {} as any, - {} as any, - {} as any, - ); - - await service.validateAtLeastOneOwner(projectId, { - id: 5, - name: 'Owner', - type: 'Owner', - }); - }); }); diff --git a/src/lib/features/project/project-service.ts b/src/lib/features/project/project-service.ts index d21f0096bc..8510c33438 100644 --- a/src/lib/features/project/project-service.ts +++ b/src/lib/features/project/project-service.ts @@ -62,7 +62,6 @@ import type { } from '../../types/stores/access-store'; import type FeatureToggleService from '../feature-toggle/feature-toggle-service'; import IncompatibleProjectError from '../../error/incompatible-project-error'; -import ProjectWithoutOwnerError from '../../error/project-without-owner-error'; import { arraysHaveSameItems } from '../../util'; import type { GroupService } from '../../services/group-service'; import type { IGroupRole } from '../../types/group'; @@ -665,8 +664,6 @@ export default class ProjectService { ): Promise { const role = await this.findProjectRole(projectId, roleId); - await this.validateAtLeastOneOwner(projectId, role); - await this.accessService.removeUserFromRole(userId, role.id, projectId); const user = await this.accountStore.get(userId); @@ -695,14 +692,6 @@ export default class ProjectService { userId, ); - const ownerRole = await this.accessService.getRoleByName( - RoleName.OWNER, - ); - - if (existingRoles.includes(ownerRole.id)) { - await this.validateAtLeastOneOwner(projectId, ownerRole); - } - await this.accessService.removeUserAccess(projectId, userId); await this.eventService.storeEvent( @@ -727,14 +716,6 @@ export default class ProjectService { groupId, ); - const ownerRole = await this.accessService.getRoleByName( - RoleName.OWNER, - ); - - if (existingRoles.includes(ownerRole.id)) { - await this.validateAtLeastOneOwner(projectId, ownerRole); - } - await this.accessService.removeGroupAccess(projectId, groupId); await this.eventService.storeEvent( @@ -804,8 +785,6 @@ export default class ProjectService { undefined, ); - await this.validateAtLeastOneOwner(projectId, role); - await this.accessService.removeGroupFromRole( group.id, role.id, @@ -967,15 +946,6 @@ export default class ProjectService { projectId, userId, ); - const ownerRole = await this.accessService.getRoleByName( - RoleName.OWNER, - ); - - const hasOwnerRole = includes(currentRoles, ownerRole); - const isRemovingOwnerRole = !includes(newRoles, ownerRole); - if (hasOwnerRole && isRemovingOwnerRole) { - await this.validateAtLeastOneOwner(projectId, ownerRole); - } const isAllowedToAssignRoles = await this.isAllowedToAddAccess( auditUser, projectId, @@ -1019,14 +989,6 @@ export default class ProjectService { groupId, ); - const ownerRole = await this.accessService.getRoleByName( - RoleName.OWNER, - ); - const hasOwnerRole = includes(currentRoles, ownerRole); - const isRemovingOwnerRole = !includes(newRoles, ownerRole); - if (hasOwnerRole && isRemovingOwnerRole) { - await this.validateAtLeastOneOwner(projectId, ownerRole); - } const isAllowedToAssignRoles = await this.isAllowedToAddAccess( auditUser, projectId, @@ -1088,25 +1050,6 @@ export default class ProjectService { return role; } - async validateAtLeastOneOwner( - projectId: string, - currentRole: IRoleDescriptor, - ): Promise { - if (currentRole.name === RoleName.OWNER) { - const users = await this.accessService.getProjectUsersForRole( - currentRole.id, - projectId, - ); - const groups = await this.groupService.getProjectGroups(projectId); - const roleGroups = groups.filter((g) => - g.roles?.includes(currentRole.id), - ); - if (users.length + roleGroups.length < 2) { - throw new ProjectWithoutOwnerError(); - } - } - } - /** @deprecated use projectInsightsService instead */ async getDoraMetrics(projectId: string): Promise { const activeFeatureFlags = ( @@ -1180,7 +1123,6 @@ export default class ProjectService { // Nothing to do.... return; } - await this.validateAtLeastOneOwner(projectId, currentRole); await this.accessService.updateUserProjectRole( userId, @@ -1233,7 +1175,6 @@ export default class ProjectService { // Nothing to do.... return; } - await this.validateAtLeastOneOwner(projectId, currentRole); await this.accessService.updateGroupProjectRole( userId,