Skip to content

Commit

Permalink
chore: Add orgId to IntegrationProvider (#6014)
Browse files Browse the repository at this point in the history
  • Loading branch information
Dschoordsch authored Aug 1, 2024
1 parent 31cd317 commit 6819e90
Show file tree
Hide file tree
Showing 40 changed files with 363 additions and 165 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ const MSTeamsPanel = (props: Props) => {
{
provider: {
id: activeProvider.id,
teamId,
scope: 'team',
webhookProviderMetadataInput: {
webhookUrl
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ const MattermostPanel = (props: Props) => {
provider: {
id: activeProvider.id,
scope: 'team',
teamId,
webhookProviderMetadataInput: {
webhookUrl
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {UpdateIntegrationProviderMutation as TUpdateIntegrationProviderMutation}
import {StandardMutation} from '../types/relayMutations'

graphql`
fragment UpdateIntegrationProviderMutation_team on UpdateIntegrationProviderSuccess {
fragment UpdateIntegrationProviderMutation_organization on UpdateIntegrationProviderSuccess {
provider {
id
... on IntegrationProviderWebhook {
Expand All @@ -27,7 +27,7 @@ const mutation = graphql`
message
}
}
...UpdateIntegrationProviderMutation_team @relay(mask: false)
...UpdateIntegrationProviderMutation_organization @relay(mask: false)
}
}
`
Expand Down
3 changes: 3 additions & 0 deletions packages/client/subscriptions/OrganizationSubscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ const subscription = graphql`
SetOrgUserRoleSuccess {
...SetOrgUserRoleMutation_organization @relay(mask: false)
}
UpdateIntegrationProviderSuccess {
...UpdateIntegrationProviderMutation_organization @relay(mask: false)
}
UpdateTemplateScopeSuccess {
...UpdateReflectTemplateScopeMutation_organization @relay(mask: false)
}
Expand Down
3 changes: 0 additions & 3 deletions packages/client/subscriptions/TeamSubscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,9 +181,6 @@ const subscription = graphql`
OldUpgradeToTeamTierPayload {
...OldUpgradeToTeamTierMutation_team @relay(mask: false)
}
UpdateIntegrationProviderSuccess {
...UpdateIntegrationProviderMutation_team @relay(mask: false)
}
AddIntegrationProviderSuccess {
...AddIntegrationProviderMutation_team @relay(mask: false)
}
Expand Down
33 changes: 33 additions & 0 deletions packages/server/__tests__/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,39 @@ export const getUserTeams = async (userId: string) => {
return user.data.user.teams as [{id: string}, ...{id: string}[]]
}

export const getUserOrgs = async (userId: string) => {
const user = await sendIntranet({
query: `
query User($userId: ID!) {
user(userId: $userId) {
id
organizations {
id
}
}
}
`,
variables: {
userId
},
isPrivate: true
})

expect(user).toMatchObject({
data: {
user: {
id: userId,
organizations: expect.arrayContaining([
{
id: expect.anything()
}
])
}
}
})
return user.data.user.organizations as [{id: string}, ...{id: string}[]]
}

export const createPGTables = async (...tables: string[]) => {
const pg = getKysely()
await Promise.all(
Expand Down
9 changes: 6 additions & 3 deletions packages/server/__tests__/jiraServerIntegration.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {getUserTeams, sendPublic, signUp} from './common'
import {getUserOrgs, getUserTeams, sendPublic, signUp} from './common'

const serverBaseUrl = 'https://jira.example.com/'
const consumerKey = 'CvSE+9fww8PLH07mWTHKUZMiGyX7liUSFbB1pRLVDyQ='
Expand All @@ -22,6 +22,7 @@ xCRxttXw+TEbs5T2EQJBANPcs2ztuKos+j0eYBKzhFDWccEYtBOLvJE5uUaxUa8v
test('Add integration provider', async () => {
const {userId, authToken} = await signUp()

const orgId = (await getUserOrgs(userId))[0].id
const teamId = (await getUserTeams(userId))[0].id

const addIntegrationProvider = await sendPublic({
Expand All @@ -34,6 +35,7 @@ test('Add integration provider', async () => {
id
isActive
teamId
orgId
... on IntegrationProviderOAuth1 {
serverBaseUrl
}
Expand All @@ -44,7 +46,7 @@ test('Add integration provider', async () => {
`,
variables: {
input: {
teamId,
orgId,
service: 'jiraServer',
authStrategy: 'oauth1',
scope: 'org',
Expand All @@ -65,7 +67,8 @@ test('Add integration provider', async () => {
provider: {
id: expect.anything(),
isActive: true,
teamId,
orgId,
teamId: null,
serverBaseUrl
}
}
Expand Down
39 changes: 30 additions & 9 deletions packages/server/dataloader/integrationAuthLoaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ import SlackAuth from '../database/types/SlackAuth'
import SlackNotification, {SlackNotificationEvent} from '../database/types/SlackNotification'
import errorFilter from '../graphql/errorFilter'
import isValid from '../graphql/isValid'
import getKysely from '../postgres/getKysely'
import {IGetBestTeamIntegrationAuthQueryResult} from '../postgres/queries/generated/getBestTeamIntegrationAuthQuery'
import {IntegrationProviderServiceEnum} from '../postgres/queries/generated/getIntegrationProvidersByIdsQuery'
import {IGetTeamMemberIntegrationAuthQueryResult} from '../postgres/queries/generated/getTeamMemberIntegrationAuthQuery'
import getBestTeamIntegrationAuth from '../postgres/queries/getBestTeamIntegrationAuth'
import getIntegrationProvidersByIds, {
TIntegrationProvider
} from '../postgres/queries/getIntegrationProvidersByIds'
import getSharedIntegrationProviders from '../postgres/queries/getSharedIntegrationProviders'
import getTeamMemberIntegrationAuth from '../postgres/queries/getTeamMemberIntegrationAuth'
import NullableDataLoader from './NullableDataLoader'
import RootDataLoader from './RootDataLoader'
Expand All @@ -25,8 +25,9 @@ interface TeamMemberIntegrationAuthPrimaryKey {

interface SharedIntegrationProviderKey {
service: IntegrationProviderServiceEnum
/// All team ids belonging to the organization, used for scope === 'org'
orgTeamIds: string[]
/// Query with 'org' scope by orgId
orgIds: string[]
/// Query with 'team' scope by teamId
teamIds: string[]
}

Expand All @@ -53,13 +54,33 @@ export const integrationProviders = (parent: RootDataLoader) => {
export const sharedIntegrationProviders = (parent: RootDataLoader) => {
return new DataLoader<SharedIntegrationProviderKey, TIntegrationProvider[], string>(
async (keys) => {
const results = await Promise.allSettled(
keys.map(async ({service, orgTeamIds, teamIds}) =>
getSharedIntegrationProviders(service, orgTeamIds, teamIds)
// slightly overfetching with the services here to keep the query simple
const services = Array.from(new Set(keys.map(({service}) => service)))
const orgIds = Array.from(new Set(keys.flatMap(({orgIds}) => orgIds)))
const teamIds = Array.from(new Set(keys.flatMap(({teamIds}) => teamIds)))

const pg = getKysely()
const results = await pg
.selectFrom('IntegrationProvider')
.selectAll()
.where(({and, or, eb}) =>
and([
eb('service', 'in', services),
eb('isActive', '=', true),
or([eb('scope', '!=', 'team'), eb('teamId', 'in', [...teamIds, ''])]),
or([eb('scope', '!=', 'org'), eb('orgId', 'in', [...orgIds, ''])])
])
)
)
const vals = results.map((result) => (result.status === 'fulfilled' ? result.value : []))
return vals
.execute()
return keys.map(({service, orgIds, teamIds}) =>
results.filter(
(row) =>
row.service === service &&
(row.scope === 'global' ||
(row.scope === 'org' && row.orgId && orgIds.includes(row.orgId)) ||
(row.scope === 'team' && row.teamId && teamIds.includes(row.teamId)))
)
) as TIntegrationProvider[][]
},
{
...parent.dataLoaderOptions
Expand Down
59 changes: 46 additions & 13 deletions packages/server/graphql/mutations/addIntegrationProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {GraphQLNonNull} from 'graphql'
import {SubscriptionChannel} from 'parabol-client/types/constEnums'
import {isNotNull} from 'parabol-client/utils/predicates'
import upsertIntegrationProvider from '../../postgres/queries/upsertIntegrationProvider'
import {isSuperUser, isTeamMember} from '../../utils/authorization'
import {getUserId, isSuperUser, isTeamMember, isUserOrgAdmin} from '../../utils/authorization'
import publish from '../../utils/publish'
import {GQLContext} from '../graphql'
import AddIntegrationProviderInput, {
Expand Down Expand Up @@ -30,20 +30,38 @@ const addIntegrationProvider = {
context: GQLContext
) => {
const {authToken, dataLoader, socketId: mutatorId} = context
const {teamId, scope} = input
const {teamId, orgId, scope} = input
const viewerId = getUserId(authToken)
const operationId = dataLoader.share()
const subOptions = {mutatorId, operationId}

// INPUT VALIDATION
if (scope === 'global' && (teamId || orgId)) {
return {error: {message: 'Global providers must not have an `orgId` nor `teamId`'}}
}
if (scope === 'org' && (!orgId || teamId)) {
return {error: {message: 'Org providers must have an `orgId` and no `teamId`'}}
}
if (scope === 'team' && (!teamId || orgId)) {
return {error: {message: 'Team providers must have a `teamId` and no `orgId`'}}
}

// AUTH
if (scope === 'global') {
if (!isSuperUser(authToken)) {
return {error: {message: 'Global scope requires su'}}
if (!isSuperUser(authToken)) {
if (scope === 'global') {
return {error: {message: 'Must be a super user to add a global provider'}}
}
if (teamId !== 'aGhostTeam') {
return {error: {message: 'Global scope requires teamId to be aGhostTeam'}}
if (scope === 'org' && !isUserOrgAdmin(viewerId, orgId!, dataLoader)) {
return {
error: {
message:
'Must be an organization admin to add an integration provider on organization level'
}
}
}
if (scope === 'team' && !isTeamMember(authToken, teamId!)) {
return {error: {message: 'Must be on the team for the integration provider'}}
}
} else if (!isTeamMember(authToken, teamId) && !isSuperUser(authToken)) {
return {error: {message: 'Must be on the team for which the provider is created'}}
}

// VALIDATION
Expand Down Expand Up @@ -74,18 +92,33 @@ const addIntegrationProvider = {
return {error: {message: 'Exactly 1 metadata provider is expected'}}
}

const resolvedOrgId =
orgId || (teamId ? (await dataLoader.get('teams').loadNonNull(teamId)).orgId : null)

// RESOLUTION
const providerId = await upsertIntegrationProvider({
authStrategy,
...rest,
...oAuth1ProviderMetadataInput,
...oAuth2ProviderMetadataInput,
...webhookProviderMetadataInput
...webhookProviderMetadataInput,
...(scope === 'global'
? {orgId: null, teamId: null}
: scope === 'org'
? {orgId, teamId: null}
: {orgId: null, teamId})
})

//TODO: add proper subscription scope handling here, teamId only exists in provider with team scope
const data = {teamId, providerId}
publish(SubscriptionChannel.TEAM, teamId, 'AddIntegrationProviderSuccess', data, subOptions)
const data = {providerId}
if (resolvedOrgId) {
publish(
SubscriptionChannel.ORGANIZATION,
resolvedOrgId,
'AddIntegrationProviderSuccess',
data,
subOptions
)
}
return data
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {phaseLabelLookup} from 'parabol-client/utils/meetings/lookups'
import appOrigin from '../../../../appOrigin'
import Meeting from '../../../../database/types/Meeting'
import {SlackNotificationEventEnum as EventEnum} from '../../../../database/types/SlackNotification'
import {IntegrationProviderMSTeams} from '../../../../postgres/queries/getIntegrationProvidersByIds'
import {IntegrationProviderMSTeams as IIntegrationProviderMSTeams} from '../../../../postgres/queries/getIntegrationProvidersByIds'
import IUser from '../../../../postgres/types/IUser'
import {MeetingTypeEnum} from '../../../../postgres/types/Meeting'
import MSTeamsServerManager from '../../../../utils/MSTeamsServerManager'
Expand Down Expand Up @@ -36,6 +36,8 @@ const notifyMSTeams = async (

return 'success'
}

type IntegrationProviderMSTeams = IIntegrationProviderMSTeams & {teamId: string}
export type MSTeamsNotificationAuth = IntegrationProviderMSTeams & {userId: string; email: string}

const createTeamPromptMeetingTitle = (meetingName: string) => `*${meetingName}* is open 💬`
Expand Down Expand Up @@ -338,7 +340,7 @@ async function getMSTeams(dataLoader: DataLoaderWorker, teamId: string, userId:
dataLoader.get('bestTeamIntegrationProviders').load({service: 'msTeams', teamId, userId}),
dataLoader.get('users').loadNonNull(userId)
])
return provider
return provider && provider.teamId
? [
MSTeamsNotificationHelper({
...(provider as IntegrationProviderMSTeams),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {phaseLabelLookup} from 'parabol-client/utils/meetings/lookups'
import appOrigin from '../../../../appOrigin'
import Meeting from '../../../../database/types/Meeting'
import {SlackNotificationEventEnum as EventEnum} from '../../../../database/types/SlackNotification'
import {IntegrationProviderMattermost} from '../../../../postgres/queries/getIntegrationProvidersByIds'
import {IntegrationProviderMattermost as IIntegrationProviderMattermost} from '../../../../postgres/queries/getIntegrationProvidersByIds'
import IUser from '../../../../postgres/types/IUser'
import {MeetingTypeEnum} from '../../../../postgres/types/Meeting'
import MattermostServerManager from '../../../../utils/MattermostServerManager'
Expand All @@ -25,6 +25,8 @@ import {
makeHackedFieldButtonValue
} from './makeMattermostAttachments'

type IntegrationProviderMattermost = IIntegrationProviderMattermost & {teamId: string}

const notifyMattermost = async (
event: EventEnum,
webhookUrl: string,
Expand Down Expand Up @@ -347,7 +349,7 @@ async function getMattermost(dataLoader: DataLoaderWorker, teamId: string, userI
const provider = await dataLoader
.get('bestTeamIntegrationProviders')
.load({service: 'mattermost', teamId, userId})
return provider
return provider && provider.teamId
? [
MattermostNotificationHelper({
...(provider as IntegrationProviderMattermost),
Expand Down
16 changes: 12 additions & 4 deletions packages/server/graphql/mutations/removeIntegrationProvider.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {GraphQLID, GraphQLNonNull} from 'graphql'
import IntegrationProviderId from 'parabol-client/shared/gqlIds/IntegrationProviderId'
import removeIntegrationProviderQuery from '../../postgres/queries/removeIntegrationProvider'
import {getUserId, isTeamMember} from '../../utils/authorization'
import {getUserId, isSuperUser, isTeamMember, isUserOrgAdmin} from '../../utils/authorization'
import standardError from '../../utils/standardError'
import {GQLContext} from '../graphql'
import RemoveIntegrationProviderPayload from '../types/RemoveIntegrationProviderPayload'
Expand All @@ -27,10 +27,18 @@ const removeIntegrationProvider = {
const providerDbId = IntegrationProviderId.split(providerId)
const provider = await dataLoader.get('integrationProviders').load(providerDbId)
if (!provider) return standardError(new Error('Integration Provider not found'))
const {teamId} = provider
const {teamId, orgId, scope} = provider

if (!isTeamMember(authToken, teamId)) {
return {error: {message: 'Must be on the team that created the provider'}}
if (!isSuperUser(authToken)) {
if (scope === 'global') {
return {error: {message: 'Must be a super user to remove a global provider'}}
}
if (scope === 'org' && !isUserOrgAdmin(viewerId, orgId!, dataLoader)) {
return {error: {message: 'Must be a member of the organization that created the provider'}}
}
if (scope === 'team' && !isTeamMember(authToken, teamId!)) {
return {error: {message: 'Must be on the team that created the provider'}}
}
}

// RESOLUTION
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,8 @@ const addTeamMemberIntegrationAuth: MutationResolvers['addTeamMemberIntegrationA
return {error: {message: 'teamId mismatch'}}
}
} else if (scope === 'org' && teamId !== integrationProvider.teamId) {
const [providerTeam, authTeam] = await Promise.all([
dataLoader.get('teams').loadNonNull(integrationProvider.teamId),
dataLoader.get('teams').loadNonNull(teamId)
])
if (providerTeam.orgId !== authTeam.orgId) {
const authTeam = await dataLoader.get('teams').loadNonNull(teamId)
if (integrationProvider.orgId !== authTeam.orgId) {
return {error: {message: 'provider not available for this team'}}
}
}
Expand Down
Loading

0 comments on commit 6819e90

Please sign in to comment.