From a43038279f98eae1e33ac773e9a9c3e45e276d5d Mon Sep 17 00:00:00 2001 From: shreddedbacon Date: Mon, 20 Nov 2023 09:30:29 +1100 Subject: [PATCH 1/2] feat: add new project and group importer for organizations --- services/api/src/models/group.ts | 2 +- services/api/src/resolvers.js | 4 + .../src/resources/organization/resolvers.ts | 224 ++++++++++++++++++ services/api/src/typeDefs.js | 25 +- 4 files changed, 249 insertions(+), 6 deletions(-) diff --git a/services/api/src/models/group.ts b/services/api/src/models/group.ts index b54feaafe5..5cb760ff3a 100644 --- a/services/api/src/models/group.ts +++ b/services/api/src/models/group.ts @@ -137,7 +137,7 @@ export const Group = (clients: { path: keycloakGroup.path, attributes: keycloakGroup.attributes, subGroups: keycloakGroup.subGroups, - organization: parseInt(attributeKVOrNull('lagoon-organization', keycloakGroup)), + organization: parseInt(attributeKVOrNull('lagoon-organization', keycloakGroup), 10) || null, // if it exists set it or null }) ); diff --git a/services/api/src/resolvers.js b/services/api/src/resolvers.js index 84d641d039..eaca8dd995 100644 --- a/services/api/src/resolvers.js +++ b/services/api/src/resolvers.js @@ -269,6 +269,8 @@ const { getNotificationsForOrganizationProjectId, getEnvironmentsByOrganizationId, removeUserFromOrganizationGroups, + checkBulkImportProjectsAndGroupsToOrganization, + bulkImportProjectsAndGroupsToOrganization } = require('./resources/organization/resolvers'); const { @@ -582,6 +584,7 @@ const resolvers = { getGroupProjectOrganizationAssociation, getProjectGroupOrganizationAssociation, getEnvVariablesByProjectEnvironmentName, + checkBulkImportProjectsAndGroupsToOrganization }, Mutation: { addProblem, @@ -714,6 +717,7 @@ const resolvers = { removeDeployTargetFromOrganization, updateEnvironmentDeployTarget, removeUserFromOrganizationGroups, + bulkImportProjectsAndGroupsToOrganization }, Subscription: { backupChanged: backupSubscriber, diff --git a/services/api/src/resources/organization/resolvers.ts b/services/api/src/resources/organization/resolvers.ts index d122556b14..10259d662c 100644 --- a/services/api/src/resources/organization/resolvers.ts +++ b/services/api/src/resources/organization/resolvers.ts @@ -1043,3 +1043,227 @@ export const deleteOrganization: ResolverFn = async ( }); return 'success'; }; + +const checkBulkProjectGroupAssociation = async (oid, pid, projectsToMove, groupsToMove, projectsInOtherOrgs, groupsInOtherOrgs, sqlClientPool, models, keycloakGroups) => { + const groupProjectIds = []; + const projectGroups = await models.GroupModel.loadGroupsByProjectIdFromGroups(pid, keycloakGroups); + // get all the groups the requested project is in + for (const group of projectGroups) { + // for each group the project is in, get the list of projects that are also in this group + if (R.prop('lagoon-projects', group.attributes)) { + const groupProjects = R.prop('lagoon-projects', group.attributes).toString().split(',') + for (const project of groupProjects) { + groupProjectIds.push({group: group.name, project: project}) + } + } + } + + // for all the projects in the first projects group, iterate through the projects and the groups attached + // to these projects and try to build out a map of all the groups and projects that are linked by the primary project + if (groupProjectIds.length > 0) { + for (const pGroup of groupProjectIds) { + const project = await projectHelpers(sqlClientPool).getProjectById(pGroup.project) + const projectGroups = await models.GroupModel.loadGroupsByProjectIdFromGroups(project.id, keycloakGroups); + // check if the project is already in the requested organization + if (project.organization != oid && project.organization == null) { + let alreadyAdded = false + for (const f of projectsToMove) { + if (f.id == project.id) { + alreadyAdded = true + } + } + if (!alreadyAdded) { + // if it isn't already in the requested organization, add it to the list of projects that should be moved + projectsToMove.push(project) + } + } else { + // if the project is in a completely different organization + if (project.organization != oid) { + let alreadyAdded = false + for (const f of projectsInOtherOrgs) { + if (f.id == project.id) { + alreadyAdded = true + } + } + if (!alreadyAdded) { + // add it to the lsit of projects that will cause this check to fail + projectsInOtherOrgs.push(project) + } + } + } + for (const group of projectGroups) { + // for every group that the project is in, check if the group is already in the requested organization + if (group.organization != oid && group.organization == null) { + let alreadyAdded = false + for (const f of groupsToMove) { + if (f.id == group.id) { + alreadyAdded = true + } + } + if (!alreadyAdded) { + // if it isn't already in the requested organization, add it to the list of groups that should be moved + groupsToMove.push(group) + } + } else { + // if the group is in a completely different organization + if (group.organization != oid) { + let alreadyAdded = false + for (const f of groupsInOtherOrgs) { + if (f.id == group.id) { + alreadyAdded = true + } + } + if (!alreadyAdded) { + // add it to the lsit of projects that will cause this check to fail + groupsInOtherOrgs.push(group) + } + } + } + } + } + } +} + +export const checkBulkImportProjectsAndGroupsToOrganization: ResolverFn = async ( + _root, + { input }, + { sqlClientPool, models, hasPermission, keycloakGroups } +) => { + let pid = input.project; + let oid = input.organization; + + // platform admin only as it potentially reveals information about projects/orgs/groups + await hasPermission('organization', 'add'); + + const projectsToMove = [] + const groupsToMove = [] + const projectsInOtherOrgs = [] + const groupsInOtherOrgs = [] + + // get all the groups the requested project is in + await checkBulkProjectGroupAssociation(oid, pid, projectsToMove, groupsToMove, projectsInOtherOrgs, groupsInOtherOrgs, sqlClientPool, models, keycloakGroups) + + return { projects: projectsToMove, groups: groupsToMove, otherOrgProjects: projectsInOtherOrgs, otherOrgGroups: groupsInOtherOrgs }; +}; + +// given a project, collect all the groups that this project has, and all the projects that those groups have and their associated projects +// and import them into the given organization +export const bulkImportProjectsAndGroupsToOrganization: ResolverFn = async ( + root, + { input, detachNotifications }, + { sqlClientPool, hasPermission, userActivityLogger, models, keycloakGroups } +) => { + + let pid = input.project; + let oid = input.organization; + + // platform admin only as it potentially reveals information about projects/orgs/groups + await hasPermission('organization', 'add'); + + const projectsToMove = [] + const groupsToMove = [] + const projectsInOtherOrgs = [] + const groupsInOtherOrgs = [] + + // get all the groups the requested project is in + await checkBulkProjectGroupAssociation(oid, pid, projectsToMove, groupsToMove, projectsInOtherOrgs, groupsInOtherOrgs, sqlClientPool, models, keycloakGroups) + + // if anything comes back in projectsInOtherOrgs or groupsInOtherOrgs, then this mutation should fail and inform the user + // to run the query first and return the fields that contain information about why it can't move the projects + if (projectsInOtherOrgs.length > 0 || groupsInOtherOrgs.length > 0) { + throw new Error( + `The process detected projects or groups that are in another organization already, you should run checkBulkImportProjectsAndGroupsToOrganization and return otherOrgProjects and otherOrgGroups fields` + ) + } + + // update all projects to be in the organization + const groupsDone = []; + const projectsDone = []; + for (const group of groupsToMove) { + // update the groups of the project to be in the organization + if (!groupsDone.includes(group.id)) { + if (group.organization != oid && group.organization == null) { + await models.GroupModel.updateGroup({ + id: group.id, + name: group.name, + attributes: { + ...group.attributes, + "lagoon-organization": [input.organization] + } + }); + groupsDone.push(group.id) + + // log this activity + userActivityLogger(`User added a group to organization`, { + project: '', + organization: input.organization, + event: 'api:addGroupToOrganization', + payload: { + data: { + group: group.name, + organization: oid + } + } + }); + } + } + } + for (const project of projectsToMove) { + if (!projectsDone.includes(project.id)) { + if (project.organization != oid && project.organization == null) { + if (detachNotifications) { + // remove all notifications from projects before adding them to the organizations + try { + await notificationHelpers(sqlClientPool).removeAllNotificationsFromProject({project: project.id}) + userActivityLogger(`User removed all notifications from project`, { + project: '', + organization: input.organization, + event: 'api:removeNotificationsFromProject', + payload: { + data: { + project: project.id, + patch:{ + organization: oid, + } + } + } + }); + } catch (err) { + throw new Error( + `Unable to remove all notifications from the project` + ) + } + } + + // set project.organization + await query( + sqlClientPool, + Sql.updateProjectOrganization({ + pid: project.id, + patch:{ + organization: oid, + } + }) + ); + projectsDone.push(project.id) + + // log this activity + userActivityLogger(`User added a project to organization`, { + project: '', + organization: input.organization, + event: 'api:addExistingProjectToOrganization', + payload: { + data: { + project: project.id, + patch:{ + organization: oid, + } + } + } + }); + } + } + } + + return { projects: projectsToMove, groups: groupsToMove, otherOrgProjects: projectsInOtherOrgs, otherOrgGroups: groupsInOtherOrgs }; +} \ No newline at end of file diff --git a/services/api/src/typeDefs.js b/services/api/src/typeDefs.js index 47f08cd0cd..4690fb3b0c 100644 --- a/services/api/src/typeDefs.js +++ b/services/api/src/typeDefs.js @@ -1417,9 +1417,17 @@ const typeDefs = gql` """ organizationById(id: Int!): Organization organizationByName(name: String!): Organization - getGroupProjectOrganizationAssociation(input: AddGroupToOrganizationInput!): String - getProjectGroupOrganizationAssociation(input: ProjectOrgGroupsInput!): String + getGroupProjectOrganizationAssociation(input: AddGroupToOrganizationInput!): String @deprecated(reason: "Use checkBulkImportProjectsAndGroupsToOrganization instead") + getProjectGroupOrganizationAssociation(input: ProjectOrgGroupsInput!): String @deprecated(reason: "Use checkBulkImportProjectsAndGroupsToOrganization instead") getEnvVariablesByProjectEnvironmentName(input: EnvVariableByProjectEnvironmentNameInput!): [EnvKeyValue] + checkBulkImportProjectsAndGroupsToOrganization(input: AddProjectToOrganizationInput!): ProjectGroupsToOrganization + } + + type ProjectGroupsToOrganization { + projects: [Project] + groups: [GroupInterface] + otherOrgProjects: [Project] + otherOrgGroups: [GroupInterface] } # Must provide id OR name @@ -2425,15 +2433,15 @@ const typeDefs = gql` """ Add a group to an organization """ - addGroupToOrganization(input: AddGroupToOrganizationInput!): OrgGroupInterface + addGroupToOrganization(input: AddGroupToOrganizationInput!): OrgGroupInterface @deprecated(reason: "Use bulkImportProjectsAndGroupsToOrganization instead") """ Add an existing group to an organization """ - addExistingGroupToOrganization(input: AddGroupToOrganizationInput!): OrgGroupInterface + addExistingGroupToOrganization(input: AddGroupToOrganizationInput!): OrgGroupInterface @deprecated(reason: "Use bulkImportProjectsAndGroupsToOrganization instead") """ Add an existing project to an organization """ - addExistingProjectToOrganization(input: AddProjectToOrganizationInput): Project + addExistingProjectToOrganization(input: AddProjectToOrganizationInput): Project @deprecated(reason: "Use bulkImportProjectsAndGroupsToOrganization instead") """ Remove a project from an organization, this will return the project to a state where it has no groups or notifications associated to it """ @@ -2446,6 +2454,13 @@ const typeDefs = gql` Remove a deploytarget from an organization """ removeDeployTargetFromOrganization(input: RemoveDeployTargetFromOrganizationInput): Organization + """ + Run the query getProjectGroupOrganizationAssociation first to see the changes that would be made before executing this, as it may contain undesirable changes + Add an existing project to an organization, this will include all the groups and all the projects that those groups contain + Optionally detach any notifications attached to the projects, they will be need to be recreated within the organization afterwards + This mutation performs a lot of actions, on big project and group imports, if it times out, subsequent runs will perform only the changes necessary + """ + bulkImportProjectsAndGroupsToOrganization(input: AddProjectToOrganizationInput, detachNotification: Boolean): ProjectGroupsToOrganization } type Subscription { From e3e6db4dbc27c09585ce3d7f927e56af941139f6 Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Wed, 29 Nov 2023 16:34:54 +1100 Subject: [PATCH 2/2] chore: use correct query name in description Co-authored-by: Toby Bellwood --- services/api/src/typeDefs.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/api/src/typeDefs.js b/services/api/src/typeDefs.js index 4690fb3b0c..b2de64c383 100644 --- a/services/api/src/typeDefs.js +++ b/services/api/src/typeDefs.js @@ -2455,7 +2455,7 @@ const typeDefs = gql` """ removeDeployTargetFromOrganization(input: RemoveDeployTargetFromOrganizationInput): Organization """ - Run the query getProjectGroupOrganizationAssociation first to see the changes that would be made before executing this, as it may contain undesirable changes + Run the query checkBulkImportProjectsAndGroupsToOrganization first to see the changes that would be made before executing this, as it may contain undesirable changes Add an existing project to an organization, this will include all the groups and all the projects that those groups contain Optionally detach any notifications attached to the projects, they will be need to be recreated within the organization afterwards This mutation performs a lot of actions, on big project and group imports, if it times out, subsequent runs will perform only the changes necessary