Skip to content

Commit

Permalink
feat: saml upload (#9750)
Browse files Browse the repository at this point in the history
Signed-off-by: Matt Krick <[email protected]>
  • Loading branch information
mattkrick authored May 20, 2024
1 parent d6a775d commit 5c40fcf
Show file tree
Hide file tree
Showing 11 changed files with 209 additions and 59 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import UploadFileIcon from '@mui/icons-material/UploadFile'
import graphql from 'babel-plugin-relay/macro'
import React, {useState} from 'react'
import React, {useRef, useState} from 'react'
import {commitLocalUpdate, useFragment} from 'react-relay'
import orgAuthenticationMetadataQuery, {
OrgAuthenticationMetadataQuery
Expand All @@ -9,6 +10,8 @@ import BasicInput from '../../../../components/InputField/BasicInput'
import SecondaryButton from '../../../../components/SecondaryButton'
import useAtmosphere from '../../../../hooks/useAtmosphere'
import useMutationProps from '../../../../hooks/useMutationProps'
import {useUploadIdPMetadata} from '../../../../mutations/useUploadIdPMetadataMutation'
import {Button} from '../../../../ui/Button/Button'
import getOAuthPopupFeatures from '../../../../utils/getOAuthPopupFeatures'
import getTokenFromSSO from '../../../../utils/getTokenFromSSO'

Expand Down Expand Up @@ -40,6 +43,7 @@ const OrgAuthenticationMetadata = (props: Props) => {
fragment OrgAuthenticationMetadata_saml on SAML {
id
metadataURL
orgId
}
`,
samlRef
Expand All @@ -49,7 +53,7 @@ const OrgAuthenticationMetadata = (props: Props) => {
const isMetadataURLSaved = saml ? saml.metadataURL === metadataURL : false
const {error, onCompleted, onError, submitMutation, submitting} = useMutationProps()
const submitMetadataURL = async () => {
if (submitting) return
if (submitting || !metadataURL) return
submitMutation()
const domain = saml?.id
if (!domain) {
Expand Down Expand Up @@ -99,6 +103,36 @@ const OrgAuthenticationMetadata = (props: Props) => {
key: 'submitMetadata'
})
}

const uploadInputRef = useRef<HTMLInputElement>(null)
const onUploadClick = () => {
uploadInputRef.current?.click()
}
const [commit] = useUploadIdPMetadata()
const uploadXML = (e: React.ChangeEvent<HTMLInputElement>) => {
const {files} = e.currentTarget
const file = files?.[0]
if (!file || !saml?.orgId) return
commit({
variables: {orgId: saml.orgId},
uploadables: {file: file},
onCompleted: (res) => {
const {uploadIdPMetadata} = res
const {error, url} = uploadIdPMetadata
const message = error?.message
if (message) {
atmosphere.eventEmitter.emit('addSnackbar', {
key: 'errorUploadIdPtMetadata',
message,
autoDismiss: 5
})
return
}
setMetadataURL(url!)
}
})
}

return (
<>
<div className='px-6 pb-3'>
Expand All @@ -115,6 +149,17 @@ const OrgAuthenticationMetadata = (props: Props) => {
onChange={(e) => setMetadataURL(e.target.value)}
error={undefined}
/>
<Button className='px-0' variant='ghost' shape='pill' size='sm' onClick={onUploadClick}>
<UploadFileIcon className={'text-xl'} />
Click to upload XML File
</Button>
<input
className='hidden'
accept='.xml'
onChange={uploadXML}
type='file'
ref={uploadInputRef}
/>
</div>
<div className={'px-6 text-tomato-500 empty:hidden'}>{error?.message}</div>
<div className='flex justify-end px-6 pb-8'>
Expand Down
43 changes: 43 additions & 0 deletions packages/client/mutations/useUploadIdPMetadataMutation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import graphql from 'babel-plugin-relay/macro'
import {useMutation} from 'react-relay'
import {useUploadIdPMetadataMutation as TuseUploadIdPMetadataMutation} from '../__generated__/useUploadIdPMetadataMutation.graphql'

const mutation = graphql`
mutation useUploadIdPMetadataMutation($file: File!, $orgId: ID!) {
uploadIdPMetadata(file: $file, orgId: $orgId) {
... on ErrorPayload {
error {
message
}
}
... on UploadIdPMetadataSuccess {
url
}
}
}
`
interface TTuseUploadIdPMetadataMutation extends Omit<TuseUploadIdPMetadataMutation, 'variables'> {
variables: Omit<TuseUploadIdPMetadataMutation['variables'], 'file'>
uploadables: {file: File}
}

export const useUploadIdPMetadata = () => {
const [commit, submitting] = useMutation<TTuseUploadIdPMetadataMutation>(mutation)
type Execute = (
config: Parameters<typeof commit>[0] & {uploadables: {file: File}}
) => ReturnType<typeof commit>

const execute: Execute = (config) => {
const {variables} = config
const {orgId} = variables
return commit({
updater: (store) => {
const org = store.get(orgId)
org?.setValue(orgId, 'id')
},
// allow components to override default handlers
...config
})
}
return [execute, submitting] as const
}
15 changes: 9 additions & 6 deletions packages/client/ui/Button/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {Slot} from '@radix-ui/react-slot'
import clsx from 'clsx'
import React from 'react'
import {twMerge} from 'tailwind-merge'

type Variant = 'primary' | 'secondary' | 'destructive' | 'ghost' | 'link' | 'outline'
type Size = 'sm' | 'md' | 'lg' | 'default'
Expand Down Expand Up @@ -45,12 +46,14 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
const Comp = asChild ? Slot : 'button'
return (
<Comp
className={clsx(
BASE_STYLES,
VARIANT_STYLES[variant],
size ? SIZE_STYLES[size] : null,
SHAPE_STYLES[shape],
className
className={twMerge(
clsx(
BASE_STYLES,
VARIANT_STYLES[variant],
size ? SIZE_STYLES[size] : null,
SHAPE_STYLES[shape],
className
)
)}
ref={ref}
{...props}
Expand Down
5 changes: 5 additions & 0 deletions packages/server/fileStorage/FileStoreManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ export default abstract class FileStoreManager {
return this.putUserFile(file, partialPath)
}

async putOrgIdPMetadata(file: ArrayBufferLike, orgId: string) {
const partialPath = `Organization/${orgId}/idpMetadata.xml`
return this.putUserFile(file, partialPath)
}

async putTemplateIllustration(file: ArrayBufferLike, orgId: string, ext: string, name?: string) {
const filename = name ?? generateUID()
const dotfreeExt = ext.replace(/^\./, '')
Expand Down
24 changes: 24 additions & 0 deletions packages/server/graphql/public/mutations/uploadIdPMetadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import getFileStoreManager from '../../../fileStorage/getFileStoreManager'
import {MutationResolvers} from '../resolverTypes'

const uploadIdPMetadata: MutationResolvers['uploadIdPMetadata'] = async (_, {file, orgId}) => {
// VALIDATION
const {contentType, buffer: jsonBuffer} = file
const buffer = Buffer.from(jsonBuffer.data)
if (!contentType || !contentType.includes('xml')) {
return {error: {message: 'file must be XML'}}
}
if (buffer.byteLength > 1000000) {
return {error: {message: 'file must be less than 1MB'}}
}
if (buffer.byteLength <= 1) {
return {error: {message: 'file must be larger than 1 byte'}}
}

// RESOLUTION
const manager = getFileStoreManager()
const url = await manager.putOrgIdPMetadata(buffer, orgId)
return {url}
}

export default uploadIdPMetadata
12 changes: 7 additions & 5 deletions packages/server/graphql/public/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import {Resolvers} from './resolverTypes'
import getTeamIdFromArgTemplateId from './rules/getTeamIdFromArgTemplateId'
import isAuthenticated from './rules/isAuthenticated'
import isEnvVarTrue from './rules/isEnvVarTrue'
import {isOrgTier, isOrgTierSource} from './rules/isOrgTier'
import {isOrgTier} from './rules/isOrgTier'
import isSuperUser from './rules/isSuperUser'
import isUserViewer from './rules/isUserViewer'
import {isViewerBillingLeader, isViewerBillingLeaderSource} from './rules/isViewerBillingLeader'
import {isViewerBillingLeader} from './rules/isViewerBillingLeader'
import {isViewerOnOrg} from './rules/isViewerOnOrg'
import isViewerOnTeam from './rules/isViewerOnTeam'
import rateLimit from './rules/rateLimit'

Expand Down Expand Up @@ -50,9 +51,10 @@ const permissionMap: PermissionMap<Resolvers> = {
verifyEmail: rateLimit({perMinute: 50, perHour: 100}),
addApprovedOrganizationDomains: or(
isSuperUser,
and(isViewerBillingLeader, isOrgTier('enterprise'))
and(isViewerBillingLeader('args.orgId'), isOrgTier('args.orgId', 'enterprise'))
),
removeApprovedOrganizationDomains: or(isSuperUser, isViewerBillingLeader),
removeApprovedOrganizationDomains: or(isSuperUser, isViewerBillingLeader('args.orgId')),
uploadIdPMetadata: isViewerOnOrg('args.orgId'),
updateTemplateCategory: isViewerOnTeam(getTeamIdFromArgTemplateId)
},
Query: {
Expand All @@ -61,7 +63,7 @@ const permissionMap: PermissionMap<Resolvers> = {
SAMLIdP: rateLimit({perMinute: 120, perHour: 3600})
},
Organization: {
saml: and(isViewerBillingLeaderSource, isOrgTierSource('enterprise'))
saml: and(isViewerBillingLeader('source.id'), isOrgTier('source.id', 'enterprise'))
},
User: {
domains: or(isSuperUser, isUserViewer)
Expand Down
9 changes: 9 additions & 0 deletions packages/server/graphql/public/rules/getResolverDotPath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const getResolverDotPath = (
dotPath: ResolverDotPath,
source: Record<string, any>,
args: Record<string, any>
) => {
return dotPath.split('.').reduce((val: any, key) => val?.[key], {source, args})
}

export type ResolverDotPath = `source.${string}` | `args.${string}`
29 changes: 10 additions & 19 deletions packages/server/graphql/public/rules/isOrgTier.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,16 @@
import {rule} from 'graphql-shield'
import {GQLContext} from '../../graphql'
import {TierEnum} from '../resolverTypes'
import {ResolverDotPath, getResolverDotPath} from './getResolverDotPath'

const resolve = async (requiredTier: TierEnum, orgId: string, {dataLoader}: GQLContext) => {
const organization = await dataLoader.get('organizations').load(orgId)
if (!organization) return new Error('Organization not found')
const {tier} = organization
if (tier !== requiredTier) return new Error(`Organization is not ${requiredTier}`)
return true
}

export const isOrgTierSource = (requiredTier: TierEnum) =>
rule(`isOrgTierSource-${requiredTier}`, {cache: 'strict'})(
async ({id: orgId}, _args, context: GQLContext) => {
return resolve(requiredTier, orgId, context)
}
)

export const isOrgTier = (requiredTier: TierEnum) =>
rule(`isOrgTier-${requiredTier}`, {cache: 'strict'})(
async (_source, {orgId}, context: GQLContext) => {
return resolve(requiredTier, orgId, context)
export const isOrgTier = (orgIdDotPath: ResolverDotPath, requiredTier: TierEnum) =>
rule(`isViewerOnOrg-${orgIdDotPath}-${requiredTier}`, {cache: 'strict'})(
async (source, args, {dataLoader}: GQLContext) => {
const orgId = getResolverDotPath(orgIdDotPath, source, args)
const organization = await dataLoader.get('organizations').load(orgId)
if (!organization) return new Error('Organization not found')
const {tier} = organization
if (tier !== requiredTier) return new Error(`Organization is not ${requiredTier}`)
return true
}
)
43 changes: 16 additions & 27 deletions packages/server/graphql/public/rules/isViewerBillingLeader.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,20 @@
import {rule} from 'graphql-shield'
import {getUserId} from '../../../utils/authorization'
import {GQLContext} from '../../graphql'
import {ResolverDotPath, getResolverDotPath} from './getResolverDotPath'

const resolve = async (orgId: string, {authToken, dataLoader}: GQLContext) => {
const viewerId = getUserId(authToken)
const organizationUser = await dataLoader
.get('organizationUsersByUserIdOrgId')
.load({orgId, userId: viewerId})
if (!organizationUser) return new Error('Organization User not found')
const {role} = organizationUser
if (role !== 'BILLING_LEADER' && role !== 'ORG_ADMIN')
return new Error('User is not billing leader')
return true
}

export const isViewerBillingLeader = rule({cache: 'strict'})(async (
_source,
{orgId},
context: GQLContext
) => {
return resolve(orgId, context)
})

export const isViewerBillingLeaderSource = rule({cache: 'strict'})(async (
{id: orgId},
_args,
context: GQLContext
) => {
return resolve(orgId, context)
})
export const isViewerBillingLeader = (orgIdDotPath: ResolverDotPath) =>
rule(`isViewerBillingLeader-${orgIdDotPath}`, {cache: 'strict'})(
async (source, args, {authToken, dataLoader}: GQLContext) => {
const orgId = getResolverDotPath(orgIdDotPath, source, args)
const viewerId = getUserId(authToken)
const organizationUser = await dataLoader
.get('organizationUsersByUserIdOrgId')
.load({orgId, userId: viewerId})
if (!organizationUser) return new Error('Organization User not found')
const {role} = organizationUser
if (role !== 'BILLING_LEADER' && role !== 'ORG_ADMIN')
return new Error('User is not billing leader')
return true
}
)
17 changes: 17 additions & 0 deletions packages/server/graphql/public/rules/isViewerOnOrg.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {rule} from 'graphql-shield'
import {getUserId} from '../../../utils/authorization'
import {GQLContext} from '../../graphql'
import {ResolverDotPath, getResolverDotPath} from './getResolverDotPath'

export const isViewerOnOrg = (orgIdDotPath: ResolverDotPath) =>
rule(`isViewerOnOrg-${orgIdDotPath}`, {cache: 'strict'})(
async (source, args, {authToken, dataLoader}: GQLContext) => {
const orgId = getResolverDotPath(orgIdDotPath, source, args)
const viewerId = getUserId(authToken)
const organizationUser = await dataLoader
.get('organizationUsersByUserIdOrgId')
.load({orgId, userId: viewerId})
if (!organizationUser) return new Error('Viewer is not on Organization')
return true
}
)
22 changes: 22 additions & 0 deletions packages/server/graphql/public/typeDefs/uploadIdPMetadata.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
extend type Mutation {
"""
Upload the IdP Metadata file for an org for those who cannot self-host the file
"""
uploadIdPMetadata(
"""
the XML Metadata file for the IdP
"""
file: File!

"""
The orgId to upload the IdP Metadata for
"""
orgId: ID!
): UploadIdPMetadataPayload!
}

union UploadIdPMetadataPayload = ErrorPayload | UploadIdPMetadataSuccess

type UploadIdPMetadataSuccess {
url: String!
}

0 comments on commit 5c40fcf

Please sign in to comment.