mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(api): limit report auto-resolution on scheduled deletion (#3221)
This commit is contained in:
@@ -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']);
|
||||||
|
|||||||
Reference in New Issue
Block a user