Skip to content

Commit

Permalink
feat: support globs for org approvals (#9367)
Browse files Browse the repository at this point in the history
* feat: refactor 4 sql queries to kysely

Signed-off-by: Matt Krick <matt.krick@gmail.com>

* feat: refactor removeApprovalOD to kysely

Signed-off-by: Matt Krick <matt.krick@gmail.com>

* feat: support wildcards in OrgApprovalDomains rules

Signed-off-by: Matt Krick <matt.krick@gmail.com>

* support org approval for invites

Signed-off-by: Matt Krick <matt.krick@gmail.com>

---------

Signed-off-by: Matt Krick <matt.krick@gmail.com>
  • Loading branch information
mattkrick committed Jan 26, 2024
1 parent bd0347b commit 822ee57
Show file tree
Hide file tree
Showing 18 changed files with 81 additions and 115 deletions.
2 changes: 2 additions & 0 deletions packages/client/validation/regex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ export const compositeIdRegex = /^[a-zA-Z0-9\-_|]{5,35}::[a-zA-Z0-9\-_|]{5,35}$/
// export const cvcRegex = /[0-9]{3,4}$/;
export const domainRegex =
/^((?!-))(xn--)?[a-z0-9][a-z0-9-_]{0,61}[a-z0-9]{0,1}\.(xn--)?([a-z0-9\-]{1,61}|[a-z0-9-]{1,30}\.[a-z]{2,})$/

export const domainWithWildcardRegex = /^(\*\.)?([\w-]+\.)+[\w-]+$/
24 changes: 17 additions & 7 deletions packages/server/dataloader/customLoaderMakers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ import MeetingTemplate from '../database/types/MeetingTemplate'
import OrganizationUser from '../database/types/OrganizationUser'
import {Reactable, ReactableEnum} from '../database/types/Reactable'
import Task, {TaskStatusEnum} from '../database/types/Task'
import isValid from '../graphql/isValid'
import {Organization} from '../graphql/public/resolverTypes'
import {SAMLSource} from '../graphql/public/types/SAML'
import getKysely from '../postgres/getKysely'
import {TeamMeetingTemplate} from '../postgres/pg.d'
import {IGetLatestTaskEstimatesQueryResult} from '../postgres/queries/generated/getLatestTaskEstimatesQuery'
import getApprovedOrganizationDomainsByDomainFromPG from '../postgres/queries/getApprovedOrganizationDomainsByDomainFromPG'
import getApprovedOrganizationDomainsFromPG from '../postgres/queries/getApprovedOrganizationDomainsFromPG'
import getGitHubAuthByUserIdTeamId, {
GitHubAuth
} from '../postgres/queries/getGitHubAuthByUserIdTeamId'
Expand All @@ -27,15 +27,13 @@ import getLatestTaskEstimates from '../postgres/queries/getLatestTaskEstimates'
import getMeetingTaskEstimates, {
MeetingTaskEstimatesResult
} from '../postgres/queries/getMeetingTaskEstimates'
import {Team} from '../postgres/queries/getTeamsByIds'
import {AnyMeeting, MeetingTypeEnum} from '../postgres/types/Meeting'
import getRedis from '../utils/getRedis'
import isUserVerified from '../utils/isUserVerified'
import NullableDataLoader from './NullableDataLoader'
import RootDataLoader from './RootDataLoader'
import normalizeResults from './normalizeResults'
import {Team} from '../postgres/queries/getTeamsByIds'
import {Organization} from '../graphql/public/resolverTypes'
import isValid from '../graphql/isValid'

export interface MeetingSettingsKey {
teamId: string
Expand Down Expand Up @@ -349,7 +347,13 @@ export const meetingSettingsByType = (parent: RootDataLoader) => {
export const organizationApprovedDomainsByOrgId = (parent: RootDataLoader) => {
return new DataLoader<string, string[], string>(
async (orgIds) => {
const currentApprovals = await getApprovedOrganizationDomainsFromPG(orgIds)
const pg = getKysely()
const currentApprovals = await pg
.selectFrom('OrganizationApprovedDomain')
.selectAll()
.where('orgId', 'in', orgIds)
.where('removedAt', 'is', null)
.execute()
return orgIds.map((orgId) => {
return currentApprovals
.filter((approval) => approval.orgId === orgId)
Expand All @@ -365,7 +369,13 @@ export const organizationApprovedDomainsByOrgId = (parent: RootDataLoader) => {
export const organizationApprovedDomains = (parent: RootDataLoader) => {
return new DataLoader<string, boolean, string>(
async (domains) => {
const currentApprovals = await getApprovedOrganizationDomainsByDomainFromPG(domains)
const pg = getKysely()
const currentApprovals = await pg
.selectFrom('OrganizationApprovedDomain')
.selectAll()
.where('domain', 'in', domains)
.where('removedAt', 'is', null)
.execute()
return domains.map((domain) => {
return !!currentApprovals.find((approval) => approval.domain === domain)
})
Expand Down
10 changes: 8 additions & 2 deletions packages/server/graphql/private/mutations/changeEmailDomain.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import {sql} from 'kysely'
import {r} from 'rethinkdb-ts'
import {RDatum, RValue} from '../../../database/stricterR'
import getKysely from '../../../postgres/getKysely'
import getUsersbyDomain from '../../../postgres/queries/getUsersByDomain'
import updateDomainsInOrganizationApprovedDomainToPG from '../../../postgres/queries/updateDomainsInOrganizationApprovedDomainToPG'
import updateUserEmailDomainsToPG from '../../../postgres/queries/updateUserEmailDomainsToPG'
import {MutationResolvers} from '../../private/resolverTypes'

Expand Down Expand Up @@ -48,7 +48,13 @@ const changeEmailDomain: MutationResolvers['changeEmailDomain'] = async (

const [updatedUserRes] = await Promise.all([
updateUserEmailDomainsToPG(normalizedNewDomain, userIdsToUpdate),
updateDomainsInOrganizationApprovedDomainToPG(normalizedOldDomain, normalizedNewDomain),
pg
.updateTable('OrganizationApprovedDomain')
.set({
domain: sql`REPLACE("domain", ${normalizedOldDomain}, ${normalizedNewDomain})`
})
.where('domain', 'like', normalizedOldDomain)
.execute(),
r
.table('Organization')
.filter((row: RDatum) => row('activeDomain').eq(normalizedOldDomain))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {domainRegex, emailRegex} from 'parabol-client/validation/regex'
import insertApprovedOrganizationDomains from '../../../postgres/queries/insertApprovedOrganizationDomains'
import {domainRegex, domainWithWildcardRegex, emailRegex} from 'parabol-client/validation/regex'
import getKysely from '../../../postgres/getKysely'
import {getUserId} from '../../../utils/authorization'
import {MutationResolvers} from '../resolverTypes'

Expand All @@ -9,18 +9,35 @@ const addApprovedOrganizationDomains: MutationResolvers['addApprovedOrganization
{authToken}
) => {
const viewerId = getUserId(authToken)
const pg = getKysely()

// VALIDATION
if (emailDomains.length < 1) {
return {error: {message: 'Must include at least 1 email domain'}}
}

const normalizedEmailDomains = emailDomains.map((domain) => domain.toLowerCase().trim())
const invalidEmailDomain = normalizedEmailDomains.find(
(domain) => !emailRegex.test(domain) && !domainRegex.test(domain)
(domain) =>
!emailRegex.test(domain) && !domainRegex.test(domain) && !domainWithWildcardRegex.test(domain)
)
if (invalidEmailDomain) {
return {error: {message: `${invalidEmailDomain} is not a valid domain or email`}}
}

// RESOLUTION
await insertApprovedOrganizationDomains(orgId, viewerId, normalizedEmailDomains)
const approvals = normalizedEmailDomains.map((domain) => ({
addedByUserId: viewerId,
orgId,
domain
}))

await pg
.insertInto('OrganizationApprovedDomain')
.values(approvals)
.onConflict((oc) => oc.doNothing())
.execute()

const data = {orgId}
return data
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,15 @@ const getIsEmailApprovedByOrg = async (
) => {
const approvedDomains = await dataLoader.get('organizationApprovedDomainsByOrgId').load(orgId)
if (approvedDomains.length === 0) return undefined
const isApproved = approvedDomains.some((domain) => email.endsWith(domain))
const exactDomain = email.split('@')[1]!

// search for wildcards, too
const [tld, domain] = exactDomain.split('.').reverse()
const wildcardDomain = `*.${domain}.${tld}`
const isApproved = approvedDomains.some(
(domain) => email.endsWith(domain) || domain === wildcardDomain
)

if (!isApproved) {
const domainList = approvedDomains.join(', ')
const message = `Cannot accept invitation. Your email must end with ${domainList}`
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,21 @@
import createEmailVerficationForExistingUser from '../../../../email/createEmailVerficationForExistingUser'
import {DataLoaderWorker} from '../../../graphql'
import getIsEmailApprovedByOrg from './getIsEmailApprovedByOrg'
const getIsUserIdApprovedByOrg = async (
userId: string,
orgId: string,
dataLoader: DataLoaderWorker,
invitationToken?: string
) => {
const approvedDomains = await dataLoader.get('organizationApprovedDomainsByOrgId').load(orgId)
if (approvedDomains.length === 0) return undefined
const organizationUser = await dataLoader
.get('organizationUsersByUserIdOrgId')
.load({userId, orgId})
// if they're in the organization, no approval needed
if (organizationUser) return undefined
const user = await dataLoader.get('users').loadNonNull(userId)
const {email, identities} = user
const isApproved = approvedDomains.some((domain) => email.endsWith(domain))
if (!isApproved) {
const maybeError = await getIsEmailApprovedByOrg(email, orgId, dataLoader)
if (maybeError) {
const message = `Your email is not on your company's approved list of users. Please reach out to your account admin or support@parabol.co for more information`
return new Error(message)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import removeApprovedOrganizationDomainsToPG from '../../../postgres/queries/removeApprovedOrganizationDomainsToPG'
import {sql} from 'kysely'
import getKysely from '../../../postgres/getKysely'
import {MutationResolvers} from '../resolverTypes'

const removeApprovedOrganizationDomains: MutationResolvers['removeApprovedOrganizationDomains'] =
Expand All @@ -7,7 +8,15 @@ const removeApprovedOrganizationDomains: MutationResolvers['removeApprovedOrgani
const normalizedEmailDomains = emailDomains.map((domain) => domain.toLowerCase().trim())

// RESOLUTION
await removeApprovedOrganizationDomainsToPG(orgId, normalizedEmailDomains)
const pg = getKysely()

await pg
.updateTable('OrganizationApprovedDomain')
.set({removedAt: sql`CURRENT_TIMESTAMP`})
.where('orgId', '=', orgId)
.where('domain', 'in', normalizedEmailDomains)
.execute()

const data = {orgId}
return data
}
Expand Down

This file was deleted.

This file was deleted.

This file was deleted.

This file was deleted.

This file was deleted.

This file was deleted.

This file was deleted.

This file was deleted.

This file was deleted.

This file was deleted.

14 changes: 10 additions & 4 deletions packages/server/utils/isEmailVerificationRequired.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import {DataLoaderWorker} from '../graphql/graphql'

const isEmailVerificationRequired = async (email: string, dataLoader: DataLoaderWorker) => {
const domain = email.split('@')[1]!
const [approvedEmail, approvedDomain] = await Promise.all([
const exactDomain = email.split('@')[1]!

// search for wildcards, too
const [tld, domain] = exactDomain.split('.').reverse()
const wildcardDomain = `*.${domain}.${tld}`

const [approvedEmail, approvedDomain, approvedWildcardDomain] = await Promise.all([
dataLoader.get('organizationApprovedDomains').load(email),
dataLoader.get('organizationApprovedDomains').load(domain)
dataLoader.get('organizationApprovedDomains').load(exactDomain),
dataLoader.get('organizationApprovedDomains').load(wildcardDomain)
])
return approvedEmail || approvedDomain
return approvedEmail || approvedDomain || approvedWildcardDomain
}

export default isEmailVerificationRequired

0 comments on commit 822ee57

Please sign in to comment.