diff --git a/services/api/src/apolloServer.js b/services/api/src/apolloServer.js index b912fb7b16..7b451f6195 100644 --- a/services/api/src/apolloServer.js +++ b/services/api/src/apolloServer.js @@ -19,12 +19,12 @@ const { } = require('./util/auth'); const { sqlClientPool } = require('./clients/sqlClient'); const esClient = require('./clients/esClient'); -const redisClient = require('./clients/redisClient'); const { getKeycloakAdminClient } = require('./clients/keycloak-admin'); const { logger } = require('./loggers/logger'); const { userActivityLogger } = require('./loggers/userActivityLogger'); const typeDefs = require('./typeDefs'); const resolvers = require('./resolvers'); +const { keycloakGrantManager } = require('./clients/keycloakClient'); const User = require('./models/user'); const Group = require('./models/group'); @@ -117,9 +117,11 @@ const apolloServer = new ApolloServer({ sqlClientPool, keycloakAdminClient, esClient, - redisClient }; + let keycloakGroups = {} + let keycloakUsersGroups = [] + return { keycloakAdminClient, sqlClientPool, @@ -133,7 +135,9 @@ const apolloServer = new ApolloServer({ GroupModel: Group.Group(modelClients), ProjectModel: ProjectModel.ProjectModel(modelClients), EnvironmentModel: EnvironmentModel.EnvironmentModel(modelClients) - } + }, + keycloakGroups, + keycloakUsersGroups, }; }, onDisconnect: (websocket, context) => { @@ -163,16 +167,69 @@ const apolloServer = new ApolloServer({ sqlClientPool, keycloakAdminClient, esClient, - redisClient }; + // get all keycloak groups, do this early to reduce the number of times this is called otherwise + // but doing this early and once is pretty cheap + let allGroups = await Group.Group(modelClients).loadAllGroups(); + let keycloakGroups = await Group.Group(modelClients).transformKeycloakGroups(allGroups); + + let currentUser = {}; + let serviceAccount = {}; + // if this is a user request, get the users keycloak groups too, do this one to reduce the number of times it is called elsewhere + let keycloakUsersGroups = [] + let groupRoleProjectIds = [] + const keycloakGrant = req.kauth ? req.kauth.grant : null + if (keycloakGrant) { + keycloakUsersGroups = await User.User(modelClients).getAllGroupsForUser(keycloakGrant.access_token.content.sub); + serviceAccount = await keycloakGrantManager.obtainFromClientCredentials(); + currentUser = await User.User(modelClients).loadUserById(keycloakGrant.access_token.content.sub); + // grab the users project ids and roles in the first request + groupRoleProjectIds = await User.User(modelClients).getAllProjectsIdsForUser(currentUser, keycloakUsersGroups); + } + + // do a permission check to see if the user is platform admin/owner, or has permission for `viewAll` on certain resources + // this reduces the number of `viewAll` permission look ups that could potentially occur during subfield resolvers for non admin users + // every `hasPermission` check adds a delay, and if you're a member of a group that has a lot of projects and environments, hasPermissions is costly when we perform + // the viewAll permission check, to then error out and follow through with the standard user permission check, effectively costing 2 hasPermission calls for every request + // this eliminates a huge number of these by making it available in the apollo context + const hasPermission = req.kauth + ? keycloakHasPermission(req.kauth.grant, requestCache, modelClients, serviceAccount, currentUser, groupRoleProjectIds) + : legacyHasPermission(req.legacyCredentials) + let projectViewAll = false + let groupViewAll = false + let environmentViewAll = false + let deploytargetViewAll = false + try { + await hasPermission("project","viewAll") + projectViewAll = true + } catch(err) { + // do nothing + } + try { + await hasPermission("group","viewAll") + groupViewAll = true + } catch(err) { + // do nothing + } + try { + await hasPermission("environment","viewAll") + environmentViewAll = true + } catch(err) { + // do nothing + } + try { + await hasPermission("openshift","viewAll") + deploytargetViewAll = true + } catch(err) { + // do nothing + } + return { keycloakAdminClient, sqlClientPool, - hasPermission: req.kauth - ? keycloakHasPermission(req.kauth.grant, requestCache, modelClients) - : legacyHasPermission(req.legacyCredentials), - keycloakGrant: req.kauth ? req.kauth.grant : null, + hasPermission, + keycloakGrant, requestCache, userActivityLogger: (message, meta) => { let defaultMeta = { @@ -193,7 +250,15 @@ const apolloServer = new ApolloServer({ GroupModel: Group.Group(modelClients), ProjectModel: ProjectModel.ProjectModel(modelClients), EnvironmentModel: EnvironmentModel.EnvironmentModel(modelClients) - } + }, + keycloakGroups, + keycloakUsersGroups, + adminScopes: { + projectViewAll: projectViewAll, + groupViewAll: groupViewAll, + environmentViewAll: environmentViewAll, + deploytargetViewAll: deploytargetViewAll, + }, }; } }, diff --git a/services/api/src/models/group.ts b/services/api/src/models/group.ts index edde368a7d..018c83b9c5 100644 --- a/services/api/src/models/group.ts +++ b/services/api/src/models/group.ts @@ -95,7 +95,7 @@ export const Group = (clients: { sqlClientPool: Pool; esClient: any; }) => { - const { keycloakAdminClient, redisClient } = clients; + const { keycloakAdminClient } = clients; const transformKeycloakGroups = async ( keycloakGroups: GroupRepresentation[] @@ -121,7 +121,9 @@ export const Group = (clients: { groups: R.isEmpty(subGroups) ? [] : await transformKeycloakGroups(subGroups), - members: await getGroupMembership(group) + // retrieving members is a heavy operation + // this is now its own resolver + // members: await getGroupMembership(group) }); } @@ -129,17 +131,20 @@ export const Group = (clients: { }; const loadGroupById = async (id: string): Promise => { - const keycloakGroup = await keycloakAdminClient.groups.findOne({ - id - }); + let keycloakGroup: Group + keycloakGroup = await keycloakAdminClient.groups.findOne({ + id, + briefRepresentation: false, + }); if (R.isNil(keycloakGroup)) { throw new GroupNotFoundError(`Group not found: ${id}`); } const groups = await transformKeycloakGroups([keycloakGroup]); + keycloakGroup = groups[0] - return groups[0]; + return keycloakGroup; }; const loadGroupByName = async (name: string): Promise => { @@ -187,16 +192,12 @@ export const Group = (clients: { }; const loadAllGroups = async (): Promise => { - const keycloakGroups = await keycloakAdminClient.groups.find(); - - let fullGroups: Group[] = []; - for (const group of keycloakGroups) { - const fullGroup = await loadGroupById(group.id); - - fullGroups = [...fullGroups, fullGroup]; - } + // briefRepresentation pulls all the group information from keycloak including the attributes + // this means we don't need to iterate over all the groups one by one anymore to get the full group information + const fullGroups = await keycloakAdminClient.groups.find({briefRepresentation: false}); + const keycloakGroups = await transformKeycloakGroups(fullGroups); - return fullGroups; + return keycloakGroups; }; const loadParentGroup = async (groupInput: Group): Promise => @@ -230,22 +231,11 @@ export const Group = (clients: { const loadGroupsByAttribute = async ( filterFn: AttributeFilterFn ): Promise => { - const keycloakGroups = await keycloakAdminClient.groups.find(); - - let fullGroups: Group[] = []; - for (const group of keycloakGroups) { - const fullGroup = await keycloakAdminClient.groups.findOne({ - id: group.id - }); - - fullGroups = [...fullGroups, fullGroup]; - } + const keycloakGroups = await keycloakAdminClient.groups.find({briefRepresentation: false}); - const filteredGroups = filterGroupsByAttribute(fullGroups, filterFn); + const filteredGroups = filterGroupsByAttribute(keycloakGroups, filterFn); - const groups = await transformKeycloakGroups(filteredGroups); - - return groups; + return await transformKeycloakGroups(filteredGroups); }; const loadGroupsByProjectId = async (projectId: number): Promise => { @@ -260,43 +250,37 @@ export const Group = (clients: { return false; }; - let groupIds = []; + // this request will be huge, but it is significantly faster than the alternative iteration that followed previously + // briefRepresentation pulls all the group information from keycloak including the attributes + // this means we don't need to iterate over all the groups one by one anymore to get the full group information + const groups = await keycloakAdminClient.groups.find({briefRepresentation: false}); - // This function is called often and is expensive to compute so prefer - // performance over DRY - try { - groupIds = await redisClient.getProjectGroupsCache(projectId); - } catch (err) { - logger.warn(`Error loading project groups from cache: ${err.message}`); - groupIds = []; - } + const filteredGroups = filterGroupsByAttribute(groups, filterFn); - if (R.isEmpty(groupIds)) { - const keycloakGroups = await keycloakAdminClient.groups.find(); - // @ts-ignore - groupIds = R.pluck('id', keycloakGroups); - } + const fullGroups = await transformKeycloakGroups(filteredGroups); - let fullGroups = []; - for (const id of groupIds) { - const fullGroup = await keycloakAdminClient.groups.findOne({ - id - }); + return fullGroups; + }; - fullGroups = [...fullGroups, fullGroup]; - } - const filteredGroups = filterGroupsByAttribute(fullGroups, filterFn); - try { - const filteredGroupIds = R.pluck('id', filteredGroups); - await redisClient.saveProjectGroupsCache(projectId, filteredGroupIds); - } catch (err) { - logger.warn(`Error saving project groups to cache: ${err.message}`); - } + // loadGroupsByProjectIdFromGroups does the same thing as loadGroupsByProjectId, except takes a groups input in the arguments + // from another source that has already calculated the required groups + const loadGroupsByProjectIdFromGroups = async (projectId: number, groups: Group[]): Promise => { + const filterFn = attribute => { + if (attribute.name === 'lagoon-projects') { + const value = R.is(Array, attribute.value) + ? R.path(['value', 0], attribute) + : attribute.value; + return R.test(new RegExp(`\\b${projectId}\\b`), value); + } - const groups = await transformKeycloakGroups(filteredGroups); + return false; + }; + const filteredGroups = filterGroupsByAttribute(groups, filterFn); - return groups; + const fullGroups = await transformKeycloakGroups(filteredGroups); + + return fullGroups; }; // Recursive function to load membership "up" the group chain @@ -335,19 +319,26 @@ export const Group = (clients: { const getProjectsFromGroupAndSubgroups = async ( group: Group ): Promise => { - const groupProjectIds = getProjectIdsFromGroup(group); + try { + const groupProjectIds = getProjectIdsFromGroup(group); + + let subGroupProjectIds = []; + // @TODO: check is `groups.groups` ever used? it always appears to be empty + if (group.groups) { + for (const subGroup of group.groups) { + const projectIds = await getProjectsFromGroupAndSubgroups(subGroup); + subGroupProjectIds = [...subGroupProjectIds, ...projectIds]; + } + } - let subGroupProjectIds = []; - for (const subGroup of group.groups) { - const projectIds = await getProjectsFromGroupAndSubgroups(subGroup); - subGroupProjectIds = [...subGroupProjectIds, ...projectIds]; + return [ + // @ts-ignore + ...groupProjectIds, + ...subGroupProjectIds + ]; + } catch (err) { + return []; } - - return [ - // @ts-ignore - ...groupProjectIds, - ...subGroupProjectIds - ]; }; const getGroupMembership = async ( @@ -486,15 +477,10 @@ export const Group = (clients: { }); } } - return newGroup; }; const deleteGroup = async (id: string): Promise => { - const group = loadGroupById(id); - // @ts-ignore - const projectIds = getProjectIdsFromGroup(group); - try { await keycloakAdminClient.groups.del({ id }); } catch (err) { @@ -504,14 +490,6 @@ export const Group = (clients: { throw new Error(`Error deleting group ${id}: ${err}`); } } - - for (const projectId of projectIds) { - try { - await redisClient.deleteProjectGroupsCache(projectId); - } catch (err) { - logger.warn(`Error deleting project groups cache: ${err.message}`); - } - } }; const addUserToGroup = async ( @@ -553,12 +531,6 @@ export const Group = (clients: { throw new Error(`Could not add user to group: ${err.message}`); } - try { - await redisClient.deleteRedisUserCache(user.id); - } catch (err) { - logger.warn(`Error deleting user cache ${user.id}: ${err}`); - } - return await loadGroupById(group.id); }; @@ -582,11 +554,6 @@ export const Group = (clients: { throw new Error(`Could not remove user from group: ${err.message}`); } - try { - await redisClient.deleteRedisUserCache(user.id); - } catch (err) { - logger.warn(`Error deleting user cache ${user.id}: ${err}`); - } } return await loadGroupById(group.id); @@ -624,23 +591,6 @@ export const Group = (clients: { `Error setting projects for group ${group.name}: ${err.message}` ); } - - // Clear the cache for users that gained access to the project - const groupAndParentsMembers = await getMembersFromGroupAndParents(group); - const userIds = R.map(R.path(['user', 'id']), groupAndParentsMembers); - for (const userId of userIds) { - try { - await redisClient.deleteRedisUserCache(userId); - } catch (err) { - logger.warn(`Error deleting user cache ${userId}: ${err}`); - } - } - - try { - await redisClient.deleteProjectGroupsCache(projectId); - } catch (err) { - logger.warn(`Error deleting project groups cache: ${err.message}`); - } }; const removeProjectFromGroup = async ( @@ -674,23 +624,6 @@ export const Group = (clients: { `Error setting projects for group ${group.name}: ${err.message}` ); } - - // Clear the cache for users that lost access to the project - const groupAndParentsMembers = await getMembersFromGroupAndParents(group); - const userIds = R.map(R.path(['user', 'id']), groupAndParentsMembers); - for (const userId of userIds) { - try { - await redisClient.deleteRedisUserCache(userId); - } catch (err) { - logger.warn(`Error deleting user cache ${userId}: ${err}`); - } - } - - try { - await redisClient.deleteProjectGroupsCache(projectId); - } catch (err) { - logger.warn(`Error deleting project groups cache: ${err.message}`); - } }; return { @@ -701,6 +634,7 @@ export const Group = (clients: { loadParentGroup, loadGroupsByAttribute, loadGroupsByProjectId, + loadGroupsByProjectIdFromGroups, getProjectsFromGroupAndParents, getProjectsFromGroupAndSubgroups, addGroup, @@ -709,6 +643,8 @@ export const Group = (clients: { addUserToGroup, removeUserFromGroup, addProjectToGroup, - removeProjectFromGroup + removeProjectFromGroup, + transformKeycloakGroups, + getGroupMembership }; }; diff --git a/services/api/src/models/user.ts b/services/api/src/models/user.ts index c1ff34755b..276b78990b 100644 --- a/services/api/src/models/user.ts +++ b/services/api/src/models/user.ts @@ -29,11 +29,12 @@ interface UserModel { loadUserById: (id: string) => Promise; loadUserByUsername: (username: string) => Promise; loadUserByIdOrUsername: (userInput: UserEdit) => Promise; - getAllGroupsForUser: (userInput: User) => Promise; - getAllProjectsIdsForUser: (userInput: User) => Promise; + getAllGroupsForUser: (userId: string) => Promise; + getAllProjectsIdsForUser: (userInput: User, groups?: Group[]) => Promise<{}>; getUserRolesForProject: ( userInput: User, - projectId: number + projectId: number, + userGroups: Group[] ) => Promise; addUser: (userInput: User) => Promise; updateUser: (userInput: UserEdit) => Promise; @@ -70,7 +71,7 @@ export const User = (clients: { sqlClientPool: any; esClient: any; }): UserModel => { - const { keycloakAdminClient, redisClient } = clients; + const { keycloakAdminClient } = clients; const fetchGitlabId = async (user: User): Promise => { const identities = await keycloakAdminClient.users.listFederatedIdentities({ @@ -153,19 +154,20 @@ export const User = (clients: { }; const loadUserById = async (id: string): Promise => { - const keycloakUser = await keycloakAdminClient.users.findOne({ + let keycloakUser: User + keycloakUser = await keycloakAdminClient.users.findOne({ id }); + const users = await transformKeycloakUsers([keycloakUser]); + keycloakUser = users[0] if (R.isNil(keycloakUser)) { - throw new UserNotFoundError(`User not found: ${id}`); + throw new UserNotFoundError(`User not found a: ${id}`); } - - const users = await transformKeycloakUsers([keycloakUser]); - - return users[0]; + return keycloakUser; }; + // used by project resolver only, so leave this one out of redis for now const loadUserByUsername = async (username: string): Promise => { const keycloakUsers = await keycloakAdminClient.users.find({ username @@ -210,56 +212,117 @@ export const User = (clients: { return users; }; - const getAllGroupsForUser = async (userInput: User): Promise => { + const getAllGroupsForUser = async (userId: string): Promise => { const GroupModel = Group(clients); let groups = []; const roleSubgroups = await keycloakAdminClient.users.listGroups({ - id: userInput.id + id: userId, + briefRepresentation: false }); + const fullGroups = await keycloakAdminClient.groups.find({briefRepresentation: false}); - for (const roleSubgroup of roleSubgroups) { - const fullRoleSubgroup = await GroupModel.loadGroupById(roleSubgroup.id); - if (!isRoleSubgroup(fullRoleSubgroup)) { - continue; - } - - const roleSubgroupParent = await GroupModel.loadParentGroup( - fullRoleSubgroup - ); + const regexp = /-(owner|maintainer|developer|reporter|guest)$/g; - groups.push(roleSubgroupParent); + for (const fullGroup of fullGroups) { + for (const roleSubgroup of roleSubgroups) { + for (const fullSubgroup of fullGroup.subGroups) { + if (roleSubgroup.name.replace(regexp, "") == fullSubgroup.name) { + groups.push(fullSubgroup) + } + } + if (roleSubgroup.name.replace(regexp, "") == fullGroup.name) { + groups.push(fullGroup) + } + } } - return groups; + const retGroups = await GroupModel.transformKeycloakGroups(groups); + return retGroups; }; const getAllProjectsIdsForUser = async ( - userInput: User - ): Promise => { + userInput: User, + groups?: Group[] + ): Promise<{}> => { const GroupModel = Group(clients); - let projects = []; + let roleProjectIds = {}; + + if (groups) { + // if groups are provided (eg, the groups have previously been calculated in a prior step), then process those groups here and extract the project ids from them + for (const roleSubgroup of groups) { + for (const fullSubgroup of roleSubgroup.subGroups) { + // https://github.com/uselagoon/lagoon/pull/3358 references potential issue with the lagoon-projects attribute where there could be empty values + // getProjectsFromGroupAndSubgroups already covers this fix + const projectIds = await GroupModel.getProjectsFromGroupAndSubgroups( + roleSubgroup + ); + if (!roleProjectIds[fullSubgroup.realmRoles[0]]) { + roleProjectIds[fullSubgroup.realmRoles[0]] = [] + } + projectIds.forEach(pid => { + roleProjectIds[fullSubgroup.realmRoles[0]].indexOf(pid) === -1 ? roleProjectIds[fullSubgroup.realmRoles[0]].push(pid) : "" + }) + } + } - const userGroups = await getAllGroupsForUser(userInput); + return roleProjectIds; + } else { + // otherwise fall back to the previous method of getting groups and project ids which is an expensive call to keycloak if repeated often + const roleSubgroups = await keycloakAdminClient.users.listGroups({ + id: userInput.id, + briefRepresentation: false + }); - for (const group of userGroups) { - const projectIds = await GroupModel.getProjectsFromGroupAndSubgroups( - group - ); - projects = [...projects, ...projectIds]; - } + const fullGroups = await keycloakAdminClient.groups.find({briefRepresentation: false}); + + // currently in lagoon groups with a role will have the role as a prefix, this regix can be used to identify and remove it to get the parent group name + const regexp = /-(owner|maintainer|developer|reporter|guest)$/g; + + for (const fullGroup of fullGroups) { + for (const roleSubgroup of roleSubgroups) { + for (const fullSubgroup of fullGroup.subGroups) { + if (roleSubgroup.name.replace(regexp, "") == fullSubgroup.name) { + // https://github.com/uselagoon/lagoon/pull/3358 references potential issue with the lagoon-projects attribute where there could be empty values + // getProjectsFromGroupAndSubgroups already covers this fix + const projectIds = await GroupModel.getProjectsFromGroupAndSubgroups( + fullSubgroup + ); + if (!roleProjectIds[roleSubgroup.realmRoles[0]]) { + roleProjectIds[roleSubgroup.realmRoles[0]] = [] + } + projectIds.forEach(pid => { + roleProjectIds[roleSubgroup.realmRoles[0]].indexOf(pid) === -1 ? roleProjectIds[roleSubgroup.realmRoles[0]].push(pid) : "" + }) + } + } + if (roleSubgroup.name.replace(regexp, "") == fullGroup.name) { + // https://github.com/uselagoon/lagoon/pull/3358 references potential issue with the lagoon-projects attribute where there could be empty values + // getProjectsFromGroupAndSubgroups already covers this fix + const projectIds = await GroupModel.getProjectsFromGroupAndSubgroups( + fullGroup + ); + if (!roleProjectIds[roleSubgroup.realmRoles[0]]) { + roleProjectIds[roleSubgroup.realmRoles[0]] = [] + } + projectIds.forEach(pid => { + roleProjectIds[roleSubgroup.realmRoles[0]].indexOf(pid) === -1 ? roleProjectIds[roleSubgroup.realmRoles[0]].push(pid) : "" + }) + } + } + } - return R.uniq(projects); + return roleProjectIds; + } }; const getUserRolesForProject = async ( userInput: User, - projectId: number + projectId: number, + userGroups: Group[] ): Promise => { const GroupModel = Group(clients); - const userGroups = await getAllGroupsForUser(userInput); - let roles = []; for (const group of userGroups) { const projectIds = await GroupModel.getProjectsFromGroupAndSubgroups( @@ -365,11 +428,6 @@ export const User = (clients: { throw new Error(`Error deleting user ${id}: ${err}`); } } - try { - await redisClient.deleteRedisUserCache(id); - } catch (err) { - logger.error(`Error deleting user cache ${id}: ${err}`); - } }; return { diff --git a/services/api/src/resolvers.js b/services/api/src/resolvers.js index d1f6be2a89..ae85db15ad 100644 --- a/services/api/src/resolvers.js +++ b/services/api/src/resolvers.js @@ -226,6 +226,7 @@ const { removeUserFromGroup, addGroupsToProject, removeGroupsFromProject, + getMembersByGroupId, } = require('./resources/group/resolvers'); const { @@ -362,7 +363,8 @@ const resolvers = { }, }, Group: { - projects: getAllProjectsByGroupId + projects: getAllProjectsByGroupId, + members: getMembersByGroupId }, DeployTargetConfig: { project: getProjectById, diff --git a/services/api/src/resources/backup/resolvers.ts b/services/api/src/resources/backup/resolvers.ts index 31e799779f..503bd6bfd0 100644 --- a/services/api/src/resources/backup/resolvers.ts +++ b/services/api/src/resources/backup/resolvers.ts @@ -138,14 +138,17 @@ export const getRestoreLocation: ResolverFn = async ( export const getBackupsByEnvironmentId: ResolverFn = async ( { id: environmentId }, { includeDeleted, limit }, - { sqlClientPool, hasPermission } + { sqlClientPool, hasPermission, adminScopes } ) => { const environment = await environmentHelpers( sqlClientPool ).getEnvironmentById(environmentId); - await hasPermission('backup', 'view', { - project: environment.project - }); + + if (!adminScopes.projectViewAll) { + await hasPermission('backup', 'view', { + project: environment.project + }); + } let queryBuilder = knex('environment_backup') .where('environment', environmentId) diff --git a/services/api/src/resources/deployment/resolvers.ts b/services/api/src/resources/deployment/resolvers.ts index 2ea7d8d021..4092874304 100644 --- a/services/api/src/resources/deployment/resolvers.ts +++ b/services/api/src/resources/deployment/resolvers.ts @@ -33,6 +33,7 @@ import sha1 from 'sha1'; import { generateBuildId } from '@lagoon/commons/dist/util/lagoon'; import { jsonMerge } from '@lagoon/commons/dist/util/func'; import { logger } from '../../loggers/logger'; +import { getUserProjectIdsFromRoleProjectIds } from '../../util/auth'; // @ts-ignore import uuid4 from 'uuid4'; @@ -115,7 +116,7 @@ export const getBuildLog: ResolverFn = async ( export const getDeploymentsByBulkId: ResolverFn = async ( root, { bulkId }, - { sqlClientPool, hasPermission, models, keycloakGrant } + { sqlClientPool, hasPermission, models, keycloakGrant, keycloakUsersGroups } ) => { /* @@ -135,9 +136,10 @@ export const getDeploymentsByBulkId: ResolverFn = async ( return []; } - userProjectIds = await models.UserModel.getAllProjectsIdsForUser({ + const userProjectRoles = await models.UserModel.getAllProjectsIdsForUser({ id: keycloakGrant.access_token.content.sub - }); + }, keycloakUsersGroups); + userProjectIds = getUserProjectIdsFromRoleProjectIds(userProjectRoles); } let queryBuilder = knex('deployment') @@ -162,7 +164,7 @@ export const getDeploymentsByBulkId: ResolverFn = async ( export const getDeploymentsByFilter: ResolverFn = async ( root, input, - { sqlClientPool, hasPermission, models, keycloakGrant } + { sqlClientPool, hasPermission, models, keycloakGrant, keycloakUsersGroups } ) => { const { openshifts, deploymentStatus = ["NEW", "PENDING", "RUNNING", "QUEUED"] } = input; @@ -184,9 +186,10 @@ export const getDeploymentsByFilter: ResolverFn = async ( return []; } - userProjectIds = await models.UserModel.getAllProjectsIdsForUser({ + const userProjectRoles = await models.UserModel.getAllProjectsIdsForUser({ id: keycloakGrant.access_token.content.sub - }); + }, keycloakUsersGroups); + userProjectIds = getUserProjectIdsFromRoleProjectIds(userProjectRoles); } let queryBuilder = knex.select("deployment.*").from('deployment'). @@ -214,15 +217,15 @@ export const getDeploymentsByFilter: ResolverFn = async ( }; export const getDeploymentsByEnvironmentId: ResolverFn = async ( - { id: eid, environmentAuthz }, + { id: eid }, { name, limit }, - { sqlClientPool, hasPermission } + { sqlClientPool, hasPermission, adminScopes } ) => { const environment = await environmentHelpers( sqlClientPool ).getEnvironmentById(eid); - if (!environmentAuthz) { + if (!adminScopes.projectViewAll) { await hasPermission('deployment', 'view', { project: environment.project }); @@ -1237,7 +1240,7 @@ export const switchActiveStandby: ResolverFn = async ( export const bulkDeployEnvironmentLatest: ResolverFn = async ( _root, { input: { environments: environmentsInput, buildVariables, name: bulkName } }, - { keycloakGrant, models, sqlClientPool, hasPermission, userActivityLogger } + { keycloakGrant, models, sqlClientPool, hasPermission, userActivityLogger, keycloakUsersGroups } ) => { /* @@ -1257,9 +1260,10 @@ export const bulkDeployEnvironmentLatest: ResolverFn = async ( return []; } - userProjectIds = await models.UserModel.getAllProjectsIdsForUser({ + const userProjectRoles = await models.UserModel.getAllProjectsIdsForUser({ id: keycloakGrant.access_token.content.sub - }); + }, keycloakUsersGroups); + userProjectIds = getUserProjectIdsFromRoleProjectIds(userProjectRoles); } let bulkId = uuid4(); diff --git a/services/api/src/resources/env-variables/resolvers.ts b/services/api/src/resources/env-variables/resolvers.ts index 1c36105d82..b4105bf178 100644 --- a/services/api/src/resources/env-variables/resolvers.ts +++ b/services/api/src/resources/env-variables/resolvers.ts @@ -12,11 +12,13 @@ import { logger } from '../../loggers/logger'; export const getEnvVarsByProjectId: ResolverFn = async ( { id: pid }, args, - { sqlClientPool, hasPermission } + { sqlClientPool, hasPermission, adminScopes } ) => { - await hasPermission('env_var', 'project:view', { - project: pid - }); + if (!adminScopes.projectViewAll) { + await hasPermission('env_var', 'project:view', { + project: pid + }); + } const rows = await query( sqlClientPool, @@ -29,19 +31,21 @@ export const getEnvVarsByProjectId: ResolverFn = async ( export const getEnvVarsByEnvironmentId: ResolverFn = async ( { id: eid }, args, - { sqlClientPool, hasPermission } + { sqlClientPool, hasPermission, adminScopes } ) => { const environment = await environmentHelpers( sqlClientPool ).getEnvironmentById(eid); - await hasPermission( - 'env_var', - `environment:view:${environment.environmentType}`, - { - project: environment.project - } - ); + if (!adminScopes.projectViewAll) { + await hasPermission( + 'env_var', + `environment:view:${environment.environmentType}`, + { + project: environment.project + } + ); + } const rows = await query( sqlClientPool, @@ -335,7 +339,7 @@ export const addOrUpdateEnvVariableByName: ResolverFn = async ( export const getEnvVariablesByProjectEnvironmentName: ResolverFn = async ( root, { input: { project: projectName, environment: environmentName } }, - { sqlClientPool, hasPermission, userActivityLogger } + { sqlClientPool, hasPermission, userActivityLogger, adminScopes } ) => { const projectId = await projectHelpers(sqlClientPool).getProjectIdByName( projectName @@ -349,13 +353,15 @@ export const getEnvVariablesByProjectEnvironmentName: ResolverFn = async ( ); const environment = environmentRows[0]; - await hasPermission( - 'env_var', - `environment:view:${environment.environmentType}`, - { - project: projectId - } - ); + if (!adminScopes.projectViewAll) { + await hasPermission( + 'env_var', + `environment:view:${environment.environmentType}`, + { + project: projectId + } + ); + } const environmentVariables = await query( sqlClientPool, @@ -364,9 +370,11 @@ export const getEnvVariablesByProjectEnvironmentName: ResolverFn = async ( return environmentVariables } else { - await hasPermission('env_var', 'project:view', { - project: projectId - }); + if (!adminScopes.projectViewAll) { + await hasPermission('env_var', 'project:view', { + project: projectId + }); + } // is project const projectVariables = await query( sqlClientPool, diff --git a/services/api/src/resources/environment/resolvers.ts b/services/api/src/resources/environment/resolvers.ts index 9e26d49865..abd81e5292 100644 --- a/services/api/src/resources/environment/resolvers.ts +++ b/services/api/src/resources/environment/resolvers.ts @@ -11,6 +11,7 @@ import { Sql } from './sql'; import { Sql as projectSql } from '../project/sql'; import { Helpers as projectHelpers } from '../project/helpers'; import { getFactFilteredEnvironmentIds } from '../fact/resolvers'; +import { logger } from '../../loggers/logger'; export const getEnvironmentByName: ResolverFn = async ( root, @@ -65,16 +66,11 @@ export const getEnvironmentById = async ( export const getEnvironmentsByProjectId: ResolverFn = async ( project, args, - { sqlClientPool, hasPermission, keycloakGrant, models } + { sqlClientPool, hasPermission, keycloakGrant, models, adminScopes } ) => { const { id: pid } = project; - // The getAllProjects resolver will authorize environment access already, - // so we can skip the request to keycloak. - // - // @TODO: When this performance issue is fixed for real, remove this hack as - // it hardcodes a "everyone can view environments" authz rule. - if (!R.prop('environmentAuthz', project)) { + if (!adminScopes.projectViewAll) { await hasPermission('environment', 'view', { project: pid }); @@ -100,7 +96,7 @@ export const getEnvironmentsByProjectId: ResolverFn = async ( ); const withK8s = Helpers(sqlClientPool).aliasOpenshiftToK8s(rows); - return withK8s.map(row => ({ ...row, environmentAuthz: true })); + return withK8s; }; export const getEnvironmentByDeploymentId: ResolverFn = async ( diff --git a/services/api/src/resources/fact/resolvers.ts b/services/api/src/resources/fact/resolvers.ts index c8304c3ad9..a88027f991 100644 --- a/services/api/src/resources/fact/resolvers.ts +++ b/services/api/src/resources/fact/resolvers.ts @@ -9,17 +9,18 @@ import crypto from 'crypto'; import { Service } from 'aws-sdk'; import * as api from '@lagoon/commons/dist/api'; import { getEnvironmentsByProjectId } from '../environment/resolvers'; +import { getUserProjectIdsFromRoleProjectIds } from '../../util/auth'; export const getFactsByEnvironmentId: ResolverFn = async ( - { id: environmentId, environmentAuthz }, + { id: environmentId }, { keyFacts, limit, summary }, - { sqlClientPool, hasPermission } + { sqlClientPool, hasPermission, adminScopes } ) => { const environment = await environmentHelpers( sqlClientPool ).getEnvironmentById(environmentId); - if (!environmentAuthz) { + if (!adminScopes.projectViewAll) { await hasPermission('fact', 'view', { project: environment.project }); @@ -122,12 +123,15 @@ const getSqlPredicate = (predicate) => { export const getProjectsByFactSearch: ResolverFn = async ( root, { input }, - { sqlClientPool, hasPermission, keycloakGrant, models } + { sqlClientPool, hasPermission, keycloakGrant, models, keycloakUsersGroups }, + info ) => { let isAdmin = false; let userProjectIds: number[]; + try { + // admin check, if passed then pre-set authz await hasPermission('project', 'viewAll'); isAdmin = true; } catch (err) { @@ -136,25 +140,22 @@ export const getProjectsByFactSearch: ResolverFn = async ( return []; } - userProjectIds = await models.UserModel.getAllProjectsIdsForUser({ + const userProjectRoles = await models.UserModel.getAllProjectsIdsForUser({ id: keycloakGrant.access_token.content.sub - }); + }, keycloakUsersGroups); + userProjectIds = getUserProjectIdsFromRoleProjectIds(userProjectRoles); } const count = await getFactFilteredProjectsCount(input, userProjectIds, sqlClientPool, isAdmin); const rows = await getFactFilteredProjects(input, userProjectIds, sqlClientPool, isAdmin); - // Just like the getAllProjects resolver, we can pass a 'environmentAuthz' prop to bypass extra - // keycloak checks at the environments level. - const rowsWithEnvironmentAuthz = rows && rows.map(row => ({ ...row, environmentAuthz: true })); - - return { projects: rowsWithEnvironmentAuthz, count }; + return { projects: rows, count }; } export const getEnvironmentsByFactSearch: ResolverFn = async ( root, { input }, - { sqlClientPool, hasPermission, keycloakGrant, models } + { sqlClientPool, hasPermission, keycloakGrant, models, keycloakUsersGroups } ) => { let isAdmin = false; @@ -168,19 +169,16 @@ export const getEnvironmentsByFactSearch: ResolverFn = async ( return []; } - userProjectIds = await models.UserModel.getAllProjectsIdsForUser({ + const userProjectRoles = await models.UserModel.getAllProjectsIdsForUser({ id: keycloakGrant.access_token.content.sub - }); + }, keycloakUsersGroups); + userProjectIds = getUserProjectIdsFromRoleProjectIds(userProjectRoles); } const count = await getFactFilteredEnvironmentsCount(input, userProjectIds, sqlClientPool, isAdmin); const rows = await getFactFilteredEnvironments(input, userProjectIds, sqlClientPool, isAdmin); - // Just like the getAllProjects resolver, we can pass a 'environmentAuthz' prop to bypass extra - // keycloak checks at the environments level. - const rowsWithEnvironmentAuthz = rows && rows.map(row => ({ ...row, environmentAuthz: true })); - - return { environments: rowsWithEnvironmentAuthz, count }; + return { environments: rows, count }; } export const processAddFacts = async (facts, sqlClientPool, hasPermission) => { diff --git a/services/api/src/resources/group/resolvers.ts b/services/api/src/resources/group/resolvers.ts index ba0eca6fbc..a2ed181657 100644 --- a/services/api/src/resources/group/resolvers.ts +++ b/services/api/src/resources/group/resolvers.ts @@ -11,144 +11,174 @@ import { KeycloakUnauthorizedError } from '../../util/auth'; export const getAllGroups: ResolverFn = async ( root, { name, type }, - { hasPermission, models, keycloakGrant } + { hasPermission, models, keycloakGrant, keycloakGroups, keycloakUsersGroups, adminScopes } ) => { - try { - await hasPermission('group', 'viewAll'); + // use the admin scope check instead of `hasPermission` for speed + if (adminScopes.groupViewAll) { + try { - if (name) { - const group = await models.GroupModel.loadGroupByName(name); - return [group]; - } else { - const groups = await models.GroupModel.loadAllGroups(); - const filterFn = (key, val) => group => group[key].includes(val); - const filteredByName = groups.filter(filterFn('name', name)); - const filteredByType = groups.filter(filterFn('type', type)); - return name || type ? R.union(filteredByName, filteredByType) : groups; - } - } catch (err) { - if (name && err instanceof GroupNotFoundError) { + if (name) { + const group = await models.GroupModel.loadGroupByName(name); + return [group]; + } else { + const groups = keycloakGroups; + const filterFn = (key, val) => group => group[key].includes(val); + const filteredByName = groups.filter(filterFn('name', name)); + const filteredByType = groups.filter(filterFn('type', type)); + return name || type ? R.union(filteredByName, filteredByType) : groups; + } + } catch (err) { + if (name && err instanceof GroupNotFoundError) { + throw err; + } + + if (err instanceof KeycloakUnauthorizedError) { + if (!keycloakGrant) { + logger.debug('No grant available for getAllGroups'); + return []; + } + } + + logger.warn(`getAllGroups failed unexpectedly: ${err.message}`); throw err; } + } + + const userGroups = await keycloakUsersGroups; + + if (name) { + return R.filter(R.propEq('name', name), userGroups); + } else { + return userGroups; + } + +}; +// TODO: recursive lookups for groups in groups? +export const getGroupFromGroups = async (id, groups) => { + const d = R.filter(R.propEq('id', id), groups); + if (d.length) { + return d[0]; + } + for (const group in groups) { + if (groups[group].groups.length) { + const d = R.filter(R.propEq('id', id), groups[group].groups) + if (d.length) { + return d[0]; + } + } + } + return {}; +} + +export const getMembersByGroupId: ResolverFn = async ( + { id }, + _input, + { hasPermission, models, keycloakGrant, keycloakGroups } +) => { + try { + // members resolver is only called by group, no need to check the permissions on the group + // as the group resolver will have already checked permission + const group = await getGroupFromGroups(id, keycloakGroups); + const members = await models.GroupModel.getGroupMembership(group); + return members; + } catch (err) { if (err instanceof KeycloakUnauthorizedError) { if (!keycloakGrant) { - logger.debug('No grant available for getAllGroups'); - return []; - } else { - const user = await models.UserModel.loadUserById( - keycloakGrant.access_token.content.sub - ); - const userGroups = await models.UserModel.getAllGroupsForUser(user); - - if (name) { - return R.filter(R.propEq('name', name), userGroups); - } else { - return userGroups; - } + logger.debug('No grant available for getGroupByName'); + throw new GroupNotFoundError(`Group not found: ${id}`); } } - logger.warn(`getAllGroups failed unexpectedly: ${err.message}`); + logger.warn(`getGroupByName failed unexpectedly: ${err.message} ${id}`); throw err; } -}; +} export const getGroupsByProjectId: ResolverFn = async ( { id: pid }, _input, - { hasPermission, models, keycloakGrant } + { hasPermission, models, keycloakGrant, keycloakGroups, keycloakUsersGroups, adminScopes } ) => { - const projectGroups = await models.GroupModel.loadGroupsByProjectId(pid); - - try { - await hasPermission('group', 'viewAll'); - - return projectGroups; - } catch (err) { - if (!keycloakGrant) { - logger.debug('No grant available for getGroupsByProjectId'); - return []; + // use the admin scope check instead of `hasPermission` for speed + if (adminScopes.groupViewAll) { + try { + const projectGroups = await models.GroupModel.loadGroupsByProjectIdFromGroups(pid, keycloakGroups); + return projectGroups; + } catch (err) { + if (!keycloakGrant) { + logger.debug('No grant available for getGroupsByProjectId'); + return []; + } } + } - const user = await models.UserModel.loadUserById( - keycloakGrant.access_token.content.sub - ); - const userGroups = await models.UserModel.getAllGroupsForUser(user); - const userProjectGroups = R.intersection(projectGroups, userGroups); + const projectGroups = await models.GroupModel.loadGroupsByProjectIdFromGroups(pid, keycloakGroups); + const userGroups = keycloakUsersGroups; + const userProjectGroups = R.intersection(projectGroups, userGroups); - return userProjectGroups; - } + return userProjectGroups; }; export const getGroupsByUserId: ResolverFn = async ( { id: uid }, _input, - { hasPermission, models, keycloakGrant } + { hasPermission, models, keycloakGrant, keycloakUsersGroups, adminScopes } ) => { - const queryUser = await models.UserModel.loadUserById(uid); - const queryUserGroups = await models.UserModel.getAllGroupsForUser(queryUser); - - try { - await hasPermission('group', 'viewAll'); + // use the admin scope check instead of `hasPermission` for speed + if (adminScopes.groupViewAll) { + try { + const queryUserGroups = await models.UserModel.getAllGroupsForUser(uid); - return queryUserGroups; - } catch (err) { - if (!keycloakGrant) { - logger.debug('No grant available for getGroupsByUserId'); - return []; + return queryUserGroups; + } catch (err) { + if (!keycloakGrant) { + logger.debug('No grant available for getGroupsByUserId'); + return []; + } } - - const currentUser = await models.UserModel.loadUserById( - keycloakGrant.access_token.content.sub - ); - const currentUserGroups = await models.UserModel.getAllGroupsForUser( - currentUser - ); - const bothUserGroups = R.intersection(queryUserGroups, currentUserGroups); - - return bothUserGroups; } + const currentUserGroups = keycloakUsersGroups; + // const bothUserGroups = R.intersection(queryUserGroups, currentUserGroups); + + return currentUserGroups; }; export const getGroupByName: ResolverFn = async ( root, { name }, - { models, hasPermission, keycloakGrant } + { models, hasPermission, keycloakGrant, keycloakUsersGroups, adminScopes } ) => { - try { - await hasPermission('group', 'viewAll'); - - const group = await models.GroupModel.loadGroupByName(name); - return group; - } catch (err) { - if (err instanceof GroupNotFoundError) { - throw err; - } - - if (err instanceof KeycloakUnauthorizedError) { - if (!keycloakGrant) { - logger.debug('No grant available for getGroupByName'); - throw new GroupNotFoundError(`Group not found: ${name}`); - } else { - const user = await models.UserModel.loadUserById( - keycloakGrant.access_token.content.sub - ); - const userGroups = await models.UserModel.getAllGroupsForUser(user); - - const group = R.head(R.filter(R.propEq('name', name), userGroups)); + // use the admin scope check instead of `hasPermission` for speed + if (adminScopes.groupViewAll) { + try { + const group = await models.GroupModel.loadGroupByName(name); + return group; + } catch (err) { + if (err instanceof GroupNotFoundError) { + throw err; + } - if (R.isEmpty(group)) { + if (err instanceof KeycloakUnauthorizedError) { + if (!keycloakGrant) { + logger.debug('No grant available for getGroupByName'); throw new GroupNotFoundError(`Group not found: ${name}`); } - - return group; } + + logger.warn(`getGroupByName failed unexpectedly: ${err.message}`); + throw err; } + } - logger.warn(`getGroupByName failed unexpectedly: ${err.message}`); - throw err; + const userGroups = keycloakUsersGroups; + const group = R.head(R.filter(R.propEq('name', name), userGroups)); + + if (R.isEmpty(group)) { + throw new GroupNotFoundError(`Group not found: ${name}`); } + + return group; }; export const addGroup: ResolverFn = async ( @@ -275,11 +305,11 @@ export const deleteGroup: ResolverFn = async ( export const deleteAllGroups: ResolverFn = async ( _root, _args, - { models, hasPermission } + { models, hasPermission, keycloakGroups } ) => { await hasPermission('group', 'deleteAll'); - const groups = await models.GroupModel.loadAllGroups(); + const groups = keycloakGroups; let deleteErrors: String[] = []; for (const group of groups) { @@ -452,28 +482,30 @@ export const getAllProjectsByGroupId: ResolverFn = async ( export const getAllProjectsInGroup: ResolverFn = async ( _root, { input: groupInput }, - { models, sqlClientPool, hasPermission, keycloakGrant } + { models, sqlClientPool, hasPermission, keycloakGrant, keycloakGroups, keycloakUsersGroups, adminScopes } ) => { const { GroupModel: { loadGroupByIdOrName, getProjectsFromGroupAndSubgroups } } = models; - try { - await hasPermission('group', 'viewAll'); - - const group = await loadGroupByIdOrName(groupInput); - const projectIdsArray = await getProjectsFromGroupAndSubgroups(group); - return projectIdsArray.map(async id => - projectHelpers(sqlClientPool).getProjectByProjectInput({ id }) - ); - } catch (err) { - if (err instanceof GroupNotFoundError) { - throw err; - } + // use the admin scope check instead of `hasPermission` for speed + if (adminScopes.groupViewAll) { + try { + // get group from all keycloak groups apollo context + const group = await getGroupFromGroups(groupInput.id, keycloakGroups) + const projectIdsArray = await getProjectsFromGroupAndSubgroups(group); + return projectIdsArray.map(async id => + projectHelpers(sqlClientPool).getProjectByProjectInput({ id }) + ); + } catch (err) { + if (err instanceof GroupNotFoundError) { + throw err; + } - if (!(err instanceof KeycloakUnauthorizedError)) { - logger.warn(`getAllGroups failed unexpectedly: ${err.message}`); - throw err; + if (!(err instanceof KeycloakUnauthorizedError)) { + logger.warn(`getAllGroups failed unexpectedly: ${err.message}`); + throw err; + } } } @@ -481,20 +513,18 @@ export const getAllProjectsInGroup: ResolverFn = async ( logger.debug('No grant available for getAllProjectsInGroup'); return []; } else { - const group = await loadGroupByIdOrName(groupInput); - - const user = await models.UserModel.loadUserById( - keycloakGrant.access_token.content.sub - ); - const userGroups = await models.UserModel.getAllGroupsForUser(user); + // get group from all keycloak groups apollo context + const group = await getGroupFromGroups(groupInput.id, keycloakGroups) + // get users groups from users keycloak groups apollo context + const userGroups = keycloakUsersGroups; // @ts-ignore if (!R.contains(group.name, R.pluck('name', userGroups))) { logger.debug('No grant available for getAllProjectsInGroup'); return []; } - const projectIdsArray = await getProjectsFromGroupAndSubgroups(group); + return projectIdsArray.map(async id => projectHelpers(sqlClientPool).getProjectByProjectInput({ id }) ); diff --git a/services/api/src/resources/index.ts b/services/api/src/resources/index.ts index 1341a13b82..f1450e4d53 100644 --- a/services/api/src/resources/index.ts +++ b/services/api/src/resources/index.ts @@ -19,6 +19,9 @@ export interface ResolverFn { ProjectModel, EnvironmentModel, }, + keycloakGroups?: any | null, + keycloakUsersGroups?: any | null, + adminScopes?: any | null, }, info? ): any; diff --git a/services/api/src/resources/insight/resolvers.ts b/services/api/src/resources/insight/resolvers.ts index 7dbef50156..a7d4c2463d 100644 --- a/services/api/src/resources/insight/resolvers.ts +++ b/services/api/src/resources/insight/resolvers.ts @@ -119,9 +119,9 @@ export const getInsightsFileData: ResolverFn = async ( }; export const getInsightsFilesByEnvironmentId: ResolverFn = async ( - { id: eid, environmentAuthz }, + { id: eid }, { name, limit }, - { sqlClientPool, hasPermission } + { sqlClientPool, hasPermission, adminScopes } ) => { if (!eid) { @@ -132,7 +132,7 @@ export const getInsightsFilesByEnvironmentId: ResolverFn = async ( sqlClientPool ).getEnvironmentById(parseInt(eid)); - if (!environmentAuthz) { + if (!adminScopes.projectViewAll) { await hasPermission('environment', 'view', { project: environmentData.project }); diff --git a/services/api/src/resources/openshift/resolvers.ts b/services/api/src/resources/openshift/resolvers.ts index 1ae1edeb48..c746a11994 100644 --- a/services/api/src/resources/openshift/resolvers.ts +++ b/services/api/src/resources/openshift/resolvers.ts @@ -96,11 +96,14 @@ export const getAllOpenshifts: ResolverFn = async ( export const getOpenshiftByProjectId: ResolverFn = async ( { id: pid }, args, - { sqlClientPool, hasPermission } + { sqlClientPool, hasPermission, adminScopes } ) => { - await hasPermission('openshift', 'view', { - project: pid - }); + + if (!adminScopes.openshiftViewAll) { + await hasPermission('openshift', 'view', { + project: pid + }); + } const rows = await query( sqlClientPool, @@ -157,7 +160,7 @@ export const getOpenshiftByDeployTargetId: ResolverFn = async ( export const getOpenshiftByEnvironmentId: ResolverFn = async ( { id: eid }, args, - { sqlClientPool, hasPermission } + { sqlClientPool, hasPermission, adminScopes } ) => { // get the project id for the environment const project = await projectHelpers( @@ -165,9 +168,11 @@ export const getOpenshiftByEnvironmentId: ResolverFn = async ( ).getProjectByEnvironmentId(eid); // check permissions on the project - await hasPermission('openshift', 'view', { - project: project.project - }); + if (!adminScopes.openshiftViewAll) { + await hasPermission('openshift', 'view', { + project: project.project + }); + } const rows = await query( sqlClientPool, diff --git a/services/api/src/resources/problem/resolvers.ts b/services/api/src/resources/problem/resolvers.ts index 8c64c970e8..779e1fd131 100644 --- a/services/api/src/resources/problem/resolvers.ts +++ b/services/api/src/resources/problem/resolvers.ts @@ -98,15 +98,17 @@ export const getProblemSources: ResolverFn = async ( export const getProblemsByEnvironmentId: ResolverFn = async ( { id: environmentId }, { severity, source }, - { sqlClientPool, hasPermission } + { sqlClientPool, hasPermission, adminScopes } ) => { const environment = await environmentHelpers( sqlClientPool ).getEnvironmentById(environmentId); - await hasPermission('problem', 'view', { - project: environment.project - }); + if (!adminScopes.projectViewAll) { + await hasPermission('problem', 'view', { + project: environment.project + }); + } const rows = await query( sqlClientPool, diff --git a/services/api/src/resources/project/helpers.ts b/services/api/src/resources/project/helpers.ts index e5274ecd51..a47bac3ed2 100644 --- a/services/api/src/resources/project/helpers.ts +++ b/services/api/src/resources/project/helpers.ts @@ -67,7 +67,7 @@ export const Helpers = (sqlClientPool: Pool) => { return parseInt(pid, 10); }, - getProjectByProjectInput: async projectInput => { + getProjectByProjectInput: async (projectInput) => { const notEmpty = R.complement(R.anyPass([R.isNil, R.isEmpty])); const hasId = R.both(R.has('id'), R.propSatisfies(notEmpty, 'id')); const hasName = R.both(R.has('name'), R.propSatisfies(notEmpty, 'name')); diff --git a/services/api/src/resources/project/resolvers.ts b/services/api/src/resources/project/resolvers.ts index 69d0c79ddd..d10a908957 100644 --- a/services/api/src/resources/project/resolvers.ts +++ b/services/api/src/resources/project/resolvers.ts @@ -12,20 +12,11 @@ import * as OS from '../openshift/sql'; import { generatePrivateKey, getSshKeyFingerprint } from '../sshKey'; import { Sql as sshKeySql } from '../sshKey/sql'; import { createHarborOperations } from './harborSetup'; +import { getUserProjectIdsFromRoleProjectIds } from '../../util/auth'; import sql from '../user/sql'; const DISABLE_CORE_HARBOR = process.env.DISABLE_CORE_HARBOR || "false" -const isAdminCheck = async (hasPermission) => { - try { - // check user is admin - await hasPermission('project', 'viewAll'); - return true; - } catch (err) { - return false; - } -}; - const isValidGitUrl = value => /(?:git|ssh|https?|git@[-\w.]+):(\/\/)?(.*?)(\.git)(\/?|\#[-\d\w._]+?)$/.test( value @@ -50,21 +41,25 @@ export const getPrivateKey: ResolverFn = async ( export const getAllProjects: ResolverFn = async ( root, { order, createdAfter, gitUrl }, - { sqlClientPool, hasPermission, models, keycloakGrant } + { sqlClientPool, hasPermission, models, keycloakGrant, keycloakUsersGroups } ) => { let userProjectIds: number[]; try { + // admin check, if passed then pre-set authz await hasPermission('project', 'viewAll'); } catch (err) { + // else user if (!keycloakGrant) { logger.debug('No grant available for getAllProjects'); return []; } + // get the project ids from the users groups + const userProjectRoles = await models.UserModel.getAllProjectsIdsForUser({ + id: keycloakGrant.access_token.content.sub, - userProjectIds = await models.UserModel.getAllProjectsIdsForUser({ - id: keycloakGrant.access_token.content.sub - }); + }, keycloakUsersGroups); + userProjectIds = getUserProjectIdsFromRoleProjectIds(userProjectRoles); } let queryBuilder = knex('project'); @@ -88,15 +83,7 @@ export const getAllProjects: ResolverFn = async ( const rows = await query(sqlClientPool, queryBuilder.toString()); const withK8s = Helpers(sqlClientPool).aliasOpenshiftToK8s(rows); - // This resolver is used for the main UI page and is quite slow. Since we've - // already authorized the user has access to all the projects we are - // returning, AND all user roles are allowed to view all environments, we can - // short-circuit the slow keycloak check in the getEnvironmentsByProjectId - // resolver. - // - // @TODO: When this performance issue is fixed for real, remove this hack as - // it hardcodes a "everyone can view environments" authz rule. - return withK8s.map(row => ({ ...row, environmentAuthz: true })); + return withK8s; }; export const getProjectByEnvironmentId: ResolverFn = async ( @@ -201,10 +188,13 @@ export const getProjectByName: ResolverFn = async ( export const getProjectsByMetadata: ResolverFn = async ( root, { metadata }, - { sqlClientPool, hasPermission, keycloakGrant, models } + { sqlClientPool, hasPermission, keycloakGrant, models, keycloakUsersGroups }, + info ) => { let userProjectIds: number[]; + try { + // admin check, if passed then pre-set authz await hasPermission('project', 'viewAll'); } catch (err) { if (!keycloakGrant) { @@ -212,9 +202,10 @@ export const getProjectsByMetadata: ResolverFn = async ( return []; } - userProjectIds = await models.UserModel.getAllProjectsIdsForUser({ + const userProjectRoles = await models.UserModel.getAllProjectsIdsForUser({ id: keycloakGrant.access_token.content.sub - }); + }, keycloakUsersGroups); + userProjectIds = getUserProjectIdsFromRoleProjectIds(userProjectRoles); } let queryBuilder = knex('project'); @@ -239,13 +230,15 @@ export const getProjectsByMetadata: ResolverFn = async ( } const rows = await query(sqlClientPool, queryBuilder.toString(), queryArgs); - return Helpers(sqlClientPool).aliasOpenshiftToK8s(rows); + const withK8s = Helpers(sqlClientPool).aliasOpenshiftToK8s(rows); + + return withK8s; }; export const addProject = async ( root, { input }, - { hasPermission, sqlClientPool, models, keycloakGrant, userActivityLogger } + { hasPermission, sqlClientPool, models, keycloakGrant, userActivityLogger, adminScopes } ) => { await hasPermission('project', 'add'); @@ -290,8 +283,7 @@ export const addProject = async ( // check if a user has permission to disable deployments of a project or not let deploymentsDisabled = 0; if (input.deploymentsDisabled) { - const canDisableProject = await isAdminCheck(hasPermission); - if (canDisableProject) { + if (adminScopes.projectViewAll) { deploymentsDisabled = input.deploymentsDisabled } } @@ -396,12 +388,9 @@ export const addProject = async ( } // Add the user who submitted this request to the project - let userAlreadyHasAccess; - try { - await hasPermission('project', 'viewAll'); - userAlreadyHasAccess = true; - } catch (e) { - userAlreadyHasAccess = false; + let userAlreadyHasAccess = false; + if (adminScopes.projectViewAll) { + userAlreadyHasAccess = true } if (!userAlreadyHasAccess && keycloakGrant) { @@ -545,7 +534,7 @@ export const updateProject: ResolverFn = async ( } } }, - { sqlClientPool, hasPermission, userActivityLogger, models } + { sqlClientPool, hasPermission, userActivityLogger, models, adminScopes } ) => { await hasPermission('project', 'update', { project: id @@ -553,8 +542,7 @@ export const updateProject: ResolverFn = async ( // check if a user has permission to disable deployments of a project or not if (deploymentsDisabled) { - const canDisableProject = await isAdminCheck(hasPermission); - if (canDisableProject == false) { + if (!adminScopes.projectViewAll) { deploymentsDisabled = 0; } } @@ -575,8 +563,7 @@ export const updateProject: ResolverFn = async ( // renaming projects is prohibited because lagoon uses the project name for quite a few things // which if changed can have unintended consequences for any existing environments if (patch.name) { - const canUpdateName = await isAdminCheck(hasPermission); - if (!canUpdateName) { + if (!adminScopes.projectViewAll) { throw new Error('Project renaming is only available to administrators.'); } } diff --git a/services/api/src/resources/task/resolvers.ts b/services/api/src/resources/task/resolvers.ts index a65411d186..c16c838947 100644 --- a/services/api/src/resources/task/resolvers.ts +++ b/services/api/src/resources/task/resolvers.ts @@ -101,14 +101,17 @@ export const getTaskLog: ResolverFn = async ( export const getTasksByEnvironmentId: ResolverFn = async ( { id: eid }, { id: filterId, taskName: taskName, limit }, - { sqlClientPool, hasPermission } + { sqlClientPool, hasPermission, adminScopes } ) => { const environment = await environmentHelpers( sqlClientPool ).getEnvironmentById(eid); - await hasPermission('task', 'view', { - project: environment.project - }); + + if (!adminScopes.projectViewAll) { + await hasPermission('task', 'view', { + project: environment.project + }); + } let queryBuilder = knex('task') .where('environment', eid) diff --git a/services/api/src/util/auth.ts b/services/api/src/util/auth.ts index 3bd3633b29..9c8da4b109 100644 --- a/services/api/src/util/auth.ts +++ b/services/api/src/util/auth.ts @@ -1,12 +1,10 @@ import * as R from 'ramda'; -import { getRedisCache, saveRedisCache } from '../clients/redisClient'; import { verify } from 'jsonwebtoken'; import { logger } from '../loggers/logger'; import { getConfigFromEnv } from '../util/config'; import { isNotNil } from './func'; import { keycloakGrantManager } from '../clients/keycloakClient'; const { userActivityLogger } = require('../loggers/userActivityLogger'); -import { User } from '../models/user'; import { Group } from '../models/group'; interface ILegacyToken { @@ -41,6 +39,52 @@ const sortRolesByWeight = (a, b) => { return 0; }; +export const getUserProjectIdsFromRoleProjectIds = ( + roleProjectIds +): number[] => { + // https://github.com/uselagoon/lagoon/pull/3358 references potential issue with the lagoon-projects attribute where there could be empty values + // the structure of this payload is: + /* + {"guest":[13],"devloper":[20,14],"maintainer":[13,18,19,12]} + */ + let upids = []; + for (const r in roleProjectIds) { + for (const pid in roleProjectIds[r]) { + upids.indexOf(roleProjectIds[r][pid]) === -1 ? upids.push(roleProjectIds[r][pid]) : "" + } + } + return R.uniq(upids); +}; + +export const getUserRoleForProjectFromRoleProjectIds = ( + roleProjectIds, projectId +): [string, number[]] => { + let upids = []; + let roles = []; + for (const r in roleProjectIds) { + for (const pid in roleProjectIds[r]) { + upids.indexOf(roleProjectIds[r][pid]) === -1 ? upids.push(roleProjectIds[r][pid]) : "" + if (projectId == roleProjectIds[r][pid]) { + roles.push(r) + } + } + } + + const highestRoleForProject = getHighestRole(roles); + + return [highestRoleForProject, R.uniq(upids)]; +}; + +const getHighestRole = (roles) => { + return R.pipe( + R.uniq, + R.reject(R.isEmpty), + R.reject(R.isNil), + R.sort(sortRolesByWeight), + R.last + )(roles); +}; + export const isLegacyToken = R.pathSatisfies(isNotNil, ['payload', 'role']); export const isKeycloakToken = R.pathSatisfies(isNotNil, ['payload', 'typ']); @@ -101,65 +145,10 @@ export class KeycloakUnauthorizedError extends Error { } } -export const keycloakHasPermission = (grant, requestCache, modelClients) => { - const UserModel = User(modelClients); +export const keycloakHasPermission = (grant, requestCache, modelClients, serviceAccount, currentUser, groupRoleProjectIds) => { const GroupModel = Group(modelClients); return async (resource, scope, attributes: IKeycloakAuthAttributes = {}) => { - const currentUserId: string = grant.access_token.content.sub; - - // Check if the same set of permissions has been granted already for this - // api query. - // TODO Is it possible to ask keycloak for ALL permissions (given a project - // or group context) and cache a single query instead? - const cacheKey = `${currentUserId}:${resource}:${scope}:${JSON.stringify( - attributes - )}`; - const cachedPermissions = requestCache.get(cacheKey); - if (cachedPermissions === true) { - return true; - } else if (!cachedPermissions === false) { - userActivityLogger.user_info( - `User does not have permission to '${scope}' on '${resource}'`, - { - user: grant ? grant.access_token.content : null - } - ); - throw new KeycloakUnauthorizedError( - `Unauthorized: You don't have permission to "${scope}" on "${resource}": ${JSON.stringify( - attributes - )}` - ); - } - - // Check the redis cache before doing a full keycloak lookup. - const resourceScope = { resource, scope, currentUserId, ...attributes }; - let redisCacheResult: number; - try { - const data = await getRedisCache(resourceScope); - redisCacheResult = parseInt(data, 10); - } catch (err) { - logger.warn(`Couldn't check redis authz cache: ${err.message}`); - } - - if (redisCacheResult === 1) { - return true; - } else if (redisCacheResult === 0) { - userActivityLogger.user_info( - `User does not have permission to '${scope}' on '${resource}'`, - { - user: grant.access_token.content - } - ); - throw new KeycloakUnauthorizedError( - `Unauthorized: You don't have permission to "${scope}" on "${resource}": ${JSON.stringify( - attributes - )}` - ); - } - - const currentUser = await UserModel.loadUserById(currentUserId); - const serviceAccount = await keycloakGrantManager.obtainFromClientCredentials(); let claims: { currentUser: [string]; @@ -169,9 +158,10 @@ export const keycloakHasPermission = (grant, requestCache, modelClients) => { userProjectRole?: [string]; userGroupRole?: [string]; } = { - currentUser: [currentUserId] + currentUser: [currentUser.id] }; + const usersAttribute = R.prop('users', attributes); if (usersAttribute && usersAttribute.length) { claims = { @@ -182,7 +172,7 @@ export const keycloakHasPermission = (grant, requestCache, modelClients) => { R.prop('users') )(attributes) ], - currentUser: [currentUserId] + currentUser: [currentUser.id] }; } @@ -197,30 +187,14 @@ export const keycloakHasPermission = (grant, requestCache, modelClients) => { projectQuery: [`${projectId}`] }; - const userProjects = await UserModel.getAllProjectsIdsForUser( - currentUser - ); - - if (userProjects.length) { + const [highestRoleForProject, upids] = getUserRoleForProjectFromRoleProjectIds(groupRoleProjectIds, projectId) + if (upids.length) { claims = { ...claims, - userProjects: [userProjects.join('-')] + userProjects: [upids.join('-')] }; } - const roles = await UserModel.getUserRolesForProject( - currentUser, - projectId - ); - - const highestRoleForProject = R.pipe( - R.uniq, - R.reject(R.isEmpty), - R.reject(R.isNil), - R.sort(sortRolesByWeight), - R.last - )(roles); - if (highestRoleForProject) { claims = { ...claims, @@ -242,7 +216,7 @@ export const keycloakHasPermission = (grant, requestCache, modelClients) => { const groupRoles = R.pipe( R.filter(membership => - R.pathEq(['user', 'id'], currentUserId, membership) + R.pathEq(['user', 'id'], currentUser.id, membership) ), R.pluck('role') )(group.members); @@ -308,13 +282,6 @@ export const keycloakHasPermission = (grant, requestCache, modelClients) => { ); if (newGrant.access_token.hasPermission(resource, scope)) { - requestCache.set(cacheKey, true); - try { - await saveRedisCache(resourceScope, 1); - } catch (err) { - logger.warn(`Couldn't save redis authz cache: ${err.message}`); - } - return; } } catch (err) { @@ -323,18 +290,11 @@ export const keycloakHasPermission = (grant, requestCache, modelClients) => { userActivityLogger.user_info( `User does not have permission to '${scope}' on '${resource}'`, { - user: currentUser + user: currentUser.id } ); } - requestCache.set(cacheKey, false); - // TODO: Re-enable when we can distinguish between error and access denied - // try { - // await saveRedisCache(resourceScope, 0); - // } catch (err) { - // logger.warn(`Couldn't save redis authz cache: ${err.message}`); - // } userActivityLogger.user_info( `User does not have permission to '${scope}' on '${resource}'`, {