fix(api): limit report auto-resolution on scheduled deletion (#3221)

This commit is contained in:
Hampus
2026-10-05 14:16:08 +02:00
committed by GitHub
parent e1eecc3b6c
commit 02c82f0038
3 changed files with 123 additions and 3 deletions
@@ -9,6 +9,7 @@ import type {AdminUserUpdatePropagator} from '@app/api/admin/services/AdminUserU
import * as AuthSession from '@app/api/auth/AuthSession'; import * as AuthSession from '@app/api/auth/AuthSession';
import {createReportID, createUserID, type UserID} from '@app/api/BrandedTypes'; import {createReportID, createUserID, type UserID} from '@app/api/BrandedTypes';
import type {BillingRepository} from '@app/api/billing/repositories/BillingRepository'; import type {BillingRepository} from '@app/api/billing/repositories/BillingRepository';
import type {NcmecRepository} from '@app/api/csam/NcmecRepository';
import {emitAdminAction} from '@app/api/infrastructure/activity/AccountChangeEvents'; import {emitAdminAction} from '@app/api/infrastructure/activity/AccountChangeEvents';
import type {KVAccountDeletionQueueService} from '@app/api/infrastructure/KVAccountDeletionQueueService'; import type {KVAccountDeletionQueueService} from '@app/api/infrastructure/KVAccountDeletionQueueService';
import {Logger} from '@app/api/Logger'; import {Logger} from '@app/api/Logger';
@@ -45,11 +46,28 @@ interface AdminUserDeletionServiceDeps {
billingRepository: BillingRepository; billingRepository: BillingRepository;
oauth2Tokens: Pick<OAuth2TokenRepository, 'deleteAllAccessTokensForUser' | 'deleteAllRefreshTokensForUser'>; oauth2Tokens: Pick<OAuth2TokenRepository, 'deleteAllAccessTokensForUser' | 'deleteAllRefreshTokensForUser'>;
storeEntitlementService: StoreEntitlementService; storeEntitlementService: StoreEntitlementService;
ncmecRepository: Pick<NcmecRepository, 'getUserWorkflow'>;
} }
const minUserRequestedDeletionDays = 14; const minUserRequestedDeletionDays = 14;
const minStandardDeletionDays = 60; const minStandardDeletionDays = 60;
const reportResolvingDeletionReasons: ReadonlySet<number> = new Set([
DeletionReasons.SPAM,
DeletionReasons.CHEATING_OR_EXPLOITATION,
DeletionReasons.COORDINATED_RAIDING,
DeletionReasons.AUTOMATION_OR_SELFBOT,
DeletionReasons.SCAM_OR_SOCIAL_ENGINEERING,
DeletionReasons.HARASSMENT_OR_BULLYING,
DeletionReasons.BAN_EVASION,
DeletionReasons.TOKEN_OR_CREDENTIAL_SCAM,
DeletionReasons.HATE_SPEECH_OR_EXTREMIST_CONTENT,
DeletionReasons.MALICIOUS_LINKS_OR_MALWARE,
DeletionReasons.IMPERSONATION_OR_FAKE_IDENTITY,
]);
const manuallyResolvedReportCategories: ReadonlySet<string> = new Set(['child_safety', 'underage_user', 'self_harm']);
function describePendingDeletion(user: User, prefix: string): Array<[string, string]> { function describePendingDeletion(user: User, prefix: string): Array<[string, string]> {
if (!user.pendingDeletionAt) return []; if (!user.pendingDeletionAt) return [];
return [ return [
@@ -247,6 +265,8 @@ export class AdminUserDeletionService {
let knownIps: ReadonlySet<string> = new Set(); let knownIps: ReadonlySet<string> = new Set();
if (data.reason_code !== DeletionReasons.USER_REQUESTED) { if (data.reason_code !== DeletionReasons.USER_REQUESTED) {
knownIps = await this.banIdentifiersForScheduledDeletion({user, adminUserId, auditLogReason}); knownIps = await this.banIdentifiersForScheduledDeletion({user, adminUserId, auditLogReason});
}
if (reportResolvingDeletionReasons.has(data.reason_code)) {
await this.resolvePendingReportsAgainstUser({user, adminUserId, reasonCode: data.reason_code}); await this.resolvePendingReportsAgainstUser({user, adminUserId, reasonCode: data.reason_code});
} }
await emitAdminAction(adminUserId, userId, 'schedule_deletion', {reasonCode: data.reason_code, ips: knownIps}); await emitAdminAction(adminUserId, userId, 'schedule_deletion', {reasonCode: data.reason_code, ips: knownIps});
@@ -389,7 +409,10 @@ export class AdminUserDeletionService {
}): Promise<void> { }): Promise<void> {
const {user, adminUserId, reasonCode} = params; const {user, adminUserId, reasonCode} = params;
const outcome = isEnforcementDeletionReason(reasonCode) ? 'actioned' : 'auto_resolved'; const outcome = isEnforcementDeletionReason(reasonCode) ? 'actioned' : 'auto_resolved';
const {reportService, auditService} = this.deps; const {reportService, auditService, ncmecRepository} = this.deps;
if (await ncmecRepository.getUserWorkflow(user.id)) {
return;
}
const reportSearchService = getReportSearchService(); const reportSearchService = getReportSearchService();
if (!reportSearchService) { if (!reportSearchService) {
Logger.warn( Logger.warn(
@@ -415,6 +438,7 @@ export class AdminUserDeletionService {
); );
if (hits.length === 0) break; if (hits.length === 0) break;
for (const hit of hits) { for (const hit of hits) {
if (manuallyResolvedReportCategories.has(hit.category)) continue;
pendingReportIds.add(hit.id); pendingReportIds.add(hit.id);
} }
offset += hits.length; offset += hits.length;
@@ -12,6 +12,7 @@ import {AdminUserSecurityService} from '@app/api/admin/services/AdminUserSecurit
import {AdminUserUpdatePropagator} from '@app/api/admin/services/AdminUserUpdatePropagator'; import {AdminUserUpdatePropagator} from '@app/api/admin/services/AdminUserUpdatePropagator';
import {createChannelID, createUserID, type UserID} from '@app/api/BrandedTypes'; import {createChannelID, createUserID, type UserID} from '@app/api/BrandedTypes';
import type {IChannelRepository} from '@app/api/channel/IChannelRepository'; import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
import {NcmecRepository} from '@app/api/csam/NcmecRepository';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate'; import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {IDiscriminatorService} from '@app/api/infrastructure/DiscriminatorService'; import type {IDiscriminatorService} from '@app/api/infrastructure/DiscriminatorService';
import type {EntityAssetService} from '@app/api/infrastructure/EntityAssetService'; import type {EntityAssetService} from '@app/api/infrastructure/EntityAssetService';
@@ -112,6 +113,7 @@ export class AdminUserService {
billingRepository: getBillingRepository(), billingRepository: getBillingRepository(),
oauth2Tokens: new OAuth2TokenRepository(), oauth2Tokens: new OAuth2TokenRepository(),
storeEntitlementService: deps.storeEntitlementService, storeEntitlementService: deps.storeEntitlementService,
ncmecRepository: new NcmecRepository(),
}); });
this.contactChangeLogService = contactChangeLog; this.contactChangeLogService = contactChangeLog;
} }
@@ -10,6 +10,8 @@ import {
type TestAccount, type TestAccount,
} from '@app/api/auth/tests/AuthTestUtils'; } from '@app/api/auth/tests/AuthTestUtils';
import {createReportID, createUserID} from '@app/api/BrandedTypes'; import {createReportID, createUserID} from '@app/api/BrandedTypes';
import {sendChannelMessage, setupTestGuildWithMembers} from '@app/api/channel/tests/ChannelTestUtils';
import {NcmecRepository} from '@app/api/csam/NcmecRepository';
import {getGatewayService, getSnowflakeService, setInjectedWorkerService} from '@app/api/middleware/ServiceRegistry'; import {getGatewayService, getSnowflakeService, setInjectedWorkerService} from '@app/api/middleware/ServiceRegistry';
import { import {
createUserCacheService, createUserCacheService,
@@ -97,10 +99,32 @@ async function runBulkJob(
return result as unknown as BulkJobResult; return result as unknown as BulkJobResult;
} }
async function reportUser(harness: ApiTestHarness, reporter: TestAccount, targetUserId: string): Promise<string> { async function reportUser(
harness: ApiTestHarness,
reporter: TestAccount,
targetUserId: string,
category = 'spam_account',
): Promise<string> {
const report = await createBuilder<ReportResponse>(harness, reporter.token) const report = await createBuilder<ReportResponse>(harness, reporter.token)
.post('/reports/user') .post('/reports/user')
.body({user_id: targetUserId, category: 'spam_account'}) .body({user_id: targetUserId, category})
.expect(HTTP_STATUS.OK)
.execute();
await drainSearchTasks();
return report.report_id;
}
async function reportMessage(
harness: ApiTestHarness,
reporter: TestAccount,
author: TestAccount,
channelId: string,
category: string,
): Promise<string> {
const message = await sendChannelMessage(harness, author.token, channelId, 'Reported content');
const report = await createBuilder<ReportResponse>(harness, reporter.token)
.post('/reports/message')
.body({channel_id: channelId, message_id: message.id, category})
.expect(HTTP_STATUS.OK) .expect(HTTP_STATUS.OK)
.execute(); .execute();
await drainSearchTasks(); await drainSearchTasks();
@@ -223,6 +247,76 @@ describe('bulkScheduleUserDeletion', () => {
const perUserLogs = await listAuditLogs('schedule_deletion'); const perUserLogs = await listAuditLogs('schedule_deletion');
expect(perUserLogs.filter((log) => log.targetId === BigInt(target.userId))).toHaveLength(1); expect(perUserLogs.filter((log) => log.targetId === BigInt(target.userId))).toHaveLength(1);
}); });
test('an abuse deletion resolves a spam report against the user', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'bulk:delete:users']);
const reporter = await createTestAccount(harness);
const target = await createTestAccount(harness);
const reportId = await reportUser(harness, reporter, target.userId);
const result = await runBulkJob([target.userId], admin.userId, DeletionReasons.BAN_EVASION);
expect(result.successful_count).toBe(1);
expect(await getReportStatus(reportId)).toBe(ReportStatus.RESOLVED);
});
test('an abuse deletion leaves child safety, underage user and self harm reports open', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'bulk:delete:users']);
const {owner, members, systemChannel} = await setupTestGuildWithMembers(harness, 3);
const [target, selfHarmReporter, userReporter] = members as [TestAccount, TestAccount, TestAccount];
const childSafetyReportId = await reportMessage(harness, owner, target, systemChannel.id, 'child_safety');
const selfHarmReportId = await reportMessage(harness, selfHarmReporter, target, systemChannel.id, 'self_harm');
const underageReportId = await reportUser(harness, userReporter, target.userId, 'underage_user');
const spamReportId = await reportUser(harness, await createTestAccount(harness), target.userId);
const result = await runBulkJob([target.userId], admin.userId, DeletionReasons.SPAM);
expect(result.successful_count).toBe(1);
expect(await getReportStatus(spamReportId)).toBe(ReportStatus.RESOLVED);
expect(await getReportStatus(childSafetyReportId)).toBe(ReportStatus.PENDING);
expect(await getReportStatus(selfHarmReportId)).toBe(ReportStatus.PENDING);
expect(await getReportStatus(underageReportId)).toBe(ReportStatus.PENDING);
});
test.each([
['OTHER', DeletionReasons.OTHER],
['INACTIVITY', DeletionReasons.INACTIVITY],
['CHILD_SEXUAL_CONTENT', DeletionReasons.CHILD_SEXUAL_CONTENT],
['CHILD_SAFETY_VIOLATION', DeletionReasons.CHILD_SAFETY_VIOLATION],
])('a deletion for %s leaves reports open', async (_label, reasonCode) => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'bulk:delete:users']);
const reporter = await createTestAccount(harness);
const target = await createTestAccount(harness);
const reportId = await reportUser(harness, reporter, target.userId);
const result = await runBulkJob([target.userId], admin.userId, reasonCode);
expect(result.successful_count).toBe(1);
expect(await getReportStatus(reportId)).toBe(ReportStatus.PENDING);
const resolutionLogs = await listAuditLogs('auto_resolve_reports_on_deletion');
expect(resolutionLogs.some((log) => log.targetId === BigInt(target.userId))).toBe(false);
});
test('an abuse deletion leaves reports open for a user with an NCMEC workflow', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'bulk:delete:users']);
const reporter = await createTestAccount(harness);
const target = await createTestAccount(harness);
const reportId = await reportUser(harness, reporter, target.userId);
const now = new Date();
await new NcmecRepository().upsertUserWorkflow({
user_id: BigInt(target.userId),
deleted_at: null,
deleted_by_admin_id: null,
deletion_private_reason: null,
deletion_ncmec_report_id: null,
archive_id: null,
archive_requested_at: null,
archive_requested_by_admin_id: null,
archive_audit_log_reason: null,
archive_completed_at: null,
deletion_job_queued_at: null,
previous_report_ids: null,
created_at: now,
updated_at: now,
});
const result = await runBulkJob([target.userId], admin.userId, DeletionReasons.SPAM);
expect(result.successful_count).toBe(1);
expect(await getReportStatus(reportId)).toBe(ReportStatus.PENDING);
});
test('a payload with notify_user false emails nobody and records it in the summary', async () => { test('a payload with notify_user false emails nobody and records it in the summary', async () => {
const admin = await createTestAccount(harness); const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'bulk:delete:users']); await setUserACLs(harness, admin, ['admin:authenticate', 'bulk:delete:users']);