From 9985ce0c117a20251c024bac1387201bd3240d14 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 5 Sep 2019 06:16:21 -0500 Subject: [PATCH 1/4] Add Project > Groups API resolver --- .../src/helpers/sync-groups-searchguard.ts | 7 ++- services/api/src/models/group.ts | 54 ++++++++++++++++++- services/api/src/models/user.ts | 42 ++++++++------- services/api/src/resolvers.js | 2 + services/api/src/resources/group/resolvers.ts | 28 ++++++++++ services/api/src/typeDefs.js | 4 ++ 6 files changed, 112 insertions(+), 25 deletions(-) diff --git a/services/api/src/helpers/sync-groups-searchguard.ts b/services/api/src/helpers/sync-groups-searchguard.ts index 44590cd2d1..b3cda1b227 100755 --- a/services/api/src/helpers/sync-groups-searchguard.ts +++ b/services/api/src/helpers/sync-groups-searchguard.ts @@ -44,11 +44,10 @@ const refreshToken = async keycloakAdminClient => { for (const group of groups) { await refreshToken(keycloakAdminClient); - const loadedGroup = await GroupModel.loadGroupById(group.id); - logger.debug(`Processing ${loadedGroup.name}`); - const projectIdsArray = await GroupModel.getProjectsFromGroupAndSubgroups(loadedGroup) + logger.debug(`Processing ${group.name}`); + const projectIdsArray = await GroupModel.getProjectsFromGroupAndSubgroups(group) const projectIds = R.join(',')(projectIdsArray) - await SearchguardOperations(sqlClient, GroupModel).syncGroup(loadedGroup.name, projectIds); + await SearchguardOperations(sqlClient, GroupModel).syncGroup(group.name, projectIds); } logger.info('Migration completed'); diff --git a/services/api/src/models/group.ts b/services/api/src/models/group.ts index d584ced6a9..296bad90c2 100644 --- a/services/api/src/models/group.ts +++ b/services/api/src/models/group.ts @@ -31,12 +31,18 @@ interface GroupEdit { attributes?: object; } +interface AttributeFilterFn { + (attribute: { name: string, value: string[] }, group: Group): boolean; +} + interface GroupModel { loadAllGroups: () => Promise; loadGroupById: (id: string) => Promise; loadGroupByName: (name: string) => Promise; loadGroupByIdOrName: (groupInput: GroupEdit) => Promise; loadParentGroup: (groupInput: Group) => Promise; + loadGroupsByAttribute: (filterFn: AttributeFilterFn) => Promise; + loadGroupsByProjectId: (projectId: number) => Promise; getProjectsFromGroupAndParents: (group: Group) => Promise, getProjectsFromGroupAndSubgroups: (group: Group) => Promise, addGroup: (groupInput: Group) => Promise; @@ -160,7 +166,17 @@ const loadGroupByIdOrName = async (groupInput: GroupEdit): Promise => { const loadAllGroups = async (): Promise => { const keycloakGroups = await keycloakAdminClient.groups.find(); - const groups = await transformKeycloakGroups(keycloakGroups); + let fullGroups: Group[] = []; + for (const group of keycloakGroups) { + const fullGroup = await loadGroupById(group.id); + + fullGroups = [ + ...fullGroups, + fullGroup, + ]; + } + + const groups = await transformKeycloakGroups(fullGroups); return groups; }; @@ -175,6 +191,40 @@ const loadParentGroup = async (groupInput: Group): Promise => asyncPipe( ]), )(groupInput); +const loadGroupsByAttribute = async (filterFn: AttributeFilterFn): Promise => { + const allGroups = await loadAllGroups(); + + const filteredGroups = R.filter((group: Group) => R.pipe( + R.toPairs, + R.reduce((isMatch: boolean, attribute: [string, string[]]): boolean => { + if (!isMatch) { + return filterFn({ + name: attribute[0], + value: attribute[1], + }, group); + } + + return isMatch; + }, false), + )(group.attributes) + )(allGroups); + + return filteredGroups; +}; + +const loadGroupsByProjectId = async (projectId: number): 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); + } + + return false; + } + + return loadGroupsByAttribute(filterFn); +} + // Recursive function to load projects "up" the group chain const getProjectsFromGroupAndParents = async ( group: Group, @@ -494,6 +544,8 @@ export const Group = (): GroupModel => ({ loadGroupByName, loadGroupByIdOrName, loadParentGroup, + loadGroupsByAttribute, + loadGroupsByProjectId, getProjectsFromGroupAndParents, getProjectsFromGroupAndSubgroups, addGroup, diff --git a/services/api/src/models/user.ts b/services/api/src/models/user.ts index 7d51c0406d..812e1c9de5 100644 --- a/services/api/src/models/user.ts +++ b/services/api/src/models/user.ts @@ -29,6 +29,7 @@ interface UserModel { loadUserById: (id: string) => Promise; loadUserByUsername: (username: string) => Promise; loadUserByIdOrUsername: (userInput: UserEdit) => Promise; + getAllGroupsForUser: (userInput: User) => Promise; getAllProjectsIdsForUser: (userInput: User) => Promise; getUserRolesForProject: ( userInput: User, @@ -201,11 +202,9 @@ const loadAllUsers = async (): Promise => { return users; }; - - -const getAllProjectsIdsForUser = async (userInput: User): Promise => { +const getAllGroupsForUser = async (userInput: User): Promise => { const GroupModel = Group(); - let projects = []; + let groups = []; const roleSubgroups = await keycloakAdminClient.users.listGroups({ id: userInput.id, @@ -221,7 +220,20 @@ const getAllProjectsIdsForUser = async (userInput: User): Promise => { fullRoleSubgroup, ); - const projectIds = await GroupModel.getProjectsFromGroupAndSubgroups(roleSubgroupParent); + groups.push(roleSubgroupParent); + } + + return groups; +} + +const getAllProjectsIdsForUser = async (userInput: User): Promise => { + const GroupModel = Group(); + let projects = []; + + const userGroups = await getAllGroupsForUser(userInput); + + for (const group of userGroups) { + const projectIds = await GroupModel.getProjectsFromGroupAndSubgroups(group); projects = [...projects, ...projectIds]; } @@ -234,22 +246,11 @@ const getUserRolesForProject = async ( ): Promise => { const GroupModel = Group(); - const roleSubgroups = await keycloakAdminClient.users.listGroups({ - id: userInput.id, - }); + const userGroups = await getAllGroupsForUser(userInput); let roles = []; - for (const roleSubgroup of roleSubgroups) { - const fullRoleSubgroup = await GroupModel.loadGroupById(roleSubgroup.id); - if (!isRoleSubgroup(fullRoleSubgroup)) { - continue; - } - - const roleSubgroupParent = await GroupModel.loadParentGroup( - fullRoleSubgroup, - ); - - const projectIds = await GroupModel.getProjectsFromGroupAndSubgroups(roleSubgroupParent); + for (const group of userGroups) { + const projectIds = await GroupModel.getProjectsFromGroupAndSubgroups(group); if (projectIds.includes(projectId)) { const groupRoles = R.pipe( @@ -257,7 +258,7 @@ const getUserRolesForProject = async ( R.pathEq(['user', 'id'], userInput.id, membership), ), R.pluck('role'), - )(roleSubgroupParent.members); + )(group.members); roles = [...roles, ...groupRoles]; } @@ -354,6 +355,7 @@ export const User = (): UserModel => ({ loadUserById, loadUserByUsername, loadUserByIdOrUsername, + getAllGroupsForUser, getAllProjectsIdsForUser, getUserRolesForProject, addUser, diff --git a/services/api/src/resolvers.js b/services/api/src/resolvers.js index a047d4a6fc..1b22f140aa 100644 --- a/services/api/src/resolvers.js +++ b/services/api/src/resolvers.js @@ -109,6 +109,7 @@ const { } = require('./resources/user/resolvers'); const { + getGroupsByProjectId, addGroup, updateGroup, deleteGroup, @@ -156,6 +157,7 @@ const resolvers /* : { [string]: ResolversObj | typeof GraphQLDate } */ = { openshift: getOpenshiftByProjectId, environments: getEnvironmentsByProjectId, envVariables: getEnvVarsByProjectId, + groups: getGroupsByProjectId, }, Environment: { project: getProjectByEnvironmentId, diff --git a/services/api/src/resources/group/resolvers.ts b/services/api/src/resources/group/resolvers.ts index dd4e3f2a1e..5dc154ec9f 100644 --- a/services/api/src/resources/group/resolvers.ts +++ b/services/api/src/resources/group/resolvers.ts @@ -1,9 +1,37 @@ import * as R from 'ramda'; import validator from 'validator'; +import * as logger from '../../logger'; import { isPatchEmpty } from '../../util/db'; import * as projectHelpers from '../project/helpers'; import { SearchguardOperations } from './searchguard'; +export const getGroupsByProjectId = async ( + { id: pid }, + _input, + { hasPermission, dataSources, keycloakGrant }, +) => { + const projectGroups = await dataSources.GroupModel.loadGroupsByProjectId(pid); + + try { + await hasPermission('group', 'viewAll'); + + return projectGroups; + } catch (err) { + if (!keycloakGrant) { + logger.warn('No grant available for getGroupsByProjectId'); + return []; + } + + const user = await dataSources.UserModel.loadUserById( + keycloakGrant.access_token.content.sub, + ); + const userGroups = await dataSources.UserModel.getAllGroupsForUser(user); + const userProjectGroups = R.intersection(projectGroups, userGroups); + + return userProjectGroups; + } +}; + export const addGroup = async (_root, { input }, { dataSources, sqlClient, hasPermission }) => { await hasPermission('group', 'add'); diff --git a/services/api/src/typeDefs.js b/services/api/src/typeDefs.js index e68815a549..e10e9e786f 100644 --- a/services/api/src/typeDefs.js +++ b/services/api/src/typeDefs.js @@ -272,6 +272,10 @@ const typeDefs = gql` Environment variables available during build-time and run-time """ envVariables: [EnvKeyValue] + """ + Which groups are directly linked to project + """ + groups: [Group] } """ From 8136f517e26711083d753df8371dc1229b2adcd4 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 5 Sep 2019 06:33:30 -0500 Subject: [PATCH 2/4] Add allGroups API query --- services/api/src/resolvers.js | 2 + services/api/src/resources/group/resolvers.ts | 38 +++++++++++++++++++ services/api/src/typeDefs.js | 4 ++ 3 files changed, 44 insertions(+) diff --git a/services/api/src/resolvers.js b/services/api/src/resolvers.js index 1b22f140aa..938cb6d454 100644 --- a/services/api/src/resolvers.js +++ b/services/api/src/resolvers.js @@ -109,6 +109,7 @@ const { } = require('./resources/user/resolvers'); const { + getAllGroups, getGroupsByProjectId, addGroup, updateGroup, @@ -209,6 +210,7 @@ const resolvers /* : { [string]: ResolversObj | typeof GraphQLDate } */ = { allProjects: getAllProjects, allOpenshifts: getAllOpenshifts, allEnvironments: getAllEnvironments, + allGroups: getAllGroups, }, Mutation: { addOrUpdateEnvironment, diff --git a/services/api/src/resources/group/resolvers.ts b/services/api/src/resources/group/resolvers.ts index 5dc154ec9f..006af51941 100644 --- a/services/api/src/resources/group/resolvers.ts +++ b/services/api/src/resources/group/resolvers.ts @@ -2,9 +2,47 @@ import * as R from 'ramda'; import validator from 'validator'; import * as logger from '../../logger'; import { isPatchEmpty } from '../../util/db'; +import { GroupNotFoundError } from '../../models/group'; import * as projectHelpers from '../project/helpers'; import { SearchguardOperations } from './searchguard'; +export const getAllGroups = async ( + root, + { name }, + { hasPermission, dataSources, keycloakGrant }, +) => { + try { + await hasPermission('group', 'viewAll'); + + if (name) { + const group = await dataSources.GroupModel.loadGroupByName(name); + return [group]; + } else { + return await dataSources.GroupModel.loadAllGroups(); + } + } catch (err) { + if (err instanceof GroupNotFoundError) { + return []; + } + + if (!keycloakGrant) { + logger.warn('No grant available for getAllGroups'); + return []; + } + + const user = await dataSources.UserModel.loadUserById( + keycloakGrant.access_token.content.sub, + ); + const userGroups = await dataSources.UserModel.getAllGroupsForUser(user); + + if (name) { + return R.filter(R.propEq('name', name), userGroups); + } else { + return userGroups; + } + } +}; + export const getGroupsByProjectId = async ( { id: pid }, _input, diff --git a/services/api/src/typeDefs.js b/services/api/src/typeDefs.js index e10e9e786f..324cf760ae 100644 --- a/services/api/src/typeDefs.js +++ b/services/api/src/typeDefs.js @@ -485,6 +485,10 @@ const typeDefs = gql` Returns all Environments matching given filter (all if no filter defined) """ allEnvironments(createdAfter: String, type: EnvType, order: EnvOrderType): [Environment] + """ + Returns all Groups matching given filter (all if no filter defined) + """ + allGroups(name: String): [Group] } # Must provide id OR name From 286aeae1a44b48c768bc5c6c5e53a7b50ff586ff Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 5 Sep 2019 06:42:08 -0500 Subject: [PATCH 3/4] Add User > Group resolver --- services/api/src/resolvers.js | 2 ++ services/api/src/resources/group/resolvers.ts | 30 +++++++++++++++++++ services/api/src/typeDefs.js | 1 + 3 files changed, 33 insertions(+) diff --git a/services/api/src/resolvers.js b/services/api/src/resolvers.js index 938cb6d454..f41415142a 100644 --- a/services/api/src/resolvers.js +++ b/services/api/src/resolvers.js @@ -111,6 +111,7 @@ const { const { getAllGroups, getGroupsByProjectId, + getGroupsByUserId, addGroup, updateGroup, deleteGroup, @@ -194,6 +195,7 @@ const resolvers /* : { [string]: ResolversObj | typeof GraphQLDate } */ = { }, User: { sshKeys: getUserSshKeys, + groups: getGroupsByUserId, }, Backup: { restore: getRestoreByBackupId, diff --git a/services/api/src/resources/group/resolvers.ts b/services/api/src/resources/group/resolvers.ts index 006af51941..7dcf2d9e72 100644 --- a/services/api/src/resources/group/resolvers.ts +++ b/services/api/src/resources/group/resolvers.ts @@ -70,6 +70,36 @@ export const getGroupsByProjectId = async ( } }; +export const getGroupsByUserId = async ( + { id: uid }, + _input, + { hasPermission, dataSources, keycloakGrant }, +) => { + const queryUser = await dataSources.UserModel.loadUserById( + uid, + ); + const queryUserGroups = await dataSources.UserModel.getAllGroupsForUser(queryUser); + + try { + await hasPermission('group', 'viewAll'); + + return queryUserGroups; + } catch (err) { + if (!keycloakGrant) { + logger.warn('No grant available for getGroupsByUserId'); + return []; + } + + const currentUser = await dataSources.UserModel.loadUserById( + keycloakGrant.access_token.content.sub, + ); + const currentUserGroups = await dataSources.UserModel.getAllGroupsForUser(currentUser); + const bothUserGroups = R.intersection(queryUserGroups, currentUserGroups); + + return bothUserGroups; + } +}; + export const addGroup = async (_root, { input }, { dataSources, sqlClient, hasPermission }) => { await hasPermission('group', 'add'); diff --git a/services/api/src/typeDefs.js b/services/api/src/typeDefs.js index 324cf760ae..f33a09f421 100644 --- a/services/api/src/typeDefs.js +++ b/services/api/src/typeDefs.js @@ -108,6 +108,7 @@ const typeDefs = gql` comment: String gitlabId: Int sshKeys: [SshKey] + groups: [Group] } type GroupMembership { From 2e10221a99ff49552455d930eb16c00838f30f85 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 6 Sep 2019 05:43:50 -0500 Subject: [PATCH 4/4] Add View All Groups permission --- services/keycloak/start.sh | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/services/keycloak/start.sh b/services/keycloak/start.sh index 4ae94e80f6..fa2653e7f5 100755 --- a/services/keycloak/start.sh +++ b/services/keycloak/start.sh @@ -1259,6 +1259,33 @@ EOF # {"type":"scope","logic":"POSITIVE","decisionStrategy":"UNANIMOUS","name":"Backup View","resources":["2ebb5852-6624-4dc6-8374-e1e54a7fd9c5"],"scopes":["8e78b877-f930-43ff-995f-c907af64f69f"],"policies":["d4fae4e2-ddc7-462c-b712-d68aaeb269e1"]} } +function add_group_viewall { + CLIENT_ID=$(/opt/jboss/keycloak/bin/kcadm.sh get -r lagoon clients?clientId=api --config $CONFIG_PATH | python -c 'import sys, json; print json.load(sys.stdin)[0]["id"]') + view_all_groups=$(/opt/jboss/keycloak/bin/kcadm.sh get -r lagoon clients/$CLIENT_ID/authz/resource-server/permission?name=View+All+Groups --config $CONFIG_PATH) + + if [ "$view_all_groups" != "[ ]" ]; then + echo "group:viewAll already configured" + return 0 + fi + + echo Configuring group:viewAll + + GROUP_RESOURCE_ID=$(/opt/jboss/keycloak/bin/kcadm.sh get -r lagoon clients/$CLIENT_ID/authz/resource-server/resource?name=group --config $CONFIG_PATH | python -c 'import sys, json; print json.load(sys.stdin)[0]["_id"]') + /opt/jboss/keycloak/bin/kcadm.sh update clients/$CLIENT_ID/authz/resource-server/resource/$GROUP_RESOURCE_ID --config $CONFIG_PATH -r ${KEYCLOAK_REALM:-master} -s 'scopes=[{"name":"add"},{"name":"update"},{"name":"delete"},{"name":"deleteAll"},{"name":"addUser"},{"name":"removeUser"},{"name":"viewAll"}]' + + /opt/jboss/keycloak/bin/kcadm.sh create clients/$CLIENT_ID/authz/resource-server/permission/scope --config $CONFIG_PATH -r lagoon -f - <