Mirrored from GitHub github.com/roostorg/coop
0

Configure Feed

Select the types of activity you want to include in your feed.

Remove unauthenticated org-data leaks and unused public mutations (#699)

* Remove unauthenticated org-data leaks and unused public mutations

* fix code review from coderabbit

author
Juan Mrad
committer
GitHub
date (Jun 4, 2026, 3:03 PM -0500) commit b29242f1 parent 45e529a4
+57 -588
-1
.env.docker
··· 59 59 NODE_ENV=production 60 60 ITEM_QUEUE_TRAFFIC_PERCENTAGE='0' 61 61 UI_URL=http://localhost:3000 62 - ENABLE_DEMO_REQUEST=false 63 62 64 63 # ── Optional API keys (uncomment and fill in as needed) ─────────────── 65 64 # GROQ_SECRET_KEY=
-2
.env.githubci
··· 73 73 74 74 ITEM_QUEUE_TRAFFIC_PERCENTAGE='0' 75 75 UI_URL=http://localhost:3000 76 - 77 - ENABLE_DEMO_REQUEST=false
-65
client/src/graphql/generated.ts
··· 787 787 | GQLManualReviewQueueNameExistsError 788 788 | GQLMutateManualReviewQueueSuccessResponse; 789 789 790 - export type GQLCreateOrgInput = { 791 - readonly email: Scalars['String']['input']; 792 - readonly name: Scalars['String']['input']; 793 - readonly website: Scalars['String']['input']; 794 - }; 795 - 796 - export type GQLCreateOrgResponse = 797 - | GQLCreateOrgSuccessResponse 798 - | GQLOrgWithEmailExistsError 799 - | GQLOrgWithNameExistsError; 800 - 801 - export type GQLCreateOrgSuccessResponse = { 802 - readonly __typename: 'CreateOrgSuccessResponse'; 803 - readonly id: Scalars['ID']['output']; 804 - }; 805 - 806 790 export type GQLCreateReportingRuleInput = { 807 791 readonly actionIds: ReadonlyArray<Scalars['ID']['input']>; 808 792 readonly conditionSet: GQLConditionSetInput; ··· 2431 2415 readonly createLocationBank: GQLMutateLocationBankResponse; 2432 2416 readonly createManualReviewJobComment: GQLAddManualReviewJobCommentResponse; 2433 2417 readonly createManualReviewQueue: GQLCreateManualReviewQueueResponse; 2434 - readonly createOrg: GQLCreateOrgResponse; 2435 2418 readonly createReportingRule: GQLCreateReportingRuleResponse; 2436 2419 readonly createRoutingRule: GQLCreateRoutingRuleResponse; 2437 2420 readonly createTextBank: GQLMutateBankResponse; ··· 2465 2448 readonly removeFavoriteRule: GQLRemoveFavoriteRuleSuccessResponse; 2466 2449 readonly renameRole: GQLRole; 2467 2450 readonly reorderRoutingRules: GQLReorderRoutingRulesResponse; 2468 - readonly requestDemo?: Maybe<Scalars['Boolean']['output']>; 2469 2451 readonly resetPassword: Scalars['Boolean']['output']; 2470 2452 /** 2471 2453 * Retries a previously-failed NCMEC submission. Org-scoped: callers can only ··· 2569 2551 input: GQLCreateManualReviewQueueInput; 2570 2552 }; 2571 2553 2572 - export type GQLMutationCreateOrgArgs = { 2573 - input: GQLCreateOrgInput; 2574 - }; 2575 - 2576 2554 export type GQLMutationCreateReportingRuleArgs = { 2577 2555 input: GQLCreateReportingRuleInput; 2578 2556 }; ··· 2699 2677 2700 2678 export type GQLMutationReorderRoutingRulesArgs = { 2701 2679 input: GQLReorderRoutingRulesInput; 2702 - }; 2703 - 2704 - export type GQLMutationRequestDemoArgs = { 2705 - input: GQLRequestDemoInput; 2706 2680 }; 2707 2681 2708 2682 export type GQLMutationResetPasswordArgs = { ··· 3147 3121 customOnly?: InputMaybe<Scalars['Boolean']['input']>; 3148 3122 }; 3149 3123 3150 - export type GQLOrgWithEmailExistsError = GQLError & { 3151 - readonly __typename: 'OrgWithEmailExistsError'; 3152 - readonly detail?: Maybe<Scalars['String']['output']>; 3153 - readonly pointer?: Maybe<Scalars['String']['output']>; 3154 - readonly requestId?: Maybe<Scalars['String']['output']>; 3155 - readonly status: Scalars['Int']['output']; 3156 - readonly title: Scalars['String']['output']; 3157 - readonly type: ReadonlyArray<Scalars['String']['output']>; 3158 - }; 3159 - 3160 - export type GQLOrgWithNameExistsError = GQLError & { 3161 - readonly __typename: 'OrgWithNameExistsError'; 3162 - readonly detail?: Maybe<Scalars['String']['output']>; 3163 - readonly pointer?: Maybe<Scalars['String']['output']>; 3164 - readonly requestId?: Maybe<Scalars['String']['output']>; 3165 - readonly status: Scalars['Int']['output']; 3166 - readonly title: Scalars['String']['output']; 3167 - readonly type: ReadonlyArray<Scalars['String']['output']>; 3168 - }; 3169 - 3170 3124 /** Information about the current page in a connection. */ 3171 3125 export type GQLPageInfo = { 3172 3126 readonly __typename: 'PageInfo'; ··· 3326 3280 readonly __typename: 'Query'; 3327 3281 readonly action?: Maybe<GQLAction>; 3328 3282 readonly actionStatistics: ReadonlyArray<GQLActionData>; 3329 - readonly allOrgs: ReadonlyArray<GQLOrg>; 3330 3283 readonly allRuleInsights?: Maybe<GQLAllRuleInsights>; 3331 3284 readonly apiKey: Scalars['String']['output']; 3332 3285 readonly appealSettings?: Maybe<GQLAppealSettings>; ··· 3865 3818 3866 3819 export type GQLReportingRuleStatus = 3867 3820 (typeof GQLReportingRuleStatus)[keyof typeof GQLReportingRuleStatus]; 3868 - export type GQLRequestDemoInput = { 3869 - readonly company: Scalars['String']['input']; 3870 - readonly email: Scalars['String']['input']; 3871 - readonly interests: ReadonlyArray<GQLRequestDemoInterest>; 3872 - readonly isFromGoogleAds: Scalars['Boolean']['input']; 3873 - readonly ref: Scalars['String']['input']; 3874 - readonly website: Scalars['String']['input']; 3875 - }; 3876 - 3877 - export const GQLRequestDemoInterest = { 3878 - AutomatedEnforcement: 'AUTOMATED_ENFORCEMENT', 3879 - ComplianceToolkit: 'COMPLIANCE_TOOLKIT', 3880 - CustomAiModels: 'CUSTOM_AI_MODELS', 3881 - ModeratorConsole: 'MODERATOR_CONSOLE', 3882 - } as const; 3883 - 3884 - export type GQLRequestDemoInterest = 3885 - (typeof GQLRequestDemoInterest)[keyof typeof GQLRequestDemoInterest]; 3886 3821 export type GQLResetPasswordInput = { 3887 3822 readonly newPassword: Scalars['String']['input']; 3888 3823 readonly token: Scalars['String']['input'];
-3
server/.env.example
··· 95 95 SUPPORT_EMAIL=support@example.com 96 96 TEAM_EMAIL=team@example.com 97 97 98 - # Set to 'true' to enable the public demo request endpoint 99 - ENABLE_DEMO_REQUEST=false 100 - 101 98 # Selects the NCMEC CyberTipline endpoint that "Submit to NCMEC" decisions are 102 99 # routed to. Anything other than the literal string `production` (including 103 100 # being unset) sends submissions to https://exttest.cybertip.org (the NCMEC
-68
server/graphql/datasources/OrgApi.test.ts
··· 7 7 import { makeMockedServer } from '../../test/setupMockedServer.js'; 8 8 import { makeTestWithFixture } from '../../test/utils.js'; 9 9 import { CoopError } from '../../utils/errors.js'; 10 - import { kyselyOrgDeleteById } from './orgKyselyPersistence.js'; 11 10 import { 12 11 kyselyUserDeleteById, 13 12 kyselyUserInsert, ··· 291 290 } finally { 292 291 await kyselyUserDeleteById(deps.KyselyPg, otherUserId); 293 292 await otherOrgCleanup(); 294 - } 295 - }, 296 - ); 297 - }); 298 - 299 - describe('createOrg', () => { 300 - testWithFixture( 301 - 'throws a BadRequest with /input/website pointer for bad website', 302 - async ({ deps }) => { 303 - await expect( 304 - deps.OrgAPIDataSource.createOrg({ 305 - input: { 306 - name: `NewOrg_${uid()}`, 307 - email: `new_${uid()}@example.com`, 308 - // eslint-disable-next-line no-script-url 309 - website: 'javascript:alert(1)', 310 - }, 311 - }), 312 - ).rejects.toMatchObject({ 313 - name: 'BadRequestError', 314 - pointer: '/input/website', 315 - }); 316 - }, 317 - ); 318 - 319 - testWithFixture( 320 - 'throws a BadRequest for malformed email', 321 - async ({ deps }) => { 322 - await expect( 323 - deps.OrgAPIDataSource.createOrg({ 324 - input: { 325 - name: `NewOrg_${uid()}`, 326 - email: 'not-an-email', 327 - website: 'https://example.com', 328 - }, 329 - }), 330 - ).rejects.toMatchObject({ 331 - name: 'BadRequestError', 332 - pointer: '/input/email', 333 - }); 334 - }, 335 - ); 336 - 337 - testWithFixture( 338 - 'seeds the built-in actions for the new org', 339 - async ({ deps }) => { 340 - const created = await deps.OrgAPIDataSource.createOrg({ 341 - input: { 342 - name: `NewOrg_${uid()}`, 343 - email: `new_${uid()}@example.com`, 344 - website: 'https://example.com', 345 - }, 346 - }); 347 - try { 348 - const actions = await deps.ModerationConfigService.getActions({ 349 - orgId: created.id, 350 - }); 351 - const actionTypes = actions.map((a) => a.actionType).sort(); 352 - expect(actionTypes).toEqual( 353 - [ 354 - 'ENQUEUE_AUTHOR_TO_MRT', 355 - 'ENQUEUE_TO_MRT', 356 - 'ENQUEUE_TO_NCMEC', 357 - ].sort(), 358 - ); 359 - } finally { 360 - await kyselyOrgDeleteById(deps.KyselyPg, created.id); 361 293 } 362 294 }, 363 295 );
+4 -159
server/graphql/datasources/OrgApi.ts
··· 1 1 import crypto from 'node:crypto'; 2 2 import { URL } from 'node:url'; 3 - import { type Exception } from '@opentelemetry/api'; 4 - import { uid } from 'uid'; 5 3 6 4 import { inject, type Dependencies } from '../../iocContainer/index.js'; 7 5 import { CoopEmailAddress } from '../../services/sendEmailService/index.js'; ··· 14 12 type ErrorInstanceData, 15 13 } from '../../utils/errors.js'; 16 14 import { WEEK_MS } from '../../utils/time.js'; 15 + import { type GQLInviteUserInput } from '../generated.js'; 17 16 import { 18 - type GQLInviteUserInput, 19 - type GQLMutationCreateOrgArgs, 20 - type GQLRequestDemoInput, 21 - } from '../generated.js'; 22 - import { 23 - kyselyOrgDeleteById, 24 - kyselyOrgFindAll, 25 - kyselyOrgFindByEmail, 26 17 kyselyOrgFindById, 27 - kyselyOrgFindByName, 28 - kyselyOrgInsert, 29 18 kyselyOrgUpdate, 30 19 type GraphQLOrgParent, 31 20 } from './orgKyselyPersistence.js'; 32 21 import { 33 - validateOrgCreateInput, 34 22 validateOrgUpdatePatch, 35 23 type OrgValidationFailure, 36 24 } from './orgValidation.js'; ··· 54 42 private readonly kysely: Dependencies['KyselyPg'], 55 43 ) {} 56 44 57 - async createOrg(params: GQLMutationCreateOrgArgs) { 58 - const { email, name, website } = params.input; 59 - 60 - const validation = validateOrgCreateInput({ 61 - name, 62 - email, 63 - websiteUrl: website, 64 - }); 65 - if (!validation.ok) { 66 - throw orgValidationFailureToBadRequestError( 67 - validation.failure, 68 - 'createOrg', 69 - ); 70 - } 71 - 72 - const existingOrgByName = await kyselyOrgFindByName(this.kysely, name); 73 - if (existingOrgByName != null) { 74 - throw makeOrgNameExistsError({ shouldErrorSpan: true }); 75 - } 76 - const existingOrgByEmail = await kyselyOrgFindByEmail(this.kysely, email); 77 - 78 - if (existingOrgByEmail != null) { 79 - throw makeOrgEmailExistsError({ shouldErrorSpan: true }); 80 - } 81 - 82 - const id = uid(); 83 - 84 - // Insert the org first so FK-dependent inserts (api_keys, signing_keys, 85 - // and everything in the Promise.all below) have a parent row to reference. 86 - // Past versions of this resolver created the api_key first, which fails 87 - // against the non-deferred `api_keys.org_id` FK to `public.orgs(id)`. 88 - const org = await kyselyOrgInsert({ 89 - db: this.kysely, 90 - id, 91 - email, 92 - name, 93 - websiteUrl: website, 94 - }); 95 - 96 - try { 97 - const { record } = await this.apiKeyService.createApiKey( 98 - id, 99 - 'Main API Key', 100 - 'Primary API key for organization', 101 - null, 102 - ); 103 - await this.signingKeyPairService.createAndStoreSigningKeys(id); 104 - 105 - // Backfill api_key_id on the org now that the key exists. 106 - await this.kysely 107 - .updateTable('public.orgs') 108 - .set({ api_key_id: record.id, updated_at: new Date() }) 109 - .where('id', '=', id) 110 - .execute(); 111 - 112 - await Promise.all([ 113 - // This should ideally be done in one transaction, but we can update 114 - // this after we move off of sequelize 115 - this.moderationConfigService.createDefaultUserType(id), 116 - this.moderationConfigService.upsertBuiltInActions(id), 117 - this.orgCreationLogger.logOrgCreated(id, name, email, website), 118 - this.userManagementService.upsertOrgDefaultUserInterfaceSettings({ 119 - orgId: id, 120 - }), 121 - this.orgSettingsService.upsertOrgDefaultSettings({ orgId: id }), 122 - this.manualReviewToolService.upsertDefaultSettings({ orgId: id }), 123 - ]); 124 - 125 - return org; 126 - } catch (e) { 127 - // Roll back the org insert so we don't leave a half-provisioned row. 128 - // `ON DELETE CASCADE` on org_id FKs cleans up any child rows that did 129 - // make it (api_keys, signing_keys, item_types, etc.). 130 - await kyselyOrgDeleteById(this.kysely, id).catch(() => { 131 - // Swallow cleanup failures: the original error is more useful to the 132 - // caller, and the next createOrg attempt will hit the unique-name 133 - // / unique-email guards above and surface a clear conflict. 134 - }); 135 - const activeSpan = this.tracer.getActiveSpan(); 136 - if (activeSpan?.isRecording()) { 137 - activeSpan.recordException(e as Exception); 138 - } 139 - throw e; 140 - } 141 - } 142 - 143 45 // Create invite token and optionally send email 144 46 async inviteUser(input: GQLInviteUserInput, orgId: string) { 145 47 const { email, role } = input; ··· 200 102 return token; 201 103 } 202 104 203 - async requestDemo(input: GQLRequestDemoInput) { 204 - if (process.env.ENABLE_DEMO_REQUEST !== 'true') { 205 - return false; 206 - } 207 - const { email, company, website, interests, ref, isFromGoogleAds } = input; 208 - const msg = { 209 - to: CoopEmailAddress.Support, 210 - from: CoopEmailAddress.NoReply, 211 - subject: '[URGENT] Demo Request', 212 - 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( 213 - ', ', 214 - )} \n\nRef: ${ref} \n\nIs from Google Ads: ${isFromGoogleAds}`, 215 - }; 216 - try { 217 - await this.sendEmail(msg); 218 - } catch (error: unknown) { 219 - return false; 220 - } 221 - return true; 222 - } 223 - 224 105 async getGraphQLOrgFromId(id: string): Promise<GraphQLOrgParent> { 225 106 const org = await kyselyOrgFindById(this.kysely, id); 226 107 if (org == null) { ··· 229 110 return org; 230 111 } 231 112 232 - async getAllGraphQLOrgs(): Promise<GraphQLOrgParent[]> { 233 - return kyselyOrgFindAll(this.kysely); 234 - } 235 - 236 113 async updateOrgInfo( 237 114 orgId: string, 238 115 input: { ··· 244 121 ): Promise<GraphQLOrgParent> { 245 122 const validation = validateOrgUpdatePatch(input); 246 123 if (!validation.ok) { 247 - throw orgValidationFailureToBadRequestError( 248 - validation.failure, 249 - 'updateOrgInfo', 250 - ); 124 + throw orgValidationFailureToBadRequestError(validation.failure); 251 125 } 252 126 253 127 const updated = await kyselyOrgUpdate(this.kysely, orgId, { ··· 321 195 } 322 196 323 197 export type OrgErrorType = 324 - | 'OrgWithEmailExistsError' 325 - | 'OrgWithNameExistsError' 326 198 | 'InviteUserTokenExpiredError' 327 199 | 'InviteUserTokenMissingError'; 328 200 329 - function orgValidationFailureToBadRequestError( 330 - failure: OrgValidationFailure, 331 - mutation: 'createOrg' | 'updateOrgInfo', 332 - ) { 333 - // `createOrg` exposes `websiteUrl` as `website` in its GraphQL input; 334 - // `updateOrgInfo` uses the same field name. 335 - const gqlField = 336 - mutation === 'createOrg' && failure.field === 'websiteUrl' 337 - ? 'website' 338 - : failure.field; 201 + function orgValidationFailureToBadRequestError(failure: OrgValidationFailure) { 339 202 return makeBadRequestError(failure.message, { 340 - pointer: `/input/${gqlField}`, 203 + pointer: `/input/${failure.field}`, 341 204 shouldErrorSpan: false, 342 205 }); 343 206 } 344 - 345 - export const makeOrgEmailExistsError = (data: ErrorInstanceData) => 346 - new CoopError({ 347 - status: 409, 348 - type: [ErrorType.UniqueViolation], 349 - title: 'An org with this email already exists', 350 - name: 'OrgWithEmailExistsError', 351 - ...data, 352 - }); 353 - 354 - export const makeOrgNameExistsError = (data: ErrorInstanceData) => 355 - new CoopError({ 356 - status: 409, 357 - type: [ErrorType.UniqueViolation], 358 - title: 'An org with this name already exists', 359 - name: 'OrgWithNameExistsError', 360 - ...data, 361 - }); 362 207 363 208 export const makeInviteUserTokenExpiredError = (data: ErrorInstanceData) => 364 209 new CoopError({
-11
server/graphql/datasources/orgKyselyPersistence.ts
··· 79 79 return row === undefined ? undefined : rowToGraphQLOrgParent(row); 80 80 } 81 81 82 - export async function kyselyOrgFindAll( 83 - db: OrgsDb, 84 - ): Promise<GraphQLOrgParent[]> { 85 - const rows = await db 86 - .selectFrom('public.orgs') 87 - .selectAll() 88 - .orderBy('name', 'asc') 89 - .execute(); 90 - return rows.map(rowToGraphQLOrgParent); 91 - } 92 - 93 82 export async function kyselyOrgInsert(opts: { 94 83 db: OrgsDb; 95 84 id: string;
-192
server/graphql/generated.ts
··· 855 855 | GQLManualReviewQueueNameExistsError 856 856 | GQLMutateManualReviewQueueSuccessResponse; 857 857 858 - export type GQLCreateOrgInput = { 859 - readonly email: Scalars['String']['input']; 860 - readonly name: Scalars['String']['input']; 861 - readonly website: Scalars['String']['input']; 862 - }; 863 - 864 - export type GQLCreateOrgResponse = 865 - | GQLCreateOrgSuccessResponse 866 - | GQLOrgWithEmailExistsError 867 - | GQLOrgWithNameExistsError; 868 - 869 - export type GQLCreateOrgSuccessResponse = { 870 - readonly __typename?: 'CreateOrgSuccessResponse'; 871 - readonly id: Scalars['ID']['output']; 872 - }; 873 - 874 858 export type GQLCreateReportingRuleInput = { 875 859 readonly actionIds: ReadonlyArray<Scalars['ID']['input']>; 876 860 readonly conditionSet: GQLConditionSetInput; ··· 2499 2483 readonly createLocationBank: GQLMutateLocationBankResponse; 2500 2484 readonly createManualReviewJobComment: GQLAddManualReviewJobCommentResponse; 2501 2485 readonly createManualReviewQueue: GQLCreateManualReviewQueueResponse; 2502 - readonly createOrg: GQLCreateOrgResponse; 2503 2486 readonly createReportingRule: GQLCreateReportingRuleResponse; 2504 2487 readonly createRoutingRule: GQLCreateRoutingRuleResponse; 2505 2488 readonly createTextBank: GQLMutateBankResponse; ··· 2533 2516 readonly removeFavoriteRule: GQLRemoveFavoriteRuleSuccessResponse; 2534 2517 readonly renameRole: GQLRole; 2535 2518 readonly reorderRoutingRules: GQLReorderRoutingRulesResponse; 2536 - readonly requestDemo?: Maybe<Scalars['Boolean']['output']>; 2537 2519 readonly resetPassword: Scalars['Boolean']['output']; 2538 2520 /** 2539 2521 * Retries a previously-failed NCMEC submission. Org-scoped: callers can only ··· 2637 2619 input: GQLCreateManualReviewQueueInput; 2638 2620 }; 2639 2621 2640 - export type GQLMutationCreateOrgArgs = { 2641 - input: GQLCreateOrgInput; 2642 - }; 2643 - 2644 2622 export type GQLMutationCreateReportingRuleArgs = { 2645 2623 input: GQLCreateReportingRuleInput; 2646 2624 }; ··· 2767 2745 2768 2746 export type GQLMutationReorderRoutingRulesArgs = { 2769 2747 input: GQLReorderRoutingRulesInput; 2770 - }; 2771 - 2772 - export type GQLMutationRequestDemoArgs = { 2773 - input: GQLRequestDemoInput; 2774 2748 }; 2775 2749 2776 2750 export type GQLMutationResetPasswordArgs = { ··· 3215 3189 customOnly?: InputMaybe<Scalars['Boolean']['input']>; 3216 3190 }; 3217 3191 3218 - export type GQLOrgWithEmailExistsError = GQLError & { 3219 - readonly __typename?: 'OrgWithEmailExistsError'; 3220 - readonly detail?: Maybe<Scalars['String']['output']>; 3221 - readonly pointer?: Maybe<Scalars['String']['output']>; 3222 - readonly requestId?: Maybe<Scalars['String']['output']>; 3223 - readonly status: Scalars['Int']['output']; 3224 - readonly title: Scalars['String']['output']; 3225 - readonly type: ReadonlyArray<Scalars['String']['output']>; 3226 - }; 3227 - 3228 - export type GQLOrgWithNameExistsError = GQLError & { 3229 - readonly __typename?: 'OrgWithNameExistsError'; 3230 - readonly detail?: Maybe<Scalars['String']['output']>; 3231 - readonly pointer?: Maybe<Scalars['String']['output']>; 3232 - readonly requestId?: Maybe<Scalars['String']['output']>; 3233 - readonly status: Scalars['Int']['output']; 3234 - readonly title: Scalars['String']['output']; 3235 - readonly type: ReadonlyArray<Scalars['String']['output']>; 3236 - }; 3237 - 3238 3192 /** Information about the current page in a connection. */ 3239 3193 export type GQLPageInfo = { 3240 3194 readonly __typename?: 'PageInfo'; ··· 3394 3348 readonly __typename?: 'Query'; 3395 3349 readonly action?: Maybe<GQLAction>; 3396 3350 readonly actionStatistics: ReadonlyArray<GQLActionData>; 3397 - readonly allOrgs: ReadonlyArray<GQLOrg>; 3398 3351 readonly allRuleInsights?: Maybe<GQLAllRuleInsights>; 3399 3352 readonly apiKey: Scalars['String']['output']; 3400 3353 readonly appealSettings?: Maybe<GQLAppealSettings>; ··· 3933 3886 3934 3887 export type GQLReportingRuleStatus = 3935 3888 (typeof GQLReportingRuleStatus)[keyof typeof GQLReportingRuleStatus]; 3936 - export type GQLRequestDemoInput = { 3937 - readonly company: Scalars['String']['input']; 3938 - readonly email: Scalars['String']['input']; 3939 - readonly interests: ReadonlyArray<GQLRequestDemoInterest>; 3940 - readonly isFromGoogleAds: Scalars['Boolean']['input']; 3941 - readonly ref: Scalars['String']['input']; 3942 - readonly website: Scalars['String']['input']; 3943 - }; 3944 - 3945 - export const GQLRequestDemoInterest = { 3946 - AutomatedEnforcement: 'AUTOMATED_ENFORCEMENT', 3947 - ComplianceToolkit: 'COMPLIANCE_TOOLKIT', 3948 - CustomAiModels: 'CUSTOM_AI_MODELS', 3949 - ModeratorConsole: 'MODERATOR_CONSOLE', 3950 - } as const; 3951 - 3952 - export type GQLRequestDemoInterest = 3953 - (typeof GQLRequestDemoInterest)[keyof typeof GQLRequestDemoInterest]; 3954 3889 export type GQLResetPasswordInput = { 3955 3890 readonly newPassword: Scalars['String']['input']; 3956 3891 readonly token: Scalars['String']['input']; ··· 5329 5264 | (Omit<GQLMutateManualReviewQueueSuccessResponse, 'data'> & { 5330 5265 data: _RefType['ManualReviewQueue']; 5331 5266 }); 5332 - CreateOrgResponse: 5333 - | GQLCreateOrgSuccessResponse 5334 - | GQLOrgWithEmailExistsError 5335 - | GQLOrgWithNameExistsError; 5336 5267 CreateReportingRuleResponse: 5337 5268 | (Omit<GQLMutateReportingRuleSuccessResponse, 'data'> & { 5338 5269 data: _RefType['ReportingRule']; ··· 5582 5513 | GQLMissingRequiredPolicyForDecisionError 5583 5514 | GQLNoJobWithIdInQueueError 5584 5515 | GQLNotFoundError 5585 - | GQLOrgWithEmailExistsError 5586 - | GQLOrgWithNameExistsError 5587 5516 | GQLPartialItemsEndpointResponseError 5588 5517 | GQLPartialItemsInvalidResponseError 5589 5518 | GQLPartialItemsMissingEndpointError ··· 5759 5688 CreateManualReviewQueueResponse: ResolverTypeWrapper< 5760 5689 GQLResolversUnionTypes<GQLResolversTypes>['CreateManualReviewQueueResponse'] 5761 5690 >; 5762 - CreateOrgInput: GQLCreateOrgInput; 5763 - CreateOrgResponse: ResolverTypeWrapper< 5764 - GQLResolversUnionTypes<GQLResolversTypes>['CreateOrgResponse'] 5765 - >; 5766 - CreateOrgSuccessResponse: ResolverTypeWrapper<GQLCreateOrgSuccessResponse>; 5767 5691 CreateReportingRuleInput: GQLCreateReportingRuleInput; 5768 5692 CreateReportingRuleResponse: ResolverTypeWrapper< 5769 5693 GQLResolversUnionTypes<GQLResolversTypes>['CreateReportingRuleResponse'] ··· 6166 6090 OpenAiIntegrationApiCredential: ResolverTypeWrapper<GQLOpenAiIntegrationApiCredential>; 6167 6091 OpenAiIntegrationApiCredentialInput: GQLOpenAiIntegrationApiCredentialInput; 6168 6092 Org: ResolverTypeWrapper<GraphQLOrgParent>; 6169 - OrgWithEmailExistsError: ResolverTypeWrapper<GQLOrgWithEmailExistsError>; 6170 - OrgWithNameExistsError: ResolverTypeWrapper<GQLOrgWithNameExistsError>; 6171 6093 PageInfo: ResolverTypeWrapper<GQLPageInfo>; 6172 6094 PartialItemsEndpointResponseError: ResolverTypeWrapper<GQLPartialItemsEndpointResponseError>; 6173 6095 PartialItemsInvalidResponseError: ResolverTypeWrapper<GQLPartialItemsInvalidResponseError>; ··· 6248 6170 ReportingRuleNameExistsError: ResolverTypeWrapper<GQLReportingRuleNameExistsError>; 6249 6171 ReportingRulePassRateData: ResolverTypeWrapper<GQLReportingRulePassRateData>; 6250 6172 ReportingRuleStatus: GQLReportingRuleStatus; 6251 - RequestDemoInput: GQLRequestDemoInput; 6252 - RequestDemoInterest: GQLRequestDemoInterest; 6253 6173 ResetPasswordInput: GQLResetPasswordInput; 6254 6174 ResolvedJobCount: ResolverTypeWrapper<GQLResolvedJobCount>; 6255 6175 RetryNcmecSubmissionResponse: ResolverTypeWrapper<GQLRetryNcmecSubmissionResponse>; ··· 6586 6506 CreateManualReviewJobCommentInput: GQLCreateManualReviewJobCommentInput; 6587 6507 CreateManualReviewQueueInput: GQLCreateManualReviewQueueInput; 6588 6508 CreateManualReviewQueueResponse: GQLResolversUnionTypes<GQLResolversParentTypes>['CreateManualReviewQueueResponse']; 6589 - CreateOrgInput: GQLCreateOrgInput; 6590 - CreateOrgResponse: GQLResolversUnionTypes<GQLResolversParentTypes>['CreateOrgResponse']; 6591 - CreateOrgSuccessResponse: GQLCreateOrgSuccessResponse; 6592 6509 CreateReportingRuleInput: GQLCreateReportingRuleInput; 6593 6510 CreateReportingRuleResponse: GQLResolversUnionTypes<GQLResolversParentTypes>['CreateReportingRuleResponse']; 6594 6511 CreateRoutingRuleInput: GQLCreateRoutingRuleInput; ··· 6877 6794 OpenAiIntegrationApiCredential: GQLOpenAiIntegrationApiCredential; 6878 6795 OpenAiIntegrationApiCredentialInput: GQLOpenAiIntegrationApiCredentialInput; 6879 6796 Org: GraphQLOrgParent; 6880 - OrgWithEmailExistsError: GQLOrgWithEmailExistsError; 6881 - OrgWithNameExistsError: GQLOrgWithNameExistsError; 6882 6797 PageInfo: GQLPageInfo; 6883 6798 PartialItemsEndpointResponseError: GQLPartialItemsEndpointResponseError; 6884 6799 PartialItemsInvalidResponseError: GQLPartialItemsInvalidResponseError; ··· 6948 6863 ReportingRuleInsights: ReportingRuleWithoutVersion; 6949 6864 ReportingRuleNameExistsError: GQLReportingRuleNameExistsError; 6950 6865 ReportingRulePassRateData: GQLReportingRulePassRateData; 6951 - RequestDemoInput: GQLRequestDemoInput; 6952 6866 ResetPasswordInput: GQLResetPasswordInput; 6953 6867 ResolvedJobCount: GQLResolvedJobCount; 6954 6868 RetryNcmecSubmissionResponse: GQLRetryNcmecSubmissionResponse; ··· 8269 8183 >; 8270 8184 }; 8271 8185 8272 - export type GQLCreateOrgResponseResolvers< 8273 - ContextType = Context, 8274 - ParentType extends GQLResolversParentTypes['CreateOrgResponse'] = 8275 - GQLResolversParentTypes['CreateOrgResponse'], 8276 - > = { 8277 - __resolveType: TypeResolveFn< 8278 - | 'CreateOrgSuccessResponse' 8279 - | 'OrgWithEmailExistsError' 8280 - | 'OrgWithNameExistsError', 8281 - ParentType, 8282 - ContextType 8283 - >; 8284 - }; 8285 - 8286 - export type GQLCreateOrgSuccessResponseResolvers< 8287 - ContextType = Context, 8288 - ParentType extends GQLResolversParentTypes['CreateOrgSuccessResponse'] = 8289 - GQLResolversParentTypes['CreateOrgSuccessResponse'], 8290 - > = { 8291 - id?: Resolver<GQLResolversTypes['ID'], ParentType, ContextType>; 8292 - __isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>; 8293 - }; 8294 - 8295 8186 export type GQLCreateReportingRuleResponseResolvers< 8296 8187 ContextType = Context, 8297 8188 ParentType extends GQLResolversParentTypes['CreateReportingRuleResponse'] = ··· 8839 8730 | 'MissingRequiredPolicyForDecisionError' 8840 8731 | 'NoJobWithIdInQueueError' 8841 8732 | 'NotFoundError' 8842 - | 'OrgWithEmailExistsError' 8843 - | 'OrgWithNameExistsError' 8844 8733 | 'PartialItemsEndpointResponseError' 8845 8734 | 'PartialItemsInvalidResponseError' 8846 8735 | 'PartialItemsMissingEndpointError' ··· 10869 10758 ContextType, 10870 10759 RequireFields<GQLMutationCreateManualReviewQueueArgs, 'input'> 10871 10760 >; 10872 - createOrg?: Resolver< 10873 - GQLResolversTypes['CreateOrgResponse'], 10874 - ParentType, 10875 - ContextType, 10876 - RequireFields<GQLMutationCreateOrgArgs, 'input'> 10877 - >; 10878 10761 createReportingRule?: Resolver< 10879 10762 GQLResolversTypes['CreateReportingRuleResponse'], 10880 10763 ParentType, ··· 11071 10954 ParentType, 11072 10955 ContextType, 11073 10956 RequireFields<GQLMutationReorderRoutingRulesArgs, 'input'> 11074 - >; 11075 - requestDemo?: Resolver< 11076 - Maybe<GQLResolversTypes['Boolean']>, 11077 - ParentType, 11078 - ContextType, 11079 - RequireFields<GQLMutationRequestDemoArgs, 'input'> 11080 10957 >; 11081 10958 resetPassword?: Resolver< 11082 10959 GQLResolversTypes['Boolean'], ··· 11816 11693 websiteUrl?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 11817 11694 }; 11818 11695 11819 - export type GQLOrgWithEmailExistsErrorResolvers< 11820 - ContextType = Context, 11821 - ParentType extends GQLResolversParentTypes['OrgWithEmailExistsError'] = 11822 - GQLResolversParentTypes['OrgWithEmailExistsError'], 11823 - > = { 11824 - detail?: Resolver< 11825 - Maybe<GQLResolversTypes['String']>, 11826 - ParentType, 11827 - ContextType 11828 - >; 11829 - pointer?: Resolver< 11830 - Maybe<GQLResolversTypes['String']>, 11831 - ParentType, 11832 - ContextType 11833 - >; 11834 - requestId?: Resolver< 11835 - Maybe<GQLResolversTypes['String']>, 11836 - ParentType, 11837 - ContextType 11838 - >; 11839 - status?: Resolver<GQLResolversTypes['Int'], ParentType, ContextType>; 11840 - title?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 11841 - type?: Resolver< 11842 - ReadonlyArray<GQLResolversTypes['String']>, 11843 - ParentType, 11844 - ContextType 11845 - >; 11846 - __isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>; 11847 - }; 11848 - 11849 - export type GQLOrgWithNameExistsErrorResolvers< 11850 - ContextType = Context, 11851 - ParentType extends GQLResolversParentTypes['OrgWithNameExistsError'] = 11852 - GQLResolversParentTypes['OrgWithNameExistsError'], 11853 - > = { 11854 - detail?: Resolver< 11855 - Maybe<GQLResolversTypes['String']>, 11856 - ParentType, 11857 - ContextType 11858 - >; 11859 - pointer?: Resolver< 11860 - Maybe<GQLResolversTypes['String']>, 11861 - ParentType, 11862 - ContextType 11863 - >; 11864 - requestId?: Resolver< 11865 - Maybe<GQLResolversTypes['String']>, 11866 - ParentType, 11867 - ContextType 11868 - >; 11869 - status?: Resolver<GQLResolversTypes['Int'], ParentType, ContextType>; 11870 - title?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 11871 - type?: Resolver< 11872 - ReadonlyArray<GQLResolversTypes['String']>, 11873 - ParentType, 11874 - ContextType 11875 - >; 11876 - __isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>; 11877 - }; 11878 - 11879 11696 export type GQLPageInfoResolvers< 11880 11697 ContextType = Context, 11881 11698 ParentType extends GQLResolversParentTypes['PageInfo'] = ··· 12210 12027 ParentType, 12211 12028 ContextType, 12212 12029 RequireFields<GQLQueryActionStatisticsArgs, 'input'> 12213 - >; 12214 - allOrgs?: Resolver< 12215 - ReadonlyArray<GQLResolversTypes['Org']>, 12216 - ParentType, 12217 - ContextType 12218 12030 >; 12219 12031 allRuleInsights?: Resolver< 12220 12032 Maybe<GQLResolversTypes['AllRuleInsights']>, ··· 14849 14661 CreateBacktestResponse?: GQLCreateBacktestResponseResolvers<ContextType>; 14850 14662 CreateContentRuleResponse?: GQLCreateContentRuleResponseResolvers<ContextType>; 14851 14663 CreateManualReviewQueueResponse?: GQLCreateManualReviewQueueResponseResolvers<ContextType>; 14852 - CreateOrgResponse?: GQLCreateOrgResponseResolvers<ContextType>; 14853 - CreateOrgSuccessResponse?: GQLCreateOrgSuccessResponseResolvers<ContextType>; 14854 14664 CreateReportingRuleResponse?: GQLCreateReportingRuleResponseResolvers<ContextType>; 14855 14665 CreateRoutingRuleResponse?: GQLCreateRoutingRuleResponseResolvers<ContextType>; 14856 14666 CreateUserRuleResponse?: GQLCreateUserRuleResponseResolvers<ContextType>; ··· 15001 14811 Notification?: GQLNotificationResolvers<ContextType>; 15002 14812 OpenAiIntegrationApiCredential?: GQLOpenAiIntegrationApiCredentialResolvers<ContextType>; 15003 14813 Org?: GQLOrgResolvers<ContextType>; 15004 - OrgWithEmailExistsError?: GQLOrgWithEmailExistsErrorResolvers<ContextType>; 15005 - OrgWithNameExistsError?: GQLOrgWithNameExistsErrorResolvers<ContextType>; 15006 14814 PageInfo?: GQLPageInfoResolvers<ContextType>; 15007 14815 PartialItemsEndpointResponseError?: GQLPartialItemsEndpointResponseErrorResolvers<ContextType>; 15008 14816 PartialItemsInvalidResponseError?: GQLPartialItemsInvalidResponseErrorResolvers<ContextType>;
+48
server/graphql/modules/org.resolver.test.ts
··· 198 198 ); 199 199 }); 200 200 }); 201 + 202 + describe('Org.usersWhoCanReviewEveryQueue is not readable cross-tenant (PII leak guard)', () => { 203 + function makeCtx(opts: { orgId: string; callerOrgId?: string | null }) { 204 + const getOrgUsersForGraphQL = jest.fn(async () => []); 205 + const ctx = { 206 + getUser: () => 207 + opts.callerOrgId === null 208 + ? null 209 + : { id: 'user-1', orgId: opts.callerOrgId ?? opts.orgId }, 210 + dataSources: { 211 + orgAPI: { getOrgUsersForGraphQL }, 212 + }, 213 + }; 214 + return { ctx, getOrgUsersForGraphQL }; 215 + } 216 + 217 + const orgParent = { id: 'org-1' }; 218 + const Org = resolvers.Org as Record< 219 + 'usersWhoCanReviewEveryQueue', 220 + ( 221 + parent: typeof orgParent, 222 + args: unknown, 223 + ctx: unknown, 224 + ) => Promise<unknown> 225 + >; 226 + 227 + it('throws when the caller belongs to a different org (IDOR guard)', async () => { 228 + const { ctx, getOrgUsersForGraphQL } = makeCtx({ 229 + orgId: 'org-1', 230 + callerOrgId: 'other-org', 231 + }); 232 + await expect( 233 + Org.usersWhoCanReviewEveryQueue(orgParent, {}, ctx), 234 + ).rejects.toThrow('User required.'); 235 + expect(getOrgUsersForGraphQL).not.toHaveBeenCalled(); 236 + }); 237 + 238 + it('throws when there is no authenticated user', async () => { 239 + const { ctx, getOrgUsersForGraphQL } = makeCtx({ 240 + orgId: 'org-1', 241 + callerOrgId: null, 242 + }); 243 + await expect( 244 + Org.usersWhoCanReviewEveryQueue(orgParent, {}, ctx), 245 + ).rejects.toThrow('User required.'); 246 + expect(getOrgUsersForGraphQL).not.toHaveBeenCalled(); 247 + }); 248 + }); 201 249 });
+5 -60
server/graphql/modules/org.ts
··· 4 4 5 5 import { filterDecisionsToFailedSubmissions } from '../../services/ncmecService/index.js'; 6 6 import { UserPermission } from '../../services/userManagementService/index.js'; 7 - import { isCoopErrorOfType } from '../../utils/errors.js'; 8 7 import { __throw } from '../../utils/misc.js'; 9 8 import { 10 9 type GQLIntegrationConfig, ··· 16 15 } from '../generated.js'; 17 16 import { type Context } from '../resolvers.js'; 18 17 import { forbiddenError, unauthenticatedError } from '../utils/errors.js'; 19 - import { gqlErrorResult, gqlSuccessResult } from '../utils/gqlResult.js'; 18 + import { gqlSuccessResult } from '../utils/gqlResult.js'; 20 19 21 20 const typeDefs = /* GraphQL */ ` 22 21 type Org { ··· 68 67 hasPartialItemsEndpoint: Boolean! 69 68 } 70 69 71 - input CreateOrgInput { 72 - name: String! 73 - email: String! 74 - website: String! 75 - } 76 - 77 - type CreateOrgSuccessResponse { 78 - id: ID! 79 - } 80 - 81 - type OrgWithEmailExistsError implements Error { 82 - title: String! 83 - status: Int! 84 - type: [String!]! 85 - pointer: String 86 - detail: String 87 - requestId: String 88 - } 89 - 90 - type OrgWithNameExistsError implements Error { 91 - title: String! 92 - status: Int! 93 - type: [String!]! 94 - pointer: String 95 - detail: String 96 - requestId: String 97 - } 98 - 99 - union CreateOrgResponse = 100 - | CreateOrgSuccessResponse 101 - | OrgWithEmailExistsError 102 - | OrgWithNameExistsError 103 - 104 70 input AppealSettingsInput { 105 71 appealsCallbackUrl: String 106 72 appealsCallbackHeaders: JSONObject ··· 134 100 135 101 type Query { 136 102 org(id: ID!): Org 137 - allOrgs: [Org!]! @publicResolver 138 103 appealSettings: AppealSettings 139 104 } 140 105 ··· 163 128 } 164 129 165 130 type Mutation { 166 - createOrg(input: CreateOrgInput!): CreateOrgResponse! @publicResolver 167 131 updateAppealSettings(input: AppealSettingsInput!): AppealSettings! 168 132 setAllUserStrikeThresholds( 169 133 input: SetAllUserStrikeThresholdsInput! ··· 187 151 } 188 152 189 153 return context.dataSources.orgAPI.getGraphQLOrgFromId(id); 190 - }, 191 - // TODO(rui): this resolver is currently public in order to support 192 - // the org dropdown in the signup page. We should deprecate that dropdown 193 - // and remove the public directive. 194 - async allOrgs(_, __, context) { 195 - return context.dataSources.orgAPI.getAllGraphQLOrgs(); 196 154 }, 197 155 async appealSettings(_, __, context) { 198 156 const user = context.getUser(); ··· 599 557 ); 600 558 }, 601 559 async usersWhoCanReviewEveryQueue(org, _, context) { 560 + const user = context.getUser(); 561 + if (!user || user.orgId !== org.id) { 562 + throw unauthenticatedError('User required.'); 563 + } 602 564 const users = await context.dataSources.orgAPI.getOrgUsersForGraphQL( 603 565 org.id, 604 566 ); ··· 714 676 }; 715 677 716 678 const Mutation: GQLMutationResolvers = { 717 - async createOrg(_, params, context) { 718 - try { 719 - const org = await context.dataSources.orgAPI.createOrg(params); 720 - return gqlSuccessResult({ id: org.id }, 'CreateOrgSuccessResponse'); 721 - } catch (e: unknown) { 722 - if ( 723 - isCoopErrorOfType(e, [ 724 - 'OrgWithEmailExistsError', 725 - 'OrgWithNameExistsError', 726 - ]) 727 - ) { 728 - return gqlErrorResult(e); 729 - } 730 - 731 - throw e; 732 - } 733 - }, 734 679 async updateAppealSettings(_, { input }, context) { 735 680 const user = context.getUser(); 736 681 if (!user || !user.orgId) {
-3
server/graphql/resolvers.ts
··· 261 261 262 262 return context.dataSources.userAPI.rejectUser(id, user.orgId); 263 263 }, 264 - async requestDemo(_, params, context) { 265 - return context.dataSources.orgAPI.requestDemo(params.input); 266 - }, 267 264 }; 268 265 269 266 export default mergeResolvers([
-24
server/graphql/schema.ts
··· 426 426 actionedSubmissionsByActionByDay: [CountByActionByDay!]! 427 427 } 428 428 429 - input CreateOrgInput { 430 - name: String! 431 - email: String! 432 - website: String! 433 - } 434 - 435 429 input SendPasswordResetInput { 436 430 email: String! 437 431 } ··· 458 452 createdAt: DateTime! 459 453 } 460 454 461 - enum RequestDemoInterest { 462 - CUSTOM_AI_MODELS 463 - MODERATOR_CONSOLE 464 - AUTOMATED_ENFORCEMENT 465 - COMPLIANCE_TOOLKIT 466 - } 467 - 468 - input RequestDemoInput { 469 - email: String! 470 - company: String! 471 - website: String! 472 - interests: [RequestDemoInterest!]! 473 - ref: String! 474 - isFromGoogleAds: Boolean! 475 - } 476 - 477 455 directive @publicResolver on FIELD_DEFINITION 478 456 479 457 type Query { ··· 496 474 deleteInvite(id: ID!): Boolean 497 475 approveUser(id: ID!): Boolean 498 476 rejectUser(id: ID!): Boolean 499 - 500 - requestDemo(input: RequestDemoInput!): Boolean @publicResolver 501 477 } 502 478 `; 503 479