diff --git a/.env.docker b/.env.docker index 4413eb6..5cb4d16 100644 --- a/.env.docker +++ b/.env.docker @@ -59,7 +59,6 @@ OTEL_SERVICE_NAME=coop NODE_ENV=production ITEM_QUEUE_TRAFFIC_PERCENTAGE='0' UI_URL=http://localhost:3000 -ENABLE_DEMO_REQUEST=false # ── Optional API keys (uncomment and fill in as needed) ─────────────── # GROQ_SECRET_KEY= diff --git a/.env.githubci b/.env.githubci index 82ec0a8..16cc268 100644 --- a/.env.githubci +++ b/.env.githubci @@ -73,5 +73,3 @@ NODE_ENV=CI ITEM_QUEUE_TRAFFIC_PERCENTAGE='0' UI_URL=http://localhost:3000 - -ENABLE_DEMO_REQUEST=false diff --git a/client/src/graphql/generated.ts b/client/src/graphql/generated.ts index e6920e1..becad59 100644 --- a/client/src/graphql/generated.ts +++ b/client/src/graphql/generated.ts @@ -787,22 +787,6 @@ export type GQLCreateManualReviewQueueResponse = | GQLManualReviewQueueNameExistsError | GQLMutateManualReviewQueueSuccessResponse; -export type GQLCreateOrgInput = { - readonly email: Scalars['String']['input']; - readonly name: Scalars['String']['input']; - readonly website: Scalars['String']['input']; -}; - -export type GQLCreateOrgResponse = - | GQLCreateOrgSuccessResponse - | GQLOrgWithEmailExistsError - | GQLOrgWithNameExistsError; - -export type GQLCreateOrgSuccessResponse = { - readonly __typename: 'CreateOrgSuccessResponse'; - readonly id: Scalars['ID']['output']; -}; - export type GQLCreateReportingRuleInput = { readonly actionIds: ReadonlyArray; readonly conditionSet: GQLConditionSetInput; @@ -2431,7 +2415,6 @@ export type GQLMutation = { readonly createLocationBank: GQLMutateLocationBankResponse; readonly createManualReviewJobComment: GQLAddManualReviewJobCommentResponse; readonly createManualReviewQueue: GQLCreateManualReviewQueueResponse; - readonly createOrg: GQLCreateOrgResponse; readonly createReportingRule: GQLCreateReportingRuleResponse; readonly createRoutingRule: GQLCreateRoutingRuleResponse; readonly createTextBank: GQLMutateBankResponse; @@ -2465,7 +2448,6 @@ export type GQLMutation = { readonly removeFavoriteRule: GQLRemoveFavoriteRuleSuccessResponse; readonly renameRole: GQLRole; readonly reorderRoutingRules: GQLReorderRoutingRulesResponse; - readonly requestDemo?: Maybe; readonly resetPassword: Scalars['Boolean']['output']; /** * Retries a previously-failed NCMEC submission. Org-scoped: callers can only @@ -2569,10 +2551,6 @@ export type GQLMutationCreateManualReviewQueueArgs = { input: GQLCreateManualReviewQueueInput; }; -export type GQLMutationCreateOrgArgs = { - input: GQLCreateOrgInput; -}; - export type GQLMutationCreateReportingRuleArgs = { input: GQLCreateReportingRuleInput; }; @@ -2701,10 +2679,6 @@ export type GQLMutationReorderRoutingRulesArgs = { input: GQLReorderRoutingRulesInput; }; -export type GQLMutationRequestDemoArgs = { - input: GQLRequestDemoInput; -}; - export type GQLMutationResetPasswordArgs = { input: GQLResetPasswordInput; }; @@ -3147,26 +3121,6 @@ export type GQLOrgSignalsArgs = { customOnly?: InputMaybe; }; -export type GQLOrgWithEmailExistsError = GQLError & { - readonly __typename: 'OrgWithEmailExistsError'; - readonly detail?: Maybe; - readonly pointer?: Maybe; - readonly requestId?: Maybe; - readonly status: Scalars['Int']['output']; - readonly title: Scalars['String']['output']; - readonly type: ReadonlyArray; -}; - -export type GQLOrgWithNameExistsError = GQLError & { - readonly __typename: 'OrgWithNameExistsError'; - readonly detail?: Maybe; - readonly pointer?: Maybe; - readonly requestId?: Maybe; - readonly status: Scalars['Int']['output']; - readonly title: Scalars['String']['output']; - readonly type: ReadonlyArray; -}; - /** Information about the current page in a connection. */ export type GQLPageInfo = { readonly __typename: 'PageInfo'; @@ -3326,7 +3280,6 @@ export type GQLQuery = { readonly __typename: 'Query'; readonly action?: Maybe; readonly actionStatistics: ReadonlyArray; - readonly allOrgs: ReadonlyArray; readonly allRuleInsights?: Maybe; readonly apiKey: Scalars['String']['output']; readonly appealSettings?: Maybe; @@ -3865,24 +3818,6 @@ export const GQLReportingRuleStatus = { export type GQLReportingRuleStatus = (typeof GQLReportingRuleStatus)[keyof typeof GQLReportingRuleStatus]; -export type GQLRequestDemoInput = { - readonly company: Scalars['String']['input']; - readonly email: Scalars['String']['input']; - readonly interests: ReadonlyArray; - readonly isFromGoogleAds: Scalars['Boolean']['input']; - readonly ref: Scalars['String']['input']; - readonly website: Scalars['String']['input']; -}; - -export const GQLRequestDemoInterest = { - AutomatedEnforcement: 'AUTOMATED_ENFORCEMENT', - ComplianceToolkit: 'COMPLIANCE_TOOLKIT', - CustomAiModels: 'CUSTOM_AI_MODELS', - ModeratorConsole: 'MODERATOR_CONSOLE', -} as const; - -export type GQLRequestDemoInterest = - (typeof GQLRequestDemoInterest)[keyof typeof GQLRequestDemoInterest]; export type GQLResetPasswordInput = { readonly newPassword: Scalars['String']['input']; readonly token: Scalars['String']['input']; diff --git a/server/.env.example b/server/.env.example index 86480c1..96a7e75 100644 --- a/server/.env.example +++ b/server/.env.example @@ -95,9 +95,6 @@ NOREPLY_EMAIL=noreply@example.com SUPPORT_EMAIL=support@example.com TEAM_EMAIL=team@example.com -# Set to 'true' to enable the public demo request endpoint -ENABLE_DEMO_REQUEST=false - # Selects the NCMEC CyberTipline endpoint that "Submit to NCMEC" decisions are # routed to. Anything other than the literal string `production` (including # being unset) sends submissions to https://exttest.cybertip.org (the NCMEC diff --git a/server/graphql/datasources/OrgApi.test.ts b/server/graphql/datasources/OrgApi.test.ts index 1214eb7..3d043c0 100644 --- a/server/graphql/datasources/OrgApi.test.ts +++ b/server/graphql/datasources/OrgApi.test.ts @@ -7,7 +7,6 @@ import createOrg from '../../test/fixtureHelpers/createOrg.js'; import { makeMockedServer } from '../../test/setupMockedServer.js'; import { makeTestWithFixture } from '../../test/utils.js'; import { CoopError } from '../../utils/errors.js'; -import { kyselyOrgDeleteById } from './orgKyselyPersistence.js'; import { kyselyUserDeleteById, kyselyUserInsert, @@ -295,71 +294,4 @@ describe('OrgAPI', () => { }, ); }); - - describe('createOrg', () => { - testWithFixture( - 'throws a BadRequest with /input/website pointer for bad website', - async ({ deps }) => { - await expect( - deps.OrgAPIDataSource.createOrg({ - input: { - name: `NewOrg_${uid()}`, - email: `new_${uid()}@example.com`, - // eslint-disable-next-line no-script-url - website: 'javascript:alert(1)', - }, - }), - ).rejects.toMatchObject({ - name: 'BadRequestError', - pointer: '/input/website', - }); - }, - ); - - testWithFixture( - 'throws a BadRequest for malformed email', - async ({ deps }) => { - await expect( - deps.OrgAPIDataSource.createOrg({ - input: { - name: `NewOrg_${uid()}`, - email: 'not-an-email', - website: 'https://example.com', - }, - }), - ).rejects.toMatchObject({ - name: 'BadRequestError', - pointer: '/input/email', - }); - }, - ); - - testWithFixture( - 'seeds the built-in actions for the new org', - async ({ deps }) => { - const created = await deps.OrgAPIDataSource.createOrg({ - input: { - name: `NewOrg_${uid()}`, - email: `new_${uid()}@example.com`, - website: 'https://example.com', - }, - }); - try { - const actions = await deps.ModerationConfigService.getActions({ - orgId: created.id, - }); - const actionTypes = actions.map((a) => a.actionType).sort(); - expect(actionTypes).toEqual( - [ - 'ENQUEUE_AUTHOR_TO_MRT', - 'ENQUEUE_TO_MRT', - 'ENQUEUE_TO_NCMEC', - ].sort(), - ); - } finally { - await kyselyOrgDeleteById(deps.KyselyPg, created.id); - } - }, - ); - }); }); diff --git a/server/graphql/datasources/OrgApi.ts b/server/graphql/datasources/OrgApi.ts index a6e5856..ce9a187 100644 --- a/server/graphql/datasources/OrgApi.ts +++ b/server/graphql/datasources/OrgApi.ts @@ -1,7 +1,5 @@ import crypto from 'node:crypto'; import { URL } from 'node:url'; -import { type Exception } from '@opentelemetry/api'; -import { uid } from 'uid'; import { inject, type Dependencies } from '../../iocContainer/index.js'; import { CoopEmailAddress } from '../../services/sendEmailService/index.js'; @@ -14,23 +12,13 @@ import { type ErrorInstanceData, } from '../../utils/errors.js'; import { WEEK_MS } from '../../utils/time.js'; +import { type GQLInviteUserInput } from '../generated.js'; import { - type GQLInviteUserInput, - type GQLMutationCreateOrgArgs, - type GQLRequestDemoInput, -} from '../generated.js'; -import { - kyselyOrgDeleteById, - kyselyOrgFindAll, - kyselyOrgFindByEmail, kyselyOrgFindById, - kyselyOrgFindByName, - kyselyOrgInsert, kyselyOrgUpdate, type GraphQLOrgParent, } from './orgKyselyPersistence.js'; import { - validateOrgCreateInput, validateOrgUpdatePatch, type OrgValidationFailure, } from './orgValidation.js'; @@ -54,92 +42,6 @@ class OrgAPI { private readonly kysely: Dependencies['KyselyPg'], ) {} - async createOrg(params: GQLMutationCreateOrgArgs) { - const { email, name, website } = params.input; - - const validation = validateOrgCreateInput({ - name, - email, - websiteUrl: website, - }); - if (!validation.ok) { - throw orgValidationFailureToBadRequestError( - validation.failure, - 'createOrg', - ); - } - - const existingOrgByName = await kyselyOrgFindByName(this.kysely, name); - if (existingOrgByName != null) { - throw makeOrgNameExistsError({ shouldErrorSpan: true }); - } - const existingOrgByEmail = await kyselyOrgFindByEmail(this.kysely, email); - - if (existingOrgByEmail != null) { - throw makeOrgEmailExistsError({ shouldErrorSpan: true }); - } - - const id = uid(); - - // Insert the org first so FK-dependent inserts (api_keys, signing_keys, - // and everything in the Promise.all below) have a parent row to reference. - // Past versions of this resolver created the api_key first, which fails - // against the non-deferred `api_keys.org_id` FK to `public.orgs(id)`. - const org = await kyselyOrgInsert({ - db: this.kysely, - id, - email, - name, - websiteUrl: website, - }); - - try { - const { record } = await this.apiKeyService.createApiKey( - id, - 'Main API Key', - 'Primary API key for organization', - null, - ); - await this.signingKeyPairService.createAndStoreSigningKeys(id); - - // Backfill api_key_id on the org now that the key exists. - await this.kysely - .updateTable('public.orgs') - .set({ api_key_id: record.id, updated_at: new Date() }) - .where('id', '=', id) - .execute(); - - await Promise.all([ - // This should ideally be done in one transaction, but we can update - // this after we move off of sequelize - this.moderationConfigService.createDefaultUserType(id), - this.moderationConfigService.upsertBuiltInActions(id), - this.orgCreationLogger.logOrgCreated(id, name, email, website), - this.userManagementService.upsertOrgDefaultUserInterfaceSettings({ - orgId: id, - }), - this.orgSettingsService.upsertOrgDefaultSettings({ orgId: id }), - this.manualReviewToolService.upsertDefaultSettings({ orgId: id }), - ]); - - return org; - } catch (e) { - // Roll back the org insert so we don't leave a half-provisioned row. - // `ON DELETE CASCADE` on org_id FKs cleans up any child rows that did - // make it (api_keys, signing_keys, item_types, etc.). - await kyselyOrgDeleteById(this.kysely, id).catch(() => { - // Swallow cleanup failures: the original error is more useful to the - // caller, and the next createOrg attempt will hit the unique-name - // / unique-email guards above and surface a clear conflict. - }); - const activeSpan = this.tracer.getActiveSpan(); - if (activeSpan?.isRecording()) { - activeSpan.recordException(e as Exception); - } - throw e; - } - } - // Create invite token and optionally send email async inviteUser(input: GQLInviteUserInput, orgId: string) { const { email, role } = input; @@ -200,27 +102,6 @@ class OrgAPI { return token; } - async requestDemo(input: GQLRequestDemoInput) { - if (process.env.ENABLE_DEMO_REQUEST !== 'true') { - return false; - } - const { email, company, website, interests, ref, isFromGoogleAds } = input; - const msg = { - to: CoopEmailAddress.Support, - from: CoopEmailAddress.NoReply, - subject: '[URGENT] Demo Request', - text: `A new potential user has requested a Coop demo.\n\nEmail address: ${email}\n\nCompany name: ${company}\n\nCompany website: ${website}\n\nInterests: ${interests.join( - ', ', - )} \n\nRef: ${ref} \n\nIs from Google Ads: ${isFromGoogleAds}`, - }; - try { - await this.sendEmail(msg); - } catch (error: unknown) { - return false; - } - return true; - } - async getGraphQLOrgFromId(id: string): Promise { const org = await kyselyOrgFindById(this.kysely, id); if (org == null) { @@ -229,10 +110,6 @@ class OrgAPI { return org; } - async getAllGraphQLOrgs(): Promise { - return kyselyOrgFindAll(this.kysely); - } - async updateOrgInfo( orgId: string, input: { @@ -244,10 +121,7 @@ class OrgAPI { ): Promise { const validation = validateOrgUpdatePatch(input); if (!validation.ok) { - throw orgValidationFailureToBadRequestError( - validation.failure, - 'updateOrgInfo', - ); + throw orgValidationFailureToBadRequestError(validation.failure); } const updated = await kyselyOrgUpdate(this.kysely, orgId, { @@ -321,45 +195,16 @@ class OrgAPI { } export type OrgErrorType = - | 'OrgWithEmailExistsError' - | 'OrgWithNameExistsError' | 'InviteUserTokenExpiredError' | 'InviteUserTokenMissingError'; -function orgValidationFailureToBadRequestError( - failure: OrgValidationFailure, - mutation: 'createOrg' | 'updateOrgInfo', -) { - // `createOrg` exposes `websiteUrl` as `website` in its GraphQL input; - // `updateOrgInfo` uses the same field name. - const gqlField = - mutation === 'createOrg' && failure.field === 'websiteUrl' - ? 'website' - : failure.field; +function orgValidationFailureToBadRequestError(failure: OrgValidationFailure) { return makeBadRequestError(failure.message, { - pointer: `/input/${gqlField}`, + pointer: `/input/${failure.field}`, shouldErrorSpan: false, }); } -export const makeOrgEmailExistsError = (data: ErrorInstanceData) => - new CoopError({ - status: 409, - type: [ErrorType.UniqueViolation], - title: 'An org with this email already exists', - name: 'OrgWithEmailExistsError', - ...data, - }); - -export const makeOrgNameExistsError = (data: ErrorInstanceData) => - new CoopError({ - status: 409, - type: [ErrorType.UniqueViolation], - title: 'An org with this name already exists', - name: 'OrgWithNameExistsError', - ...data, - }); - export const makeInviteUserTokenExpiredError = (data: ErrorInstanceData) => new CoopError({ status: 403, diff --git a/server/graphql/datasources/orgKyselyPersistence.ts b/server/graphql/datasources/orgKyselyPersistence.ts index 0609c37..5aaf85c 100644 --- a/server/graphql/datasources/orgKyselyPersistence.ts +++ b/server/graphql/datasources/orgKyselyPersistence.ts @@ -79,17 +79,6 @@ export async function kyselyOrgFindByEmail( return row === undefined ? undefined : rowToGraphQLOrgParent(row); } -export async function kyselyOrgFindAll( - db: OrgsDb, -): Promise { - const rows = await db - .selectFrom('public.orgs') - .selectAll() - .orderBy('name', 'asc') - .execute(); - return rows.map(rowToGraphQLOrgParent); -} - export async function kyselyOrgInsert(opts: { db: OrgsDb; id: string; diff --git a/server/graphql/generated.ts b/server/graphql/generated.ts index 7a97786..e7e7c97 100644 --- a/server/graphql/generated.ts +++ b/server/graphql/generated.ts @@ -855,22 +855,6 @@ export type GQLCreateManualReviewQueueResponse = | GQLManualReviewQueueNameExistsError | GQLMutateManualReviewQueueSuccessResponse; -export type GQLCreateOrgInput = { - readonly email: Scalars['String']['input']; - readonly name: Scalars['String']['input']; - readonly website: Scalars['String']['input']; -}; - -export type GQLCreateOrgResponse = - | GQLCreateOrgSuccessResponse - | GQLOrgWithEmailExistsError - | GQLOrgWithNameExistsError; - -export type GQLCreateOrgSuccessResponse = { - readonly __typename?: 'CreateOrgSuccessResponse'; - readonly id: Scalars['ID']['output']; -}; - export type GQLCreateReportingRuleInput = { readonly actionIds: ReadonlyArray; readonly conditionSet: GQLConditionSetInput; @@ -2499,7 +2483,6 @@ export type GQLMutation = { readonly createLocationBank: GQLMutateLocationBankResponse; readonly createManualReviewJobComment: GQLAddManualReviewJobCommentResponse; readonly createManualReviewQueue: GQLCreateManualReviewQueueResponse; - readonly createOrg: GQLCreateOrgResponse; readonly createReportingRule: GQLCreateReportingRuleResponse; readonly createRoutingRule: GQLCreateRoutingRuleResponse; readonly createTextBank: GQLMutateBankResponse; @@ -2533,7 +2516,6 @@ export type GQLMutation = { readonly removeFavoriteRule: GQLRemoveFavoriteRuleSuccessResponse; readonly renameRole: GQLRole; readonly reorderRoutingRules: GQLReorderRoutingRulesResponse; - readonly requestDemo?: Maybe; readonly resetPassword: Scalars['Boolean']['output']; /** * Retries a previously-failed NCMEC submission. Org-scoped: callers can only @@ -2637,10 +2619,6 @@ export type GQLMutationCreateManualReviewQueueArgs = { input: GQLCreateManualReviewQueueInput; }; -export type GQLMutationCreateOrgArgs = { - input: GQLCreateOrgInput; -}; - export type GQLMutationCreateReportingRuleArgs = { input: GQLCreateReportingRuleInput; }; @@ -2769,10 +2747,6 @@ export type GQLMutationReorderRoutingRulesArgs = { input: GQLReorderRoutingRulesInput; }; -export type GQLMutationRequestDemoArgs = { - input: GQLRequestDemoInput; -}; - export type GQLMutationResetPasswordArgs = { input: GQLResetPasswordInput; }; @@ -3215,26 +3189,6 @@ export type GQLOrgSignalsArgs = { customOnly?: InputMaybe; }; -export type GQLOrgWithEmailExistsError = GQLError & { - readonly __typename?: 'OrgWithEmailExistsError'; - readonly detail?: Maybe; - readonly pointer?: Maybe; - readonly requestId?: Maybe; - readonly status: Scalars['Int']['output']; - readonly title: Scalars['String']['output']; - readonly type: ReadonlyArray; -}; - -export type GQLOrgWithNameExistsError = GQLError & { - readonly __typename?: 'OrgWithNameExistsError'; - readonly detail?: Maybe; - readonly pointer?: Maybe; - readonly requestId?: Maybe; - readonly status: Scalars['Int']['output']; - readonly title: Scalars['String']['output']; - readonly type: ReadonlyArray; -}; - /** Information about the current page in a connection. */ export type GQLPageInfo = { readonly __typename?: 'PageInfo'; @@ -3394,7 +3348,6 @@ export type GQLQuery = { readonly __typename?: 'Query'; readonly action?: Maybe; readonly actionStatistics: ReadonlyArray; - readonly allOrgs: ReadonlyArray; readonly allRuleInsights?: Maybe; readonly apiKey: Scalars['String']['output']; readonly appealSettings?: Maybe; @@ -3933,24 +3886,6 @@ export const GQLReportingRuleStatus = { export type GQLReportingRuleStatus = (typeof GQLReportingRuleStatus)[keyof typeof GQLReportingRuleStatus]; -export type GQLRequestDemoInput = { - readonly company: Scalars['String']['input']; - readonly email: Scalars['String']['input']; - readonly interests: ReadonlyArray; - readonly isFromGoogleAds: Scalars['Boolean']['input']; - readonly ref: Scalars['String']['input']; - readonly website: Scalars['String']['input']; -}; - -export const GQLRequestDemoInterest = { - AutomatedEnforcement: 'AUTOMATED_ENFORCEMENT', - ComplianceToolkit: 'COMPLIANCE_TOOLKIT', - CustomAiModels: 'CUSTOM_AI_MODELS', - ModeratorConsole: 'MODERATOR_CONSOLE', -} as const; - -export type GQLRequestDemoInterest = - (typeof GQLRequestDemoInterest)[keyof typeof GQLRequestDemoInterest]; export type GQLResetPasswordInput = { readonly newPassword: Scalars['String']['input']; readonly token: Scalars['String']['input']; @@ -5329,10 +5264,6 @@ export type GQLResolversUnionTypes<_RefType extends Record> = { | (Omit & { data: _RefType['ManualReviewQueue']; }); - CreateOrgResponse: - | GQLCreateOrgSuccessResponse - | GQLOrgWithEmailExistsError - | GQLOrgWithNameExistsError; CreateReportingRuleResponse: | (Omit & { data: _RefType['ReportingRule']; @@ -5582,8 +5513,6 @@ export type GQLResolversInterfaceTypes< | GQLMissingRequiredPolicyForDecisionError | GQLNoJobWithIdInQueueError | GQLNotFoundError - | GQLOrgWithEmailExistsError - | GQLOrgWithNameExistsError | GQLPartialItemsEndpointResponseError | GQLPartialItemsInvalidResponseError | GQLPartialItemsMissingEndpointError @@ -5759,11 +5688,6 @@ export type GQLResolversTypes = { CreateManualReviewQueueResponse: ResolverTypeWrapper< GQLResolversUnionTypes['CreateManualReviewQueueResponse'] >; - CreateOrgInput: GQLCreateOrgInput; - CreateOrgResponse: ResolverTypeWrapper< - GQLResolversUnionTypes['CreateOrgResponse'] - >; - CreateOrgSuccessResponse: ResolverTypeWrapper; CreateReportingRuleInput: GQLCreateReportingRuleInput; CreateReportingRuleResponse: ResolverTypeWrapper< GQLResolversUnionTypes['CreateReportingRuleResponse'] @@ -6166,8 +6090,6 @@ export type GQLResolversTypes = { OpenAiIntegrationApiCredential: ResolverTypeWrapper; OpenAiIntegrationApiCredentialInput: GQLOpenAiIntegrationApiCredentialInput; Org: ResolverTypeWrapper; - OrgWithEmailExistsError: ResolverTypeWrapper; - OrgWithNameExistsError: ResolverTypeWrapper; PageInfo: ResolverTypeWrapper; PartialItemsEndpointResponseError: ResolverTypeWrapper; PartialItemsInvalidResponseError: ResolverTypeWrapper; @@ -6248,8 +6170,6 @@ export type GQLResolversTypes = { ReportingRuleNameExistsError: ResolverTypeWrapper; ReportingRulePassRateData: ResolverTypeWrapper; ReportingRuleStatus: GQLReportingRuleStatus; - RequestDemoInput: GQLRequestDemoInput; - RequestDemoInterest: GQLRequestDemoInterest; ResetPasswordInput: GQLResetPasswordInput; ResolvedJobCount: ResolverTypeWrapper; RetryNcmecSubmissionResponse: ResolverTypeWrapper; @@ -6586,9 +6506,6 @@ export type GQLResolversParentTypes = { CreateManualReviewJobCommentInput: GQLCreateManualReviewJobCommentInput; CreateManualReviewQueueInput: GQLCreateManualReviewQueueInput; CreateManualReviewQueueResponse: GQLResolversUnionTypes['CreateManualReviewQueueResponse']; - CreateOrgInput: GQLCreateOrgInput; - CreateOrgResponse: GQLResolversUnionTypes['CreateOrgResponse']; - CreateOrgSuccessResponse: GQLCreateOrgSuccessResponse; CreateReportingRuleInput: GQLCreateReportingRuleInput; CreateReportingRuleResponse: GQLResolversUnionTypes['CreateReportingRuleResponse']; CreateRoutingRuleInput: GQLCreateRoutingRuleInput; @@ -6877,8 +6794,6 @@ export type GQLResolversParentTypes = { OpenAiIntegrationApiCredential: GQLOpenAiIntegrationApiCredential; OpenAiIntegrationApiCredentialInput: GQLOpenAiIntegrationApiCredentialInput; Org: GraphQLOrgParent; - OrgWithEmailExistsError: GQLOrgWithEmailExistsError; - OrgWithNameExistsError: GQLOrgWithNameExistsError; PageInfo: GQLPageInfo; PartialItemsEndpointResponseError: GQLPartialItemsEndpointResponseError; PartialItemsInvalidResponseError: GQLPartialItemsInvalidResponseError; @@ -6948,7 +6863,6 @@ export type GQLResolversParentTypes = { ReportingRuleInsights: ReportingRuleWithoutVersion; ReportingRuleNameExistsError: GQLReportingRuleNameExistsError; ReportingRulePassRateData: GQLReportingRulePassRateData; - RequestDemoInput: GQLRequestDemoInput; ResetPasswordInput: GQLResetPasswordInput; ResolvedJobCount: GQLResolvedJobCount; RetryNcmecSubmissionResponse: GQLRetryNcmecSubmissionResponse; @@ -8269,29 +8183,6 @@ export type GQLCreateManualReviewQueueResponseResolvers< >; }; -export type GQLCreateOrgResponseResolvers< - ContextType = Context, - ParentType extends GQLResolversParentTypes['CreateOrgResponse'] = - GQLResolversParentTypes['CreateOrgResponse'], -> = { - __resolveType: TypeResolveFn< - | 'CreateOrgSuccessResponse' - | 'OrgWithEmailExistsError' - | 'OrgWithNameExistsError', - ParentType, - ContextType - >; -}; - -export type GQLCreateOrgSuccessResponseResolvers< - ContextType = Context, - ParentType extends GQLResolversParentTypes['CreateOrgSuccessResponse'] = - GQLResolversParentTypes['CreateOrgSuccessResponse'], -> = { - id?: Resolver; - __isTypeOf?: IsTypeOfResolverFn; -}; - export type GQLCreateReportingRuleResponseResolvers< ContextType = Context, ParentType extends GQLResolversParentTypes['CreateReportingRuleResponse'] = @@ -8839,8 +8730,6 @@ export type GQLErrorResolvers< | 'MissingRequiredPolicyForDecisionError' | 'NoJobWithIdInQueueError' | 'NotFoundError' - | 'OrgWithEmailExistsError' - | 'OrgWithNameExistsError' | 'PartialItemsEndpointResponseError' | 'PartialItemsInvalidResponseError' | 'PartialItemsMissingEndpointError' @@ -10869,12 +10758,6 @@ export type GQLMutationResolvers< ContextType, RequireFields >; - createOrg?: Resolver< - GQLResolversTypes['CreateOrgResponse'], - ParentType, - ContextType, - RequireFields - >; createReportingRule?: Resolver< GQLResolversTypes['CreateReportingRuleResponse'], ParentType, @@ -11072,12 +10955,6 @@ export type GQLMutationResolvers< ContextType, RequireFields >; - requestDemo?: Resolver< - Maybe, - ParentType, - ContextType, - RequireFields - >; resetPassword?: Resolver< GQLResolversTypes['Boolean'], ParentType, @@ -11816,66 +11693,6 @@ export type GQLOrgResolvers< websiteUrl?: Resolver; }; -export type GQLOrgWithEmailExistsErrorResolvers< - ContextType = Context, - ParentType extends GQLResolversParentTypes['OrgWithEmailExistsError'] = - GQLResolversParentTypes['OrgWithEmailExistsError'], -> = { - detail?: Resolver< - Maybe, - ParentType, - ContextType - >; - pointer?: Resolver< - Maybe, - ParentType, - ContextType - >; - requestId?: Resolver< - Maybe, - ParentType, - ContextType - >; - status?: Resolver; - title?: Resolver; - type?: Resolver< - ReadonlyArray, - ParentType, - ContextType - >; - __isTypeOf?: IsTypeOfResolverFn; -}; - -export type GQLOrgWithNameExistsErrorResolvers< - ContextType = Context, - ParentType extends GQLResolversParentTypes['OrgWithNameExistsError'] = - GQLResolversParentTypes['OrgWithNameExistsError'], -> = { - detail?: Resolver< - Maybe, - ParentType, - ContextType - >; - pointer?: Resolver< - Maybe, - ParentType, - ContextType - >; - requestId?: Resolver< - Maybe, - ParentType, - ContextType - >; - status?: Resolver; - title?: Resolver; - type?: Resolver< - ReadonlyArray, - ParentType, - ContextType - >; - __isTypeOf?: IsTypeOfResolverFn; -}; - export type GQLPageInfoResolvers< ContextType = Context, ParentType extends GQLResolversParentTypes['PageInfo'] = @@ -12211,11 +12028,6 @@ export type GQLQueryResolvers< ContextType, RequireFields >; - allOrgs?: Resolver< - ReadonlyArray, - ParentType, - ContextType - >; allRuleInsights?: Resolver< Maybe, ParentType, @@ -14849,8 +14661,6 @@ export type GQLResolvers = { CreateBacktestResponse?: GQLCreateBacktestResponseResolvers; CreateContentRuleResponse?: GQLCreateContentRuleResponseResolvers; CreateManualReviewQueueResponse?: GQLCreateManualReviewQueueResponseResolvers; - CreateOrgResponse?: GQLCreateOrgResponseResolvers; - CreateOrgSuccessResponse?: GQLCreateOrgSuccessResponseResolvers; CreateReportingRuleResponse?: GQLCreateReportingRuleResponseResolvers; CreateRoutingRuleResponse?: GQLCreateRoutingRuleResponseResolvers; CreateUserRuleResponse?: GQLCreateUserRuleResponseResolvers; @@ -15001,8 +14811,6 @@ export type GQLResolvers = { Notification?: GQLNotificationResolvers; OpenAiIntegrationApiCredential?: GQLOpenAiIntegrationApiCredentialResolvers; Org?: GQLOrgResolvers; - OrgWithEmailExistsError?: GQLOrgWithEmailExistsErrorResolvers; - OrgWithNameExistsError?: GQLOrgWithNameExistsErrorResolvers; PageInfo?: GQLPageInfoResolvers; PartialItemsEndpointResponseError?: GQLPartialItemsEndpointResponseErrorResolvers; PartialItemsInvalidResponseError?: GQLPartialItemsInvalidResponseErrorResolvers; diff --git a/server/graphql/modules/org.resolver.test.ts b/server/graphql/modules/org.resolver.test.ts index 929cf1f..9d1d0c8 100644 --- a/server/graphql/modules/org.resolver.test.ts +++ b/server/graphql/modules/org.resolver.test.ts @@ -198,4 +198,52 @@ describe('Org resolvers', () => { ); }); }); + + describe('Org.usersWhoCanReviewEveryQueue is not readable cross-tenant (PII leak guard)', () => { + function makeCtx(opts: { orgId: string; callerOrgId?: string | null }) { + const getOrgUsersForGraphQL = jest.fn(async () => []); + const ctx = { + getUser: () => + opts.callerOrgId === null + ? null + : { id: 'user-1', orgId: opts.callerOrgId ?? opts.orgId }, + dataSources: { + orgAPI: { getOrgUsersForGraphQL }, + }, + }; + return { ctx, getOrgUsersForGraphQL }; + } + + const orgParent = { id: 'org-1' }; + const Org = resolvers.Org as Record< + 'usersWhoCanReviewEveryQueue', + ( + parent: typeof orgParent, + args: unknown, + ctx: unknown, + ) => Promise + >; + + it('throws when the caller belongs to a different org (IDOR guard)', async () => { + const { ctx, getOrgUsersForGraphQL } = makeCtx({ + orgId: 'org-1', + callerOrgId: 'other-org', + }); + await expect( + Org.usersWhoCanReviewEveryQueue(orgParent, {}, ctx), + ).rejects.toThrow('User required.'); + expect(getOrgUsersForGraphQL).not.toHaveBeenCalled(); + }); + + it('throws when there is no authenticated user', async () => { + const { ctx, getOrgUsersForGraphQL } = makeCtx({ + orgId: 'org-1', + callerOrgId: null, + }); + await expect( + Org.usersWhoCanReviewEveryQueue(orgParent, {}, ctx), + ).rejects.toThrow('User required.'); + expect(getOrgUsersForGraphQL).not.toHaveBeenCalled(); + }); + }); }); diff --git a/server/graphql/modules/org.ts b/server/graphql/modules/org.ts index f7d6fb0..60f812b 100644 --- a/server/graphql/modules/org.ts +++ b/server/graphql/modules/org.ts @@ -4,7 +4,6 @@ import { GraphQLError } from 'graphql'; import { filterDecisionsToFailedSubmissions } from '../../services/ncmecService/index.js'; import { UserPermission } from '../../services/userManagementService/index.js'; -import { isCoopErrorOfType } from '../../utils/errors.js'; import { __throw } from '../../utils/misc.js'; import { type GQLIntegrationConfig, @@ -16,7 +15,7 @@ import { } from '../generated.js'; import { type Context } from '../resolvers.js'; import { forbiddenError, unauthenticatedError } from '../utils/errors.js'; -import { gqlErrorResult, gqlSuccessResult } from '../utils/gqlResult.js'; +import { gqlSuccessResult } from '../utils/gqlResult.js'; const typeDefs = /* GraphQL */ ` type Org { @@ -68,39 +67,6 @@ const typeDefs = /* GraphQL */ ` hasPartialItemsEndpoint: Boolean! } - input CreateOrgInput { - name: String! - email: String! - website: String! - } - - type CreateOrgSuccessResponse { - id: ID! - } - - type OrgWithEmailExistsError implements Error { - title: String! - status: Int! - type: [String!]! - pointer: String - detail: String - requestId: String - } - - type OrgWithNameExistsError implements Error { - title: String! - status: Int! - type: [String!]! - pointer: String - detail: String - requestId: String - } - - union CreateOrgResponse = - | CreateOrgSuccessResponse - | OrgWithEmailExistsError - | OrgWithNameExistsError - input AppealSettingsInput { appealsCallbackUrl: String appealsCallbackHeaders: JSONObject @@ -134,7 +100,6 @@ const typeDefs = /* GraphQL */ ` type Query { org(id: ID!): Org - allOrgs: [Org!]! @publicResolver appealSettings: AppealSettings } @@ -163,7 +128,6 @@ const typeDefs = /* GraphQL */ ` } type Mutation { - createOrg(input: CreateOrgInput!): CreateOrgResponse! @publicResolver updateAppealSettings(input: AppealSettingsInput!): AppealSettings! setAllUserStrikeThresholds( input: SetAllUserStrikeThresholdsInput! @@ -188,12 +152,6 @@ const Query: GQLQueryResolvers = { return context.dataSources.orgAPI.getGraphQLOrgFromId(id); }, - // TODO(rui): this resolver is currently public in order to support - // the org dropdown in the signup page. We should deprecate that dropdown - // and remove the public directive. - async allOrgs(_, __, context) { - return context.dataSources.orgAPI.getAllGraphQLOrgs(); - }, async appealSettings(_, __, context) { const user = context.getUser(); if (user == null || !user.orgId) { @@ -599,6 +557,10 @@ const Org: GQLOrgResolvers = { ); }, async usersWhoCanReviewEveryQueue(org, _, context) { + const user = context.getUser(); + if (!user || user.orgId !== org.id) { + throw unauthenticatedError('User required.'); + } const users = await context.dataSources.orgAPI.getOrgUsersForGraphQL( org.id, ); @@ -714,23 +676,6 @@ const MatchingBanks: GQLMatchingBanksResolvers = { }; const Mutation: GQLMutationResolvers = { - async createOrg(_, params, context) { - try { - const org = await context.dataSources.orgAPI.createOrg(params); - return gqlSuccessResult({ id: org.id }, 'CreateOrgSuccessResponse'); - } catch (e: unknown) { - if ( - isCoopErrorOfType(e, [ - 'OrgWithEmailExistsError', - 'OrgWithNameExistsError', - ]) - ) { - return gqlErrorResult(e); - } - - throw e; - } - }, async updateAppealSettings(_, { input }, context) { const user = context.getUser(); if (!user || !user.orgId) { diff --git a/server/graphql/resolvers.ts b/server/graphql/resolvers.ts index 05d0a22..572fdba 100644 --- a/server/graphql/resolvers.ts +++ b/server/graphql/resolvers.ts @@ -261,9 +261,6 @@ const Mutation: GQLMutationResolvers = { return context.dataSources.userAPI.rejectUser(id, user.orgId); }, - async requestDemo(_, params, context) { - return context.dataSources.orgAPI.requestDemo(params.input); - }, }; export default mergeResolvers([ diff --git a/server/graphql/schema.ts b/server/graphql/schema.ts index 63e86c7..194c123 100644 --- a/server/graphql/schema.ts +++ b/server/graphql/schema.ts @@ -426,12 +426,6 @@ const typeDefs = /* GraphQL */ ` actionedSubmissionsByActionByDay: [CountByActionByDay!]! } - input CreateOrgInput { - name: String! - email: String! - website: String! - } - input SendPasswordResetInput { email: String! } @@ -458,22 +452,6 @@ const typeDefs = /* GraphQL */ ` createdAt: DateTime! } - enum RequestDemoInterest { - CUSTOM_AI_MODELS - MODERATOR_CONSOLE - AUTOMATED_ENFORCEMENT - COMPLIANCE_TOOLKIT - } - - input RequestDemoInput { - email: String! - company: String! - website: String! - interests: [RequestDemoInterest!]! - ref: String! - isFromGoogleAds: Boolean! - } - directive @publicResolver on FIELD_DEFINITION type Query { @@ -496,8 +474,6 @@ const typeDefs = /* GraphQL */ ` deleteInvite(id: ID!): Boolean approveUser(id: ID!): Boolean rejectUser(id: ID!): Boolean - - requestDemo(input: RequestDemoInput!): Boolean @publicResolver } `;