Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: saml upload #9750

Merged
merged 2 commits into from
May 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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'))
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

curious on your thoughts about this new pattern.
I want to reuse rules as much as I can. For example, this rule requires an orgId to determine if the viewer is on the org. That orgId may exist in an arg, or in the source, and it could have any name. I think a dot notation path is the cleanest way to do this, but wonder what you think?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like it! Not having type safety is my main concern, but that's still probably safer that accidentally mixing isOrgTierSource for isOrgTier

},
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!
}
Loading