From 8b1bd55f84cbed5a0f6bfabbe9044efa0d03fe7b Mon Sep 17 00:00:00 2001 From: Juan Mrad Date: Mon, 4 May 2026 21:32:37 -0500 Subject: [PATCH] [Fix] Make built-in MRT/NCMEC enqueue actions available to all orgs (#393) * Make built-in MRT/NCMEC enqueue actions available to all orgs * push migration for reals --- ...add_built_in_actions_for_existing_orgs.sql | 106 ++++++++++++++++++ server/bin/create-org-and-user.ts | 1 + server/graphql/modules/org.resolver.test.ts | 104 +++++++++++++++++ server/graphql/modules/org.ts | 42 +++++-- .../moderationConfigService.test.ts | 106 +++++++++++++++++- .../moderationConfigService.ts | 3 + .../modules/ActionOperations.ts | 81 +++++++++++++ server/test/fixtureHelpers/createOrg.ts | 4 + 8 files changed, 433 insertions(+), 14 deletions(-) create mode 100644 db/src/scripts/api-server-pg/2026.05.01T15.45.30.add_built_in_actions_for_existing_orgs.sql create mode 100644 server/graphql/modules/org.resolver.test.ts diff --git a/db/src/scripts/api-server-pg/2026.05.01T15.45.30.add_built_in_actions_for_existing_orgs.sql b/db/src/scripts/api-server-pg/2026.05.01T15.45.30.add_built_in_actions_for_existing_orgs.sql new file mode 100644 index 0000000..6501ce2 --- /dev/null +++ b/db/src/scripts/api-server-pg/2026.05.01T15.45.30.add_built_in_actions_for_existing_orgs.sql @@ -0,0 +1,106 @@ +-- Backfill per-org built-in actions for existing orgs. New orgs get them via +-- ModerationConfigService.upsertBuiltInActions. Idempotent on (org_id, action_type). +INSERT INTO public.actions ( + id, + name, + description, + org_id, + action_type, + callback_url, + callback_url_headers, + callback_url_body, + penalty, + apply_user_strikes, + applies_to_all_items_of_kind, + updated_at +) +SELECT + substr(md5(o.id || ':ENQUEUE_TO_MRT'), 1, 11), + 'Enqueue Item to Manual Review', + 'Sends the matched item directly to a manual review queue, routed by the org''s MRT routing rules.', + o.id, + 'ENQUEUE_TO_MRT'::public.action_type, + NULL, + NULL, + NULL, + 'NONE'::public.user_penalty_severity, + false, + ARRAY['CONTENT', 'USER', 'THREAD']::public.item_type_kind[], + CURRENT_TIMESTAMP +FROM public.orgs o +WHERE NOT EXISTS ( + SELECT 1 FROM public.actions a + WHERE a.org_id = o.id + AND a.action_type = 'ENQUEUE_TO_MRT' +) +ON CONFLICT DO NOTHING; + +INSERT INTO public.actions ( + id, + name, + description, + org_id, + action_type, + callback_url, + callback_url_headers, + callback_url_body, + penalty, + apply_user_strikes, + applies_to_all_items_of_kind, + updated_at +) +SELECT + substr(md5(o.id || ':ENQUEUE_AUTHOR_TO_MRT'), 1, 11), + 'Enqueue Author for Manual Review', + 'Sends the author of the matched content to a manual review queue, with the matched item attached as context.', + o.id, + 'ENQUEUE_AUTHOR_TO_MRT'::public.action_type, + NULL, + NULL, + NULL, + 'NONE'::public.user_penalty_severity, + false, + ARRAY['CONTENT']::public.item_type_kind[], + CURRENT_TIMESTAMP +FROM public.orgs o +WHERE NOT EXISTS ( + SELECT 1 FROM public.actions a + WHERE a.org_id = o.id + AND a.action_type = 'ENQUEUE_AUTHOR_TO_MRT' +) +ON CONFLICT DO NOTHING; + +INSERT INTO public.actions ( + id, + name, + description, + org_id, + action_type, + callback_url, + callback_url_headers, + callback_url_body, + penalty, + apply_user_strikes, + applies_to_all_items_of_kind, + updated_at +) +SELECT + substr(md5(o.id || ':ENQUEUE_TO_NCMEC'), 1, 11), + 'Enqueue for NCMEC Review', + 'Sends the user associated with the matched item to the NCMEC review flow, gathering their media for reporting.', + o.id, + 'ENQUEUE_TO_NCMEC'::public.action_type, + NULL, + NULL, + NULL, + 'NONE'::public.user_penalty_severity, + false, + ARRAY['CONTENT', 'USER']::public.item_type_kind[], + CURRENT_TIMESTAMP +FROM public.orgs o +WHERE NOT EXISTS ( + SELECT 1 FROM public.actions a + WHERE a.org_id = o.id + AND a.action_type = 'ENQUEUE_TO_NCMEC' +) +ON CONFLICT DO NOTHING; diff --git a/server/bin/create-org-and-user.ts b/server/bin/create-org-and-user.ts index 3aeb817..5c6daa9 100644 --- a/server/bin/create-org-and-user.ts +++ b/server/bin/create-org-and-user.ts @@ -94,6 +94,7 @@ async function createOrgAndUser() { // Initialize org settings await Promise.all([ container.ModerationConfigService.createDefaultUserType(orgId), + container.ModerationConfigService.upsertBuiltInActions(orgId), container.OrgCreationLogger.logOrgCreated( orgId, argv.name, diff --git a/server/graphql/modules/org.resolver.test.ts b/server/graphql/modules/org.resolver.test.ts new file mode 100644 index 0000000..45e6c05 --- /dev/null +++ b/server/graphql/modules/org.resolver.test.ts @@ -0,0 +1,104 @@ +import { type Action } from '../../services/moderationConfigService/index.js'; +import { resolveOrgActions } from './org.js'; + +function makeAction( + id: string, + actionType: Action['actionType'], + orgId: string, +): Action { + const base = { + id, + orgId, + name: id, + description: null, + penalty: 'NONE' as const, + applyUserStrikes: false, + }; + if (actionType === 'CUSTOM_ACTION') { + return { + ...base, + actionType, + callbackUrl: 'https://example.com', + callbackUrlBody: null, + callbackUrlHeaders: null, + customMrtApiParams: null, + }; + } + return { ...base, actionType }; +} + +describe('Org resolvers', () => { + describe('resolveOrgActions', () => { + function makeContext(opts: { + orgId: string; + callerOrgId?: string; + actionTypes: Array<{ id: string; actionType: Action['actionType'] }>; + hasNCMECReportingEnabled: boolean; + }) { + const actions = opts.actionTypes.map((a) => + makeAction(a.id, a.actionType, opts.orgId), + ); + const getActions = jest.fn(async () => actions); + const hasNCMECReportingEnabled = jest.fn( + async () => opts.hasNCMECReportingEnabled, + ); + const ctx = { + getUser: () => ({ orgId: opts.callerOrgId ?? opts.orgId }), + services: { + ModerationConfigService: { getActions }, + NcmecService: { hasNCMECReportingEnabled }, + }, + }; + return { ctx, getActions, hasNCMECReportingEnabled }; + } + + it('hides ENQUEUE_TO_NCMEC built-in when NCMEC reporting is disabled', async () => { + const { ctx } = makeContext({ + orgId: 'org-1', + actionTypes: [ + { id: 'a-custom', actionType: 'CUSTOM_ACTION' }, + { id: 'a-mrt', actionType: 'ENQUEUE_TO_MRT' }, + { id: 'a-author', actionType: 'ENQUEUE_AUTHOR_TO_MRT' }, + { id: 'a-ncmec', actionType: 'ENQUEUE_TO_NCMEC' }, + ], + hasNCMECReportingEnabled: false, + }); + + const result = await resolveOrgActions({ id: 'org-1' }, {}, ctx); + expect(result.map((it) => it.id).sort()).toEqual([ + 'a-author', + 'a-custom', + 'a-mrt', + ]); + }); + + it('returns ENQUEUE_TO_NCMEC built-in when NCMEC reporting is enabled', async () => { + const { ctx } = makeContext({ + orgId: 'org-1', + actionTypes: [ + { id: 'a-mrt', actionType: 'ENQUEUE_TO_MRT' }, + { id: 'a-ncmec', actionType: 'ENQUEUE_TO_NCMEC' }, + ], + hasNCMECReportingEnabled: true, + }); + + const result = await resolveOrgActions({ id: 'org-1' }, {}, ctx); + expect(result.map((it) => it.id).sort()).toEqual(['a-mrt', 'a-ncmec']); + }); + + it('rejects when caller org does not match the requested org (IDOR guard)', async () => { + const { ctx, getActions, hasNCMECReportingEnabled } = makeContext({ + orgId: 'org-1', + callerOrgId: 'other-org', + actionTypes: [], + hasNCMECReportingEnabled: false, + }); + + await expect(resolveOrgActions({ id: 'org-1' }, {}, ctx)).rejects.toThrow( + 'User required.', + ); + expect(getActions).not.toHaveBeenCalled(); + expect(hasNCMECReportingEnabled).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/server/graphql/modules/org.ts b/server/graphql/modules/org.ts index 845bfaa..be9dbd7 100644 --- a/server/graphql/modules/org.ts +++ b/server/graphql/modules/org.ts @@ -13,6 +13,7 @@ import { import { GraphQLError } from 'graphql'; import { gqlErrorResult, gqlSuccessResult } from '../utils/gqlResult.js'; import { forbiddenError, unauthenticatedError } from '../utils/errors.js'; +import { type Context } from '../resolvers.js'; const typeDefs = /* GraphQL */ ` type Org { @@ -199,17 +200,40 @@ const Query: GQLQueryResolvers = { }, }; -const Org: GQLOrgResolvers = { - async actions(org, _, context) { - const user = context.getUser(); - if (!user || user.orgId !== org.id) { - throw unauthenticatedError('User required.'); - } - return context.services.ModerationConfigService.getActions({ +// Narrowed to only the context fields this resolver actually uses, so tests +// can build a minimal mock without casting. `Context` (the full resolver +// context) is structurally assignable to this, so production usage is unchanged. +type ResolveOrgActionsContext = { + getUser: () => { orgId: string } | null | undefined; + services: { + ModerationConfigService: Pick; + NcmecService: Pick; + }; +}; + +export async function resolveOrgActions( + org: { id: string }, + _: unknown, + context: ResolveOrgActionsContext, +) { + const user = context.getUser(); + if (!user || user.orgId !== org.id) { + throw unauthenticatedError('User required.'); + } + const [actions, hasNcmecEnabled] = await Promise.all([ + context.services.ModerationConfigService.getActions({ orgId: org.id, readFromReplica: true, - }); - }, + }), + context.services.NcmecService.hasNCMECReportingEnabled(org.id), + ]); + return hasNcmecEnabled + ? actions + : actions.filter((it) => it.actionType !== 'ENQUEUE_TO_NCMEC'); +} + +const Org: GQLOrgResolvers = { + actions: resolveOrgActions, async contentTypes(org, _, context) { const user = context.getUser(); if (!user || user.orgId !== org.id) { diff --git a/server/services/moderationConfigService/moderationConfigService.test.ts b/server/services/moderationConfigService/moderationConfigService.test.ts index 3a6cdf8..fcf48ee 100644 --- a/server/services/moderationConfigService/moderationConfigService.test.ts +++ b/server/services/moderationConfigService/moderationConfigService.test.ts @@ -523,6 +523,92 @@ describe('ModerationConfigService', () => { describe('Action-returning methods', () => { describe('Creation methods', () => { + describe('#upsertBuiltInActions', () => { + it('seeds the three built-in (non-CUSTOM_ACTION) rows for the org', async () => { + const all = await sutWithPrimary.getActions({ orgId: dummyOrgId }); + const builtIns = all.filter( + (it) => it.actionType !== 'CUSTOM_ACTION', + ); + const types = builtIns.map((it) => it.actionType).sort(); + expect(types).toEqual( + [ + 'ENQUEUE_AUTHOR_TO_MRT', + 'ENQUEUE_TO_MRT', + 'ENQUEUE_TO_NCMEC', + ].sort(), + ); + for (const action of builtIns) { + expect(action.orgId).toBe(dummyOrgId); + expect(action).not.toHaveProperty('callbackUrl'); + } + }); + + it('is idempotent: calling twice does not create duplicates', async () => { + const before = await sutWithPrimary.getActions({ + orgId: dummyOrgId, + }); + const beforeBuiltIns = before + .filter((it) => it.actionType !== 'CUSTOM_ACTION') + .map((it) => it.id) + .sort(); + await sutWithPrimary.upsertBuiltInActions(dummyOrgId); + const after = await sutWithPrimary.getActions({ + orgId: dummyOrgId, + }); + const afterBuiltIns = after + .filter((it) => it.actionType !== 'CUSTOM_ACTION') + .map((it) => it.id) + .sort(); + expect(afterBuiltIns).toEqual(beforeBuiltIns); + }); + + it('built-ins surface for the appropriate item type kinds', async () => { + const fresh = await createOrg( + { + KyselyPg: container.KyselyPg, + ModerationConfigService: container.ModerationConfigService, + ApiKeyService: container.ApiKeyService, + }, + uid(), + ); + try { + const contentType = + await sutWithPrimary.createContentType(fresh.org.id, { + schema: dummySchema, + description: null, + name: faker.random.alphaNumeric(16), + schemaFieldRoles: { displayName: 'fakeField' }, + }); + + const forUser = await sutWithPrimary.getActionsForItemType({ + orgId: fresh.org.id, + itemTypeId: fresh.defaultUserItemType.id, + itemTypeKind: 'USER', + }); + expect( + forUser.map((it) => it.actionType).sort(), + ).toEqual(['ENQUEUE_TO_MRT', 'ENQUEUE_TO_NCMEC'].sort()); + + const forContent = await sutWithPrimary.getActionsForItemType({ + orgId: fresh.org.id, + itemTypeId: contentType.id, + itemTypeKind: 'CONTENT', + }); + expect( + forContent.map((it) => it.actionType).sort(), + ).toEqual( + [ + 'ENQUEUE_AUTHOR_TO_MRT', + 'ENQUEUE_TO_MRT', + 'ENQUEUE_TO_NCMEC', + ].sort(), + ); + } finally { + await fresh.cleanup(); + } + }); + }); + describe('#createAction', () => { it('should return and durably save the new action', async () => { const saved = await sutWithPrimary.createAction(dummyOrgId, { @@ -573,8 +659,13 @@ describe('ModerationConfigService', () => { it('should return all actions, properly formatted', async () => { const res = await sutWithPrimary.getActions({ orgId: dummyOrgId }); - expect(res).toHaveLength(createdActions.length); - expect(res).toEqual(expect.arrayContaining(createdActions)); + const customActions = res.filter( + (it) => it.actionType === 'CUSTOM_ACTION', + ); + expect(customActions).toHaveLength(createdActions.length); + expect(customActions).toEqual( + expect.arrayContaining(createdActions), + ); }); it('should round-trip a non-null customMrtApiParams value', async () => { @@ -1077,8 +1168,11 @@ describe('ModerationConfigService', () => { readFromReplica: false, }); - const ids = result.map((it) => it.id).sort(); - expect(ids).toEqual( + const customIds = result + .filter((it) => it.actionType === 'CUSTOM_ACTION') + .map((it) => it.id) + .sort(); + expect(customIds).toEqual( [ viaJunctionAction.id, viaAppliesAllAction.id, @@ -1104,7 +1198,9 @@ describe('ModerationConfigService', () => { itemTypeKind: 'CONTENT', readFromReplica: false, }); - expect(otherResult).toEqual([]); + expect( + otherResult.filter((it) => it.actionType === 'CUSTOM_ACTION'), + ).toEqual([]); } finally { await otherOrg.cleanup(); } diff --git a/server/services/moderationConfigService/moderationConfigService.ts b/server/services/moderationConfigService/moderationConfigService.ts index 5734e2c..0c16c59 100644 --- a/server/services/moderationConfigService/moderationConfigService.ts +++ b/server/services/moderationConfigService/moderationConfigService.ts @@ -287,6 +287,9 @@ export class ModerationConfigService implements ReturnsModerationConfigTypes { async deleteCustomAction(opts: { orgId: string; actionId: string }) { return this.actionOps.deleteCustomAction(opts); } + async upsertBuiltInActions(orgId: string) { + return this.actionOps.upsertBuiltInActions(orgId); + } async getActions(opts: { orgId: string; diff --git a/server/services/moderationConfigService/modules/ActionOperations.ts b/server/services/moderationConfigService/modules/ActionOperations.ts index 08c6c50..51c639d 100644 --- a/server/services/moderationConfigService/modules/ActionOperations.ts +++ b/server/services/moderationConfigService/modules/ActionOperations.ts @@ -26,6 +26,37 @@ function assertCustomAction(action: Action): asserts action is CustomAction { } } +// Seeded once per org by upsertBuiltInActions; not creatable/editable via the +// CRUD APIs, which are scoped to action_type='CUSTOM_ACTION'. +export const BUILT_IN_ACTIONS = [ + { + actionType: 'ENQUEUE_TO_MRT', + name: 'Enqueue Item to Manual Review', + description: + 'Sends the matched item directly to a manual review queue, routed by the org\u2019s MRT routing rules.', + appliesToAllItemsOfKind: ['CONTENT', 'USER', 'THREAD'] as const, + }, + { + actionType: 'ENQUEUE_AUTHOR_TO_MRT', + name: 'Enqueue Author for Manual Review', + description: + 'Sends the author of the matched content to a manual review queue, with the matched item attached as context.', + appliesToAllItemsOfKind: ['CONTENT'] as const, + }, + { + actionType: 'ENQUEUE_TO_NCMEC', + name: 'Enqueue for NCMEC Review', + description: + 'Sends the user associated with the matched item to the NCMEC review flow, gathering their media for reporting.', + appliesToAllItemsOfKind: ['CONTENT', 'USER'] as const, + }, +] as const satisfies readonly { + actionType: Exclude; + name: string; + description: string; + appliesToAllItemsOfKind: readonly ItemTypeKind[]; +}[]; + const actionDbSelection = [ 'id', 'name', @@ -135,6 +166,56 @@ export default class ActionOperations { }); } + // Idempotent: existing built-ins are detected by (org_id, action_type). + async upsertBuiltInActions(orgId: string): Promise { + return this.transactionWithRetry(async (trx) => { + const existingByType = new Set( + ( + (await trx + .selectFrom('public.actions') + .select('action_type as actionType') + .where('org_id', '=', orgId) + .where('action_type', '!=', 'CUSTOM_ACTION') + .execute()) as { actionType: Action['actionType'] }[] + ).map((row) => row.actionType), + ); + + const toInsert = BUILT_IN_ACTIONS.filter( + (b) => !existingByType.has(b.actionType), + ).map((b) => ({ + id: uid(), + name: b.name, + description: b.description, + org_id: orgId, + action_type: b.actionType, + callback_url: null, + callback_url_headers: null, + callback_url_body: null, + penalty: 'NONE' as const, + apply_user_strikes: false, + applies_to_all_items_of_kind: [...b.appliesToAllItemsOfKind], + updated_at: new Date(), + })); + + if (toInsert.length > 0) { + await trx + .insertInto('public.actions') + .values(toInsert) + .onConflict((oc) => oc.doNothing()) + .execute(); + } + + const refreshed = (await trx + .selectFrom('public.actions') + .select(actionDbSelection) + .where('org_id', '=', orgId) + .where('action_type', '!=', 'CUSTOM_ACTION') + .execute()) as ActionDbResult[]; + + return refreshed.map((row) => this.#dbResultToAction(row)); + }); + } + async updateCustomAction(opts: { orgId: string; actionId: string; diff --git a/server/test/fixtureHelpers/createOrg.ts b/server/test/fixtureHelpers/createOrg.ts index e35f770..9511b47 100644 --- a/server/test/fixtureHelpers/createOrg.ts +++ b/server/test/fixtureHelpers/createOrg.ts @@ -40,6 +40,10 @@ export default async function createOrg( orgId, ).catch(logErrorAndThrow); + await deps.ModerationConfigService.upsertBuiltInActions(orgId).catch( + logErrorAndThrow, + ); + return { org, apiKey, -- 2.51.2