refactor(api): emit moderation events and apply account actions (#3031)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-29 12:24:15 +02:00
1 parent c488906131
commit 364084c819
385 files changed
+8994 -22221

No files matched your search

+1 -76
View File
@@ -2,7 +2,7 @@
import type {AdminAuditLog, BannedIpEntry, BannedIpKind, IAdminRepository} from '@app/api/admin/IAdminRepository';
import {createUserID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import {isIpBanExempt} from '@app/api/ban/IpBanExemptions';
import {ContentBlocklistCategory} from '@app/api/constants/ContentModeration';
import {
deleteOneOrMany,
@@ -20,21 +20,16 @@ import type {
BannedUrlDomainRow,
BannedUrlRow,
} from '@app/api/database/types/AdminArchiveTypes';
import {isAccountPolicyContactDomainReputationExempt} from '@app/api/risk/AccountPolicyService';
import {isIpBanExempt} from '@app/api/risk/IpBanExemptions';
import {
AdminAuditLogs,
BannedAvatarHashes,
BannedEmails,
BannedFileShas,
BannedIps,
BannedPhonePrefixes,
BannedPhrases,
BannedProfileSubstrings,
BannedUrlDomains,
BannedUrls,
DisposableEmailDomains,
SuspiciousEmailDomains,
} from '@app/api/Tables';
import {parseIpBanEntry, tryParseSingleIp} from '@app/api/utils/IpRangeUtils';
import {canonicalizeStoredPhrase} from '@app/api/utils/PhraseBlocklistNormalization';
@@ -51,20 +46,10 @@ const IS_EMAIL_BANNED_QUERY = BannedEmails.select({
where: BannedEmails.where.eq('email_lower'),
});
const LOAD_ALL_BANNED_EMAILS_QUERY = BannedEmails.select();
const IS_EMAIL_DOMAIN_SUSPICIOUS_QUERY = SuspiciousEmailDomains.select({
where: SuspiciousEmailDomains.where.eq('domain'),
});
const LOAD_ALL_SUSPICIOUS_EMAIL_DOMAINS_QUERY = SuspiciousEmailDomains.select();
const IS_EMAIL_DOMAIN_DISPOSABLE_QUERY = DisposableEmailDomains.select({
where: DisposableEmailDomains.where.eq('domain'),
});
const createLoadDisposableEmailDomainsQuery = (limit?: number) =>
limit ? DisposableEmailDomains.select({limit}) : DisposableEmailDomains.select();
const IS_PHRASE_BANNED_QUERY = BannedPhrases.select({
where: BannedPhrases.where.eq('phrase'),
});
const LOAD_ALL_BANNED_PHRASES_QUERY = BannedPhrases.select();
const LOAD_ALL_BANNED_PHONE_PREFIXES_QUERY = BannedPhonePrefixes.select();
const IS_URL_BANNED_QUERY = BannedUrls.select({
where: BannedUrls.where.eq('url_canonical'),
});
@@ -263,59 +248,6 @@ export class AdminRepository implements IAdminRepository {
return rows.map((row) => row.email_lower);
}
async isEmailDomainSuspicious(domain: string): Promise<boolean> {
const domainLower = domain.toLowerCase();
if (isAccountPolicyContactDomainReputationExempt(domainLower)) return false;
const result = await fetchOne<{
domain: string;
}>(IS_EMAIL_DOMAIN_SUSPICIOUS_QUERY.bind({domain: domainLower}));
return !!result;
}
async addSuspiciousEmailDomain(domain: string): Promise<void> {
const domainLower = domain.toLowerCase();
await upsertOne(SuspiciousEmailDomains.insert({domain: domainLower}));
}
async removeSuspiciousEmailDomain(domain: string): Promise<void> {
const domainLower = domain.toLowerCase();
await deleteOneOrMany(SuspiciousEmailDomains.deleteByPk({domain: domainLower}));
}
async loadAllSuspiciousEmailDomains(): Promise<Array<string>> {
const rows = await fetchMany<{
domain: string;
}>(LOAD_ALL_SUSPICIOUS_EMAIL_DOMAINS_QUERY.bind({}));
return rows.map((row) => row.domain);
}
async isEmailDomainDisposable(domain: string): Promise<boolean> {
if (!Config.blocklistFeeds.enabled) return false;
const domainLower = domain.toLowerCase();
if (isAccountPolicyContactDomainReputationExempt(domainLower)) return false;
const result = await fetchOne<{
domain: string;
}>(IS_EMAIL_DOMAIN_DISPOSABLE_QUERY.bind({domain: domainLower}));
return !!result;
}
async addDisposableEmailDomain(domain: string): Promise<void> {
const domainLower = domain.toLowerCase();
await upsertOne(DisposableEmailDomains.insert({domain: domainLower}));
}
async removeDisposableEmailDomain(domain: string): Promise<void> {
const domainLower = domain.toLowerCase();
await deleteOneOrMany(DisposableEmailDomains.deleteByPk({domain: domainLower}));
}
async listDisposableEmailDomains(limit?: number): Promise<Array<string>> {
const rows = await fetchMany<{
domain: string;
}>(createLoadDisposableEmailDomainsQuery(limit).bind({}));
return rows.map((row) => row.domain);
}
async isPhraseBanned(phrase: string): Promise<boolean> {
const phraseLower = canonicalizeStoredPhrase(phrase);
const result = await fetchOne<{
@@ -341,13 +273,6 @@ export class AdminRepository implements IAdminRepository {
return rows.map((row) => row.phrase);
}
async loadAllBannedPhonePrefixes(): Promise<Array<string>> {
const rows = await fetchMany<{
prefix: string;
}>(LOAD_ALL_BANNED_PHONE_PREFIXES_QUERY.bind({}));
return rows.map((row) => row.prefix);
}
async isUrlBanned(url: string): Promise<boolean> {
const canonical = url.toLowerCase();
const result = await fetchOne<{
-6
View File
@@ -37,8 +37,6 @@ import {
} from '@app/api/middleware/ServiceSingletons';
import type {IApplicationRepository} from '@app/api/oauth/repositories/IApplicationRepository';
import type {ReportService} from '@app/api/report/ReportService';
import type {IRiskHistoryRepository} from '@app/api/risk/HistoricalOutcomeRepository';
import type {ISuspiciousIpRepository} from '@app/api/risk/SuspiciousIpRepository';
import type {UserService} from '@app/api/user/services/UserService';
import type {VoiceRepository} from '@app/api/voice/VoiceRepository';
import type {SendSystemDmResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
@@ -81,10 +79,8 @@ export class AdminService {
private readonly bulkMessageDeletionQueue: KVBulkMessageDeletionQueueService,
private readonly applicationRepository: IApplicationRepository,
private readonly stripe: Stripe | null = null,
private readonly riskHistoryRepository: Pick<IRiskHistoryRepository, 'recordOutcomeForUser'>,
private readonly jobLedger: IJobLedgerRepository,
private readonly ipInfoService: IpInfoService,
private readonly suspiciousIpRepository: ISuspiciousIpRepository,
) {
const {users, gateway, worker, snowflake} = this.apiContext.services;
this.auditService = new AdminAuditService(this.adminRepository, snowflake, {
@@ -97,7 +93,6 @@ export class AdminService {
adminRepository: this.adminRepository,
auditService: this.auditService,
ipInfoService: this.ipInfoService,
suspiciousIpRepository: this.suspiciousIpRepository,
});
this.userService = new AdminUserService({
apiContext: this.apiContext,
@@ -111,7 +106,6 @@ export class AdminService {
kvDeletionQueue: getKVAccountDeletionQueue(),
bulkMessageDeletionQueue: this.bulkMessageDeletionQueue,
stripe: this.stripe,
riskHistoryRepository: this.riskHistoryRepository,
reportService: this.reportService,
});
this.guildServiceAggregate = new AdminGuildService({
@@ -59,22 +59,6 @@ export abstract class IAdminRepository {
abstract loadAllBannedEmails(): Promise<Array<string>>;
abstract isEmailDomainSuspicious(domain: string): Promise<boolean>;
abstract addSuspiciousEmailDomain(domain: string): Promise<void>;
abstract removeSuspiciousEmailDomain(domain: string): Promise<void>;
abstract loadAllSuspiciousEmailDomains(): Promise<Array<string>>;
abstract isEmailDomainDisposable(domain: string): Promise<boolean>;
abstract addDisposableEmailDomain(domain: string): Promise<void>;
abstract removeDisposableEmailDomain(domain: string): Promise<void>;
abstract listDisposableEmailDomains(limit?: number): Promise<Array<string>>;
abstract isPhraseBanned(phrase: string): Promise<boolean>;
abstract banPhrase(phrase: string): Promise<void>;
@@ -83,8 +67,6 @@ export abstract class IAdminRepository {
abstract loadAllBannedPhrases(): Promise<Array<string>>;
abstract loadAllBannedPhonePrefixes(): Promise<Array<string>>;
abstract loadAllBannedIps(): Promise<Set<string>>;
abstract isUrlBanned(url: string): Promise<boolean>;
@@ -47,7 +47,6 @@ import {
BulkBanFileShasRequest,
BulkJobResponse,
CheckAvatarHashRequest,
SuspiciousEmailDomainRequest,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import type {ZodType} from 'zod';
@@ -76,17 +75,6 @@ const BLOCKLIST_CATALOG = [
supports_bulk_delete: false,
supports_update: false,
},
{
list_type: 'email-domain-suspicious' as const,
description:
'Email domains flagged as suspicious. Registration is not blocked, but new accounts using the domain must verify a phone number before they can act on the platform. The list itself is not exposed to users.',
value_field: 'domain',
fields: [],
scoped: false,
supports_bulk_create: false,
supports_bulk_delete: false,
supports_update: false,
},
{
list_type: 'phrase' as const,
description:
@@ -154,11 +142,6 @@ const BLOCKLIST_CATALOG = [
const BLOCKLIST_TYPE_ACLS: Record<AdminBlocklistListType, {add: string; check: string; remove: string}> = {
ip: {add: AdminACLs.BAN_IP_ADD, check: AdminACLs.BAN_IP_CHECK, remove: AdminACLs.BAN_IP_REMOVE},
email: {add: AdminACLs.BAN_EMAIL_ADD, check: AdminACLs.BAN_EMAIL_CHECK, remove: AdminACLs.BAN_EMAIL_REMOVE},
'email-domain-suspicious': {
add: AdminACLs.SUSPICIOUS_EMAIL_DOMAIN_ADD,
check: AdminACLs.SUSPICIOUS_EMAIL_DOMAIN_CHECK,
remove: AdminACLs.SUSPICIOUS_EMAIL_DOMAIN_REMOVE,
},
phrase: {add: AdminACLs.BAN_PHRASE_ADD, check: AdminACLs.BAN_PHRASE_CHECK, remove: AdminACLs.BAN_PHRASE_REMOVE},
url: {add: AdminACLs.BAN_URL_ADD, check: AdminACLs.BAN_URL_CHECK, remove: AdminACLs.BAN_URL_REMOVE},
'url-domain': {
@@ -186,7 +169,6 @@ const BLOCKLIST_TYPE_ACLS: Record<AdminBlocklistListType, {add: string; check: s
const BLOCKLIST_AUDIT_TARGET_TYPES: Record<AdminBlocklistListType, string> = {
ip: 'ip',
email: 'email',
'email-domain-suspicious': 'email_domain',
phrase: 'phrase',
url: 'url',
'url-domain': 'url_domain',
@@ -255,8 +237,6 @@ async function checkBlocklistEntry(
return bans.checkIpBan({ip: entryValue});
case 'email':
return bans.checkEmailBan({email: entryValue});
case 'email-domain-suspicious':
return bans.checkSuspiciousEmailDomain({domain: entryValue});
case 'phrase':
return bans.checkPhraseBan({phrase: entryValue});
case 'url':
@@ -375,13 +355,6 @@ export function BanAdminController(app: HonoApp) {
case 'email':
await bans.banEmail(await parseBlocklistBody(BanEmailRequest, raw), adminUserId, auditLogReason);
break;
case 'email-domain-suspicious':
await bans.addSuspiciousEmailDomain(
await parseBlocklistBody(SuspiciousEmailDomainRequest, raw),
adminUserId,
auditLogReason,
);
break;
case 'phrase':
await bans.banPhrase(await parseBlocklistBody(BanPhraseRequest, raw), adminUserId, auditLogReason);
break;
@@ -624,9 +597,6 @@ export function BanAdminController(app: HonoApp) {
case 'email':
await bans.unbanEmail({email: entryValue}, adminUserId, auditLogReason);
break;
case 'email-domain-suspicious':
await bans.removeSuspiciousEmailDomain({domain: entryValue}, adminUserId, auditLogReason);
break;
case 'phrase':
await bans.unbanPhrase({phrase: entryValue}, adminUserId, auditLogReason);
break;
@@ -135,11 +135,6 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
youtube_enabled: policy.youtube_enabled,
bluesky_enabled: policy.bluesky_enabled,
},
deferred_phone_gate: {
enabled: policy.deferred_phone_gate_enabled,
window_hours: policy.deferred_phone_gate_window_hours,
member_threshold: policy.deferred_phone_gate_member_threshold,
},
services_resolved: resolvedServices,
services_available: {
gif: integrations.gif.effective_available,
@@ -879,17 +874,6 @@ function planInstancePolicyPatch(
patch.bluesky_enabled = policy.services.bluesky_enabled ?? null;
}
}
if (policy.deferred_phone_gate) {
if (policy.deferred_phone_gate.enabled !== undefined) {
patch.deferred_phone_gate_enabled = policy.deferred_phone_gate.enabled;
}
if (policy.deferred_phone_gate.window_hours !== undefined) {
patch.deferred_phone_gate_window_hours = policy.deferred_phone_gate.window_hours;
}
if (policy.deferred_phone_gate.member_threshold !== undefined) {
patch.deferred_phone_gate_member_threshold = policy.deferred_phone_gate.member_threshold;
}
}
return {patch, enablesSingleCommunity};
}
@@ -17,10 +17,12 @@ import {SearchUsersResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas
import {
AdminAclListResponse,
AdminUserAclsRequest,
AdminUserBanNoteRequest,
AdminUserBanRequest,
AdminUserBotStatusRequest,
AdminUserChangeLogQuery,
AdminUserClearFieldsRequest,
AdminUserDeletionCancelRequest,
AdminUserDeletionScheduleRequest,
AdminUserDmChannelListQuery,
AdminUserDmChannelListResponse,
@@ -872,6 +874,30 @@ export function UserAdminController(app: HonoApp) {
);
},
);
app.post(
'/admin/users/:user_id/ban/notes',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_TEMP_BAN),
Validator('param', UserIdParam),
Validator('json', AdminUserBanNoteRequest),
OpenAPI({
operationId: 'annotate_admin_user_ban',
summary: 'Add a note to a user ban',
responseSchema: null,
statusCode: 204,
security: 'adminApiKey',
tags: 'Admin',
description:
'Append a note to the current ban of a user. The note is recorded as the reason of a new annotate_ban audit log entry whose metadata names the ban audit log entry. Earlier entries are never changed. Requires USER_TEMP_BAN permission.',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const {user_id: userId} = ctx.req.valid('param');
await adminService.userService.banService.annotateBan({user_id: userId, ...ctx.req.valid('json')}, adminUserId);
return ctx.body(null, 204);
},
);
app.put(
'/admin/users/:user_id/deletion',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
@@ -886,7 +912,7 @@ export function UserAdminController(app: HonoApp) {
security: 'adminApiKey',
tags: 'Admin',
description:
'Schedule user account for deletion after grace period. Account will be fully deleted with all content unless cancellation is executed. Creates audit log entry. Requires USER_DELETE permission.',
'Schedule user account for deletion after grace period. Account will be fully deleted with all content unless cancellation is executed. When a deletion is already scheduled, the request must name it in replace_pending_deletion_at or it returns 409. Records who scheduled the deletion. Creates audit log entry. Requires USER_DELETE permission.',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
@@ -909,6 +935,7 @@ export function UserAdminController(app: HonoApp) {
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_DELETE),
Validator('param', UserIdParam),
Validator('json', AdminUserDeletionCancelRequest),
OpenAPI({
operationId: 'cancel_admin_user_deletion',
summary: 'Cancel user deletion',
@@ -917,7 +944,7 @@ export function UserAdminController(app: HonoApp) {
security: 'adminApiKey',
tags: 'Admin',
description:
'Cancel a scheduled account deletion. User account restoration prevents data loss. Creates audit log entry. Requires USER_DELETE permission.',
'Cancel the scheduled account deletion named by expected_pending_deletion_at. Returns 409 when a different deletion is pending and 400 when none is. The user is emailed only when notify_user is true, and the email never includes the audit log reason. Creates audit log entry recording the cancelled deletion. Requires USER_DELETE permission.',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
@@ -927,7 +954,7 @@ export function UserAdminController(app: HonoApp) {
const {user_id: userId} = ctx.req.valid('param');
return ctx.json(
await adminService.userService.deletionService.cancelAccountDeletion(
{user_id: userId},
{user_id: userId, ...ctx.req.valid('json')},
adminUserId,
auditLogReason,
adminUserAcls,
@@ -20,6 +20,7 @@ export async function mapUserToAdminResponse(
const canViewEmail = !acls || hasAcl(acls, AdminACLs.USER_VIEW_EMAIL);
const canViewDob = !acls || hasAcl(acls, AdminACLs.USER_VIEW_DOB);
const canViewIp = !acls || hasAcl(acls, AdminACLs.USER_VIEW_IP);
const canViewAuditLog = !acls || hasAcl(acls, AdminACLs.AUDIT_LOG_VIEW);
const lastActiveIpReverse =
canViewIp && user.lastActiveIp ? await getIpAddressReverse(user.lastActiveIp, cacheService) : null;
let lastActiveLocation: string | null = null;
@@ -65,6 +66,9 @@ export async function mapUserToAdminResponse(
pending_bulk_message_deletion_at: user.pendingBulkMessageDeletionAt?.toISOString() ?? null,
deletion_reason_code: user.deletionReasonCode,
deletion_public_reason: user.deletionPublicReason,
deletion_audit_log_reason: canViewAuditLog ? user.deletionAuditLogReason : null,
deletion_scheduled_by: user.deletionScheduledBy?.toString() ?? null,
deletion_scheduled_at: user.deletionScheduledAt?.toISOString() ?? null,
acls: user.acls ? Array.from(user.acls) : [],
traits: Array.from(user.traits).sort(),
has_totp: user.totpSecret !== null,
@@ -65,6 +65,10 @@ export class AdminAuditService {
}
}
async findAuditLog(logId: bigint): Promise<AdminAuditLog | null> {
return this.adminRepository.getAuditLog(logId);
}
async getAuditLog(logId: bigint): Promise<AdminAuditLogResponse | null> {
const log = await this.adminRepository.getAuditLog(logId);
if (!log) {
@@ -4,6 +4,8 @@ import type {ApiContext} from '@app/api/ApiContext';
import type {IAdminRepository} from '@app/api/admin/IAdminRepository';
import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
import {createUserID, type UserID} from '@app/api/BrandedTypes';
import {getIpBanBlastRadiusVerdict, isSingleIpBanCandidate} from '@app/api/ban/IpBanCgnatGuard';
import {isIpBanExempt} from '@app/api/ban/IpBanExemptions';
import {
BANNED_AVATAR_HASHES_REFRESH_CHANNEL,
BANNED_FILE_SHAS_REFRESH_CHANNEL,
@@ -23,14 +25,6 @@ import {ipBanCache} from '@app/api/middleware/IpBanMiddleware';
import {phraseBlocklistCache} from '@app/api/middleware/PhraseBlocklistCache';
import {profileSubstringBlocklistCache} from '@app/api/middleware/ProfileSubstringBlocklistCache';
import {urlBlocklistCache} from '@app/api/middleware/UrlBlocklistCache';
import {
getIpBanBlastRadiusVerdict,
getSuspiciousIpSkipReason,
isSingleIpBanCandidate,
} from '@app/api/risk/IpBanCgnatGuard';
import {isIpBanExempt} from '@app/api/risk/IpBanExemptions';
import type {ISuspiciousIpRepository} from '@app/api/risk/SuspiciousIpRepository';
import {tryParseSingleIp} from '@app/api/utils/IpRangeUtils';
import {canonicalizeStoredPhrase} from '@app/api/utils/PhraseBlocklistNormalization';
import {canonicalizeUrl} from '@app/api/utils/UrlNormalizer';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
@@ -40,14 +34,13 @@ import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidat
import {NotFoundError} from '@fluxer/errors/src/domains/core/NotFoundError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {AdminBlocklistListType} from '@fluxer/schema/src/domains/admin/AdminBlocklistSchemas';
import type {IpInfoLookupResult, IpInfoService} from '@pkgs/geoip/src/IpInfoService';
import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService';
interface AdminBanManagementServiceDeps {
apiContext: ApiContext;
adminRepository: IAdminRepository;
auditService: AdminAuditService;
ipInfoService: IpInfoService;
suspiciousIpRepository: ISuspiciousIpRepository;
}
interface AdminBlocklistEntry {
@@ -207,86 +200,6 @@ export class AdminBanManagementService {
return {banned};
}
async markSuspiciousIpForScheduledDeletion(
data: {
ip: string;
sourceUserId: UserID;
deletionReasonCode: number;
},
adminUserId: UserID,
auditLogReason: string | null,
): Promise<void> {
const parsed = tryParseSingleIp(data.ip);
if (!parsed) {
await this.deps.auditService.createAuditLog({
adminUserId,
targetType: 'ip',
targetId: BigInt(0),
action: 'mark_suspicious_ip_skipped_invalid',
auditLogReason,
metadata: new Map([['ip', data.ip]]),
});
return;
}
let info: IpInfoLookupResult;
try {
info = await this.deps.ipInfoService.lookup(parsed.canonical, {
source: 'admin.scheduled_deletion_suspicious_ip',
reason: 'pre_write_suspicious_ip_guard',
metadata: {
source_user_id: data.sourceUserId.toString(),
deletion_reason_code: data.deletionReasonCode,
},
});
} catch (error) {
Logger.warn({error, ip: parsed.canonical}, 'IPInfo guard failed while marking suspicious IP');
return;
}
const skipReason = getSuspiciousIpSkipReason(info);
if (skipReason) {
Logger.info({ip: parsed.canonical, skipReason}, 'Skipping suspicious IP marker from scheduled deletion');
await this.deps.auditService.createAuditLog({
adminUserId,
targetType: 'ip',
targetId: BigInt(0),
action: `mark_suspicious_ip_skipped_${skipReason}`,
auditLogReason,
metadata: new Map([
['ip', parsed.canonical],
['source_user_id', data.sourceUserId.toString()],
['deletion_reason_code', data.deletionReasonCode.toString()],
['provider_name', info.anonymous.providerName ?? ''],
]),
});
return;
}
await this.deps.suspiciousIpRepository.markSuspiciousIp({
ip: parsed.canonical,
source: 'scheduled_deletion',
reason: 'account_scheduled_for_deletion',
sourceUserId: data.sourceUserId,
deletionReasonCode: data.deletionReasonCode,
providerName: info.anonymous.providerName,
asn: info.asn.number,
asnName: info.asn.name,
asnType: info.asn.type,
riskNote: info.riskNote,
});
await this.deps.auditService.createAuditLog({
adminUserId,
targetType: 'ip',
targetId: BigInt(0),
action: 'mark_suspicious_ip',
auditLogReason,
metadata: new Map([
['ip', parsed.canonical],
['source_user_id', data.sourceUserId.toString()],
['deletion_reason_code', data.deletionReasonCode.toString()],
['provider_name', info.anonymous.providerName ?? ''],
]),
});
}
private async shouldSkipIpBanForCgnat(ip: string): Promise<boolean> {
if (!isSingleIpBanCandidate(ip)) {
return false;
@@ -350,50 +263,6 @@ export class AdminBanManagementService {
return {banned};
}
async addSuspiciousEmailDomain(
data: {
domain: string;
},
adminUserId: UserID,
auditLogReason: string | null,
) {
const {adminRepository} = this.deps;
await adminRepository.addSuspiciousEmailDomain(data.domain);
await this.createBlocklistAuditLog({
adminUserId,
targetType: 'email_domain',
action: 'add_suspicious_email_domain',
auditLogReason,
metadata: new Map([['domain', data.domain]]),
});
}
async removeSuspiciousEmailDomain(
data: {
domain: string;
},
adminUserId: UserID,
auditLogReason: string | null,
) {
const {adminRepository} = this.deps;
await adminRepository.removeSuspiciousEmailDomain(data.domain);
await this.createBlocklistAuditLog({
adminUserId,
targetType: 'email_domain',
action: 'remove_suspicious_email_domain',
auditLogReason,
metadata: new Map([['domain', data.domain]]),
});
}
async checkSuspiciousEmailDomain(data: {domain: string}): Promise<{
banned: boolean;
}> {
const {adminRepository} = this.deps;
const banned = await adminRepository.isEmailDomainSuspicious(data.domain);
return {banned};
}
async banPhrase(
data: {
phrase: string;
@@ -872,10 +741,6 @@ export class AdminBanManagementService {
const rows = await adminRepository.loadAllBannedEmails();
return rows.map((value) => createBlocklistEntry(listType, value));
}
case 'email-domain-suspicious': {
const rows = await adminRepository.loadAllSuspiciousEmailDomains();
return rows.map((value) => createBlocklistEntry(listType, value));
}
case 'phrase': {
const rows = await adminRepository.loadAllBannedPhrases();
return rows.map((value) => createBlocklistEntry(listType, value));
@@ -6,9 +6,13 @@ import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService'
import type {AdminUserUpdatePropagator} from '@app/api/admin/services/AdminUserUpdatePropagator';
import * as AuthSession from '@app/api/auth/AuthSession';
import {createUserID, type UserID} from '@app/api/BrandedTypes';
import {emitAdminAction} from '@app/api/infrastructure/activity/AccountChangeEvents';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError';
import {ConflictError} from '@fluxer/errors/src/domains/core/ConflictError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {TempBanUserRequest} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import type {AdminUserBanNoteRequest, TempBanUserRequest} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
interface AdminUserBanServiceDeps {
apiContext: ApiContext;
@@ -70,11 +74,49 @@ export class AdminUserBanService {
['banned_until', tempBannedUntil.toISOString()],
]),
});
await emitAdminAction(adminUserId, userId, 'temp_ban', {durationHours: data.duration_hours});
return {
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
};
}
async annotateBan(data: AdminUserBanNoteRequest & {user_id: bigint}, adminUserId: UserID): Promise<void> {
const {users: userRepository} = this.deps.apiContext.services;
const {auditService} = this.deps;
const userId = createUserID(data.user_id);
const user = await userRepository.findUnique(userId);
if (!user) {
throw new UnknownUserError();
}
const banLog = await auditService.findAuditLog(data.ban_audit_log_id);
if (banLog?.action !== 'temp_ban' || banLog.targetType !== 'user' || banLog.targetId !== BigInt(userId)) {
throw new BadRequestError({
code: APIErrorCodes.INVALID_FORM_BODY,
message: 'ban_audit_log_id does not name a ban of this user',
});
}
const bannedUntil = user.tempBannedUntil;
if (
(user.flags & UserFlags.DISABLED) === 0n ||
!bannedUntil ||
bannedUntil.getTime() <= Date.now() ||
banLog.metadata.get('banned_until') !== bannedUntil.toISOString()
) {
throw new ConflictError({
code: APIErrorCodes.CONFLICT,
message: 'ban_audit_log_id does not name the current ban of this user',
});
}
await auditService.createAuditLog({
adminUserId,
targetType: 'user',
targetId: BigInt(userId),
action: 'annotate_ban',
auditLogReason: data.note,
metadata: new Map([['ban_audit_log_id', data.ban_audit_log_id.toString()]]),
});
}
async unbanUser(
data: {
user_id: bigint;
@@ -110,6 +152,7 @@ export class AdminUserBanService {
auditLogReason,
metadata: new Map(),
});
await emitAdminAction(adminUserId, userId, 'unban');
return {
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
};
@@ -8,18 +8,26 @@ import type {AdminUserUpdatePropagator} from '@app/api/admin/services/AdminUserU
import * as AuthSession from '@app/api/auth/AuthSession';
import {createReportID, createUserID, type UserID} from '@app/api/BrandedTypes';
import type {BillingRepository} from '@app/api/billing/repositories/BillingRepository';
import {emitAdminAction} from '@app/api/infrastructure/activity/AccountChangeEvents';
import type {KVAccountDeletionQueueService} from '@app/api/infrastructure/KVAccountDeletionQueueService';
import {Logger} from '@app/api/Logger';
import type {User} from '@app/api/models/User';
import type {OAuth2TokenRepository} from '@app/api/oauth/repositories/OAuth2TokenRepository';
import {ReportStatus} from '@app/api/report/IReportRepository';
import type {ReportService} from '@app/api/report/ReportService';
import {getReportSearchService} from '@app/api/SearchFactory';
import {clearPendingDeletion, reschedulePendingDeletion} from '@app/api/user/services/PendingDeletionCoordinator';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {DeletionReasons} from '@fluxer/constants/src/Core';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {ConflictError} from '@fluxer/errors/src/domains/core/ConflictError';
import {NoPendingDeletionError} from '@fluxer/errors/src/domains/core/NoPendingDeletionError';
import {ReportAlreadyResolvedError} from '@fluxer/errors/src/domains/moderation/ReportAlreadyResolvedError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {ScheduleAccountDeletionRequest} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import type {
AdminUserDeletionCancelRequest,
ScheduleAccountDeletionRequest,
} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import type Stripe from 'stripe';
interface AdminUserDeletionServiceDeps {
@@ -31,11 +39,26 @@ interface AdminUserDeletionServiceDeps {
kvDeletionQueue: KVAccountDeletionQueueService;
stripe: Stripe | null;
billingRepository: BillingRepository;
oauth2Tokens: Pick<OAuth2TokenRepository, 'deleteAllAccessTokensForUser' | 'deleteAllRefreshTokensForUser'>;
}
const minUserRequestedDeletionDays = 14;
const minStandardDeletionDays = 60;
function describePendingDeletion(user: User, prefix: string): Array<[string, string]> {
if (!user.pendingDeletionAt) return [];
return [
[`${prefix}_pending_deletion_at`, user.pendingDeletionAt.toISOString()],
[`${prefix}_scheduled_by`, user.deletionScheduledBy?.toString() ?? ''],
[`${prefix}_scheduled_at`, user.deletionScheduledAt?.toISOString() ?? ''],
[`${prefix}_reason_code`, user.deletionReasonCode?.toString() ?? ''],
];
}
function sameInstant(left: Date, right: string): boolean {
return left.getTime() === new Date(right).getTime();
}
export function resolveDeletionDays(reasonCode: number, requestedDays: number): number {
const minDays =
reasonCode === DeletionReasons.USER_REQUESTED ? minUserRequestedDeletionDays : minStandardDeletionDays;
@@ -70,8 +93,18 @@ export class AdminUserDeletionService {
if (!user) {
throw new UnknownUserError();
}
if (
user.pendingDeletionAt &&
(!data.replace_pending_deletion_at || !sameInstant(user.pendingDeletionAt, data.replace_pending_deletion_at))
) {
throw new ConflictError({
code: APIErrorCodes.CONFLICT,
message: 'A deletion is already scheduled for this account',
});
}
const daysUntilDeletion = resolveDeletionDays(data.reason_code, data.days_until_deletion);
const pendingDeletionAt = new Date();
const scheduledAt = new Date();
const pendingDeletionAt = new Date(scheduledAt);
pendingDeletionAt.setDate(pendingDeletionAt.getDate() + daysUntilDeletion);
const updatedUser = await userRepository.updateDeletionSchedule(user, {
flags: user.flags | UserFlags.DELETED,
@@ -79,6 +112,8 @@ export class AdminUserDeletionService {
deletion_reason_code: data.reason_code,
deletion_public_reason: data.public_reason ?? null,
deletion_audit_log_reason: auditLogReason,
deletion_scheduled_by: adminUserId,
deletion_scheduled_at: scheduledAt,
});
await reschedulePendingDeletion({
userId,
@@ -89,6 +124,8 @@ export class AdminUserDeletionService {
deletionQueue: this.deps.kvDeletionQueue,
});
await AuthSession.terminateAllUserSessions(this.deps.apiContext, userId);
await this.deps.oauth2Tokens.deleteAllAccessTokensForUser(userId);
await this.deps.oauth2Tokens.deleteAllRefreshTokensForUser(userId);
const {stripe, billingRepository} = this.deps;
if (user.stripeSubscriptionId && stripe) {
try {
@@ -158,17 +195,16 @@ export class AdminUserDeletionService {
metadata: new Map([
['days', daysUntilDeletion.toString()],
['reason_code', data.reason_code.toString()],
['pending_deletion_at', pendingDeletionAt.toISOString()],
...describePendingDeletion(user, 'replaced'),
]),
});
let knownIps: ReadonlySet<string> = new Set();
if (data.reason_code !== DeletionReasons.USER_REQUESTED) {
await this.banIdentifiersForScheduledDeletion({
user,
adminUserId,
auditLogReason,
deletionReasonCode: data.reason_code,
});
knownIps = await this.banIdentifiersForScheduledDeletion({user, adminUserId, auditLogReason});
await this.resolvePendingReportsAgainstUser({user, adminUserId});
}
await emitAdminAction(adminUserId, userId, 'schedule_deletion', {reasonCode: data.reason_code, ips: knownIps});
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
if (user.email) {
try {
@@ -190,9 +226,7 @@ export class AdminUserDeletionService {
}
async cancelAccountDeletion(
data: {
user_id: bigint;
},
data: AdminUserDeletionCancelRequest & {user_id: bigint},
adminUserId: UserID,
auditLogReason: string | null,
acls: ReadonlySet<string>,
@@ -204,6 +238,15 @@ export class AdminUserDeletionService {
if (!user) {
throw new UnknownUserError();
}
if (!user.pendingDeletionAt) {
throw new NoPendingDeletionError();
}
if (!sameInstant(user.pendingDeletionAt, data.expected_pending_deletion_at)) {
throw new ConflictError({
code: APIErrorCodes.CONFLICT,
message: 'The pending deletion does not match expected_pending_deletion_at',
});
}
const updatedUser = await userRepository.updateDeletionSchedule(user, {
flags: user.flags & ~UserFlags.DELETED & ~UserFlags.SELF_DELETED,
pending_deletion_at: null,
@@ -218,8 +261,8 @@ export class AdminUserDeletionService {
deletionQueue: this.deps.kvDeletionQueue,
});
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
if (user.email) {
await emailService.sendUnbanNotification(user.email, user.username, auditLogReason || null, user.locale);
if (data.notify_user && user.email) {
await emailService.sendUnbanNotification(user.email, user.username, null, user.locale);
}
await auditService.createAuditLog({
adminUserId,
@@ -227,8 +270,12 @@ export class AdminUserDeletionService {
targetId: BigInt(userId),
action: 'cancel_deletion',
auditLogReason,
metadata: new Map(),
metadata: new Map([
...describePendingDeletion(user, 'cancelled'),
['notify_user', data.notify_user ? 'true' : 'false'],
]),
});
await emitAdminAction(adminUserId, userId, 'cancel_deletion');
return {
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
};
@@ -238,9 +285,8 @@ export class AdminUserDeletionService {
user: User;
adminUserId: UserID;
auditLogReason: string | null;
deletionReasonCode: number;
}): Promise<void> {
const {user, adminUserId, auditLogReason, deletionReasonCode} = params;
}): Promise<ReadonlySet<string>> {
const {user, adminUserId, auditLogReason} = params;
const {users: userRepository} = this.deps.apiContext.services;
const {banManagementService} = this.deps;
const reason = auditLogReason ?? 'auto-enforcement on scheduled deletion';
@@ -251,52 +297,35 @@ export class AdminUserDeletionService {
Logger.warn({error, userId: user.id.toString()}, 'Failed to auto-ban email on scheduled deletion');
}
}
const ipsToReview = new Set<string>();
const knownIps = new Set<string>();
if (user.lastActiveIp) {
ipsToReview.add(user.lastActiveIp);
knownIps.add(user.lastActiveIp);
}
try {
const authorizedIps = await userRepository.getAuthorizedIps(user.id);
for (const {ip} of authorizedIps) {
if (ip) ipsToReview.add(ip);
if (ip) knownIps.add(ip);
}
} catch (error) {
Logger.warn({error, userId: user.id.toString()}, 'Failed to list authorized IPs for scheduled deletion review');
Logger.warn({error, userId: user.id.toString()}, 'Failed to list authorized IPs for scheduled deletion');
}
try {
const sessions = await userRepository.listAuthSessions(user.id);
for (const session of sessions) {
if (session.clientIp) ipsToReview.add(session.clientIp);
if (session.clientIp) knownIps.add(session.clientIp);
}
} catch (error) {
Logger.warn({error, userId: user.id.toString()}, 'Failed to list auth sessions for scheduled deletion review');
Logger.warn({error, userId: user.id.toString()}, 'Failed to list auth sessions for scheduled deletion');
}
try {
const tombstones = await userRepository.listAuthSessionTombstones(user.id);
for (const tombstone of tombstones) {
if (tombstone.clientIp) ipsToReview.add(tombstone.clientIp);
if (tombstone.clientIp) knownIps.add(tombstone.clientIp);
}
} catch (error) {
Logger.warn(
{error, userId: user.id.toString()},
'Failed to list auth session tombstones for scheduled deletion review',
);
}
for (const ip of ipsToReview) {
try {
await banManagementService.markSuspiciousIpForScheduledDeletion(
{
ip,
sourceUserId: user.id,
deletionReasonCode,
},
adminUserId,
reason,
);
} catch (error) {
Logger.warn({error, userId: user.id.toString(), ip}, 'Failed to mark suspicious IP on scheduled deletion');
}
Logger.warn({error, userId: user.id.toString()}, 'Failed to list auth session tombstones for scheduled deletion');
}
return knownIps;
}
private async resolvePendingReportsAgainstUser(params: {user: User; adminUserId: UserID}): Promise<void> {
@@ -11,10 +11,9 @@ import * as AuthUtility from '@app/api/auth/AuthUtility';
import {visibleWebAuthnCredentials} from '@app/api/auth/services/PasskeyRelyingParty';
import {createPasswordResetToken, createUserID, type UserID} from '@app/api/BrandedTypes';
import type {UserRow} from '@app/api/database/types/UserTypes';
import {emitAdminAction} from '@app/api/infrastructure/activity/AccountChangeEvents';
import {Logger} from '@app/api/Logger';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import type {IRiskHistoryRepository} from '@app/api/risk/HistoricalOutcomeRepository';
import type {HistoricalOutcomeCode} from '@app/api/risk/RiskHistoryTypes';
import {mapWebAuthnCredentialToResponse} from '@app/api/user/UserMappers';
import {resolveAssignedTraits} from '@app/api/user/UserTraits';
import {getIpAddressReverse, getLocationLabelFromIp} from '@app/api/utils/IpUtils';
@@ -23,7 +22,6 @@ import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {
ADMIN_PHONE_TOGGLE_CLEARABLE_FLAGS,
ALL_SUSPICIOUS_ACTIVITY_FLAGS,
DEFERRABLE_PHONE_FLAGS,
DEFERRED_PHONE_ON_COMMUNITY_JOIN,
PHONE_GATE_PROMOTED_FROM_DEFERRAL,
@@ -54,7 +52,6 @@ interface AdminUserSecurityServiceDeps {
apiContext: ApiContext;
auditService: AdminAuditService;
updatePropagator: AdminUserUpdatePropagator;
riskHistoryRepository: Pick<IRiskHistoryRepository, 'recordOutcomeForUser'>;
}
interface FlagAuditMetadataParams {
@@ -158,6 +155,7 @@ export class AdminUserSecurityService {
newFlags,
}),
});
await emitAdminAction(adminUserId, userId, 'update_flags');
return {
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
};
@@ -433,6 +431,7 @@ export class AdminUserSecurityService {
],
),
});
await emitAdminAction(adminUserId, userId, 'set_phone_verified');
return {
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
};
@@ -471,12 +470,6 @@ export class AdminUserSecurityService {
user.toRow(),
);
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
if (
(currentFlags & ALL_SUSPICIOUS_ACTIVITY_FLAGS) !== (newFlags & ALL_SUSPICIOUS_ACTIVITY_FLAGS) &&
(newFlags & ALL_SUSPICIOUS_ACTIVITY_FLAGS) !== 0
) {
await this.recordRiskOutcomes(userId, ['challenged'], 'admin_update_suspicious_activity_flags');
}
await auditService.createAuditLog({
adminUserId,
targetType: 'user',
@@ -485,6 +478,7 @@ export class AdminUserSecurityService {
auditLogReason,
metadata: new Map([['flags', data.flags.toString()]]),
});
await emitAdminAction(adminUserId, userId, 'set_suspicious_flags');
return {
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
};
@@ -514,11 +508,6 @@ export class AdminUserSecurityService {
);
await AuthSession.terminateAllUserSessions(this.deps.apiContext, userId);
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
await this.recordRiskOutcomes(
userId,
data.flags !== 0 ? ['challenged', 'disabled_suspicious'] : ['disabled_suspicious'],
'admin_disable_suspicious_activity',
);
if (user.email) {
await emailService.sendAccountDisabledForSuspiciousActivityEmail(user.email, user.username, null, user.locale);
}
@@ -530,6 +519,7 @@ export class AdminUserSecurityService {
auditLogReason,
metadata: new Map([['flags', data.flags.toString()]]),
});
await emitAdminAction(adminUserId, userId, 'disable_suspicious');
return {
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
};
@@ -720,24 +710,4 @@ export class AdminUserSecurityService {
}),
};
}
private async recordRiskOutcomes(
userId: UserID,
outcomeCodes: ReadonlyArray<HistoricalOutcomeCode>,
source: string,
): Promise<void> {
if (outcomeCodes.length === 0) {
return;
}
try {
await this.deps.riskHistoryRepository.recordOutcomeForUser({
userId: userId.toString(),
occurredAt: new Date(),
source,
outcomeCodes,
});
} catch (error) {
Logger.warn({error, userId, source}, 'Failed to persist admin risk history outcome');
}
}
}
@@ -19,8 +19,8 @@ import type {KVAccountDeletionQueueService} from '@app/api/infrastructure/KVAcco
import type {KVBulkMessageDeletionQueueService} from '@app/api/infrastructure/KVBulkMessageDeletionQueueService';
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import {getBillingRepository} from '@app/api/middleware/ServiceRegistry';
import {OAuth2TokenRepository} from '@app/api/oauth/repositories/OAuth2TokenRepository';
import type {ReportService} from '@app/api/report/ReportService';
import type {IRiskHistoryRepository} from '@app/api/risk/HistoricalOutcomeRepository';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import type {UserContactChangeLogService} from '@app/api/user/services/UserContactChangeLogService';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
@@ -46,7 +46,6 @@ interface AdminUserServiceDeps {
kvDeletionQueue: KVAccountDeletionQueueService;
bulkMessageDeletionQueue: KVBulkMessageDeletionQueueService;
stripe: Stripe | null;
riskHistoryRepository: Pick<IRiskHistoryRepository, 'recordOutcomeForUser'>;
reportService: ReportService;
}
@@ -94,7 +93,6 @@ export class AdminUserService {
apiContext: deps.apiContext,
auditService: deps.auditService,
updatePropagator: this.updatePropagator,
riskHistoryRepository: deps.riskHistoryRepository,
});
this.banService = new AdminUserBanService({
apiContext: deps.apiContext,
@@ -110,6 +108,7 @@ export class AdminUserService {
kvDeletionQueue: deps.kvDeletionQueue,
stripe: deps.stripe,
billingRepository: getBillingRepository(),
oauth2Tokens: new OAuth2TokenRepository(),
});
this.contactChangeLogService = contactChangeLog;
}
@@ -44,7 +44,6 @@ export class AdminGuildMembershipService {
throw new UnknownUserError();
}
await guildService.members.addUserToGuild({
skipRiskGate: true,
userId,
guildId,
sendJoinMessage,
@@ -5,10 +5,9 @@ import type {IAdminRepository} from '@app/api/admin/IAdminRepository';
import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
import {AdminBanManagementService} from '@app/api/admin/services/AdminBanManagementService';
import {createUserID} from '@app/api/BrandedTypes';
import {resetIpBanExemptionsForTesting} from '@app/api/ban/IpBanExemptions';
import {getConfig} from '@app/api/Config';
import {ipBanCache} from '@app/api/middleware/IpBanMiddleware';
import {resetIpBanExemptionsForTesting} from '@app/api/risk/IpBanExemptions';
import type {ISuspiciousIpRepository} from '@app/api/risk/SuspiciousIpRepository';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError';
import type {IpInfoLookupResult, IpInfoService} from '@pkgs/geoip/src/IpInfoService';
@@ -28,7 +27,7 @@ function ipInfoResult(overrides: Partial<IpInfoLookupResult> = {}): IpInfoLookup
return {
ip: CARRIER_IP,
available: true,
riskNote: 'test',
note: 'test',
geo: {
countryCode: 'US',
countryName: 'United States',
@@ -101,7 +100,6 @@ function createBanManagementService(lookup: (ip: string) => Promise<IpInfoLookup
adminRepository: adminRepository as unknown as IAdminRepository,
auditService: auditService as unknown as AdminAuditService,
ipInfoService: ipInfoService as unknown as IpInfoService,
suspiciousIpRepository: {} as unknown as ISuspiciousIpRepository,
});
return {service, bannedIps, auditCalls};
}
@@ -0,0 +1,102 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {AdminAuditLog} from '@app/api/admin/IAdminRepository';
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {decodeHeaderUtf8} from '@app/api/middleware/AuditLogMiddleware';
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
function asReceivedHeader(value: string): string {
return Buffer.from(value, 'utf8').toString('latin1');
}
async function auditLogsFor(targetUserId: string, action: string): Promise<Array<AdminAuditLog>> {
const logs = await getAdminRepository().listAllAuditLogsPaginated(1000);
return logs.filter((log) => log.action === action && log.targetId.toString() === targetUserId);
}
describe('decodeHeaderUtf8', () => {
test('reads UTF-8 bytes that arrived as a latin1 string', () => {
expect(decodeHeaderUtf8(asReceivedHeader('§ 4.2 räksmörgås 日本'))).toBe('§ 4.2 räksmörgås 日本');
});
test('keeps ASCII and latin1 text that is not UTF-8', () => {
expect(decodeHeaderUtf8('Batch 12')).toBe('Batch 12');
expect(decodeHeaderUtf8('café')).toBe('café');
});
});
describe('Admin ban reasons and notes', () => {
let harness: ApiTestHarness;
let admin: TestAccount;
beforeEach(async () => {
harness = await createApiTestHarness();
admin = await setUserACLs(harness, await createTestAccount(harness), ['admin:authenticate', 'user:temp_ban']);
});
afterEach(async () => {
await harness?.shutdown();
});
async function ban(target: TestAccount, reason: string): Promise<AdminAuditLog> {
await createBuilder(harness, admin.token)
.put(`/admin/users/${target.userId}/ban`)
.header('X-Audit-Log-Reason', asReceivedHeader(reason))
.body({duration_hours: 24})
.expect(HTTP_STATUS.OK)
.execute();
const [log] = await auditLogsFor(target.userId, 'temp_ban');
return log!;
}
test('stores a UTF-8 ban reason as sent', async () => {
const target = await createTestAccount(harness);
const log = await ban(target, '§ 3 Regel – wiederholt');
expect(log.auditLogReason).toBe('§ 3 Regel – wiederholt');
});
test('appends a note to the current ban without changing the ban entry', async () => {
const target = await createTestAccount(harness);
const banLog = await ban(target, 'Original reason');
await createBuilder(harness, admin.token)
.post(`/admin/users/${target.userId}/ban/notes`)
.body({ban_audit_log_id: banLog.logId.toString(), note: 'Also sent § 4 links'})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
const [note] = await auditLogsFor(target.userId, 'annotate_ban');
expect(note?.auditLogReason).toBe('Also sent § 4 links');
expect(note?.adminUserId.toString()).toBe(admin.userId);
expect(Object.fromEntries(note!.metadata)).toEqual({ban_audit_log_id: banLog.logId.toString()});
const [unchanged] = await auditLogsFor(target.userId, 'temp_ban');
expect(unchanged?.auditLogReason).toBe('Original reason');
});
test('refuses a note that names another user ban', async () => {
const target = await createTestAccount(harness);
const other = await createTestAccount(harness);
await ban(target, 'Target ban');
const otherBan = await ban(other, 'Other ban');
await createBuilder(harness, admin.token)
.post(`/admin/users/${target.userId}/ban/notes`)
.body({ban_audit_log_id: otherBan.logId.toString(), note: 'Wrong ban'})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
expect(await auditLogsFor(target.userId, 'annotate_ban')).toHaveLength(0);
});
test('refuses a note once the ban has been lifted', async () => {
const target = await createTestAccount(harness);
const banLog = await ban(target, 'Lifted ban');
await createBuilder(harness, admin.token)
.delete(`/admin/users/${target.userId}/ban`)
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder(harness, admin.token)
.post(`/admin/users/${target.userId}/ban/notes`)
.body({ban_audit_log_id: banLog.logId.toString(), note: 'Too late'})
.expect(HTTP_STATUS.CONFLICT)
.execute();
});
});
@@ -1,9 +1,17 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {randomInt} from 'node:crypto';
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
import {
clearTestEmails,
createTestAccount,
findLastTestEmail,
listTestEmails,
setUserACLs,
type TestAccount,
} from '@app/api/auth/tests/AuthTestUtils';
import {createUserID} from '@app/api/BrandedTypes';
import {setInjectedIpInfoService} from '@app/api/middleware/ServiceMiddleware';
import {CassandraSuspiciousIpRepository} from '@app/api/risk/SuspiciousIpRepository';
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
@@ -20,7 +28,7 @@ function ipInfoResult(ip: string, overrides: Partial<IpInfoLookupResult> = {}):
return {
ip,
available: true,
riskNote: 'test',
note: 'test',
geo: {
countryCode: 'US',
countryName: 'United States',
@@ -105,7 +113,11 @@ describe('Admin Deletion Queue', () => {
}>(harness, `${admin.token}`)
.put(`/admin/users/${targetUser.userId}/deletion`)
.header('x-forwarded-for', adminIp)
.body({reason_code: 2, days_until_deletion: 62})
.body({
reason_code: 2,
days_until_deletion: 62,
replace_pending_deletion_at: firstSchedule.user.pending_deletion_at,
})
.execute();
const firstDate = firstSchedule.user.pending_deletion_at.slice(0, 10);
const secondDate = secondSchedule.user.pending_deletion_at.slice(0, 10);
@@ -144,6 +156,7 @@ describe('Admin Deletion Queue', () => {
}>(harness, `${admin.token}`)
.delete(`/admin/users/${targetUser.userId}/deletion`)
.header('x-forwarded-for', adminIp)
.body({expected_pending_deletion_at: schedule.user.pending_deletion_at})
.execute();
expect(await harness.kvProvider.zcard('deletion_queue')).toBe(0);
expect(
@@ -172,7 +185,7 @@ describe('Admin Deletion Queue', () => {
expect(ipBan.banned).toBe(false);
expect(emailBan.banned).toBe(false);
});
test('moderation scheduled deletion bans email and marks IP suspicious without banning it', async () => {
test('moderation scheduled deletion bans email without banning the IP', async () => {
const adminIp = createUniqueTestIp();
const targetIp = createUniqueTestIp();
const admin = await createTestAccount(harness, {ipAddress: adminIp});
@@ -189,87 +202,8 @@ describe('Admin Deletion Queue', () => {
const emailBan = await createBuilder<{banned: boolean}>(harness, `${admin.token}`)
.get(`/admin/blocklists/email/entries/${encodeURIComponent(targetUser.email)}`)
.execute();
const suspiciousIp = await new CassandraSuspiciousIpRepository().findActiveByIp(targetIp);
expect(ipBan.banned).toBe(false);
expect(emailBan.banned).toBe(true);
expect(suspiciousIp?.source).toBe('scheduled_deletion');
expect(suspiciousIp?.sourceUserId).toBe(targetUser.userId);
});
test('moderation scheduled deletion does not mark trusted paid VPN IPs suspicious', async () => {
const adminIp = createUniqueTestIp();
const targetIp = createUniqueTestIp();
const admin = await createTestAccount(harness, {ipAddress: adminIp});
const targetUser = await createTestAccount(harness, {ipAddress: targetIp});
setInjectedIpInfoService({
async lookup(ip: string) {
return ipInfoResult(ip, {
anonymous: {
isAnonymous: true,
providerName: 'Example Privacy Relay LLC',
isVpn: true,
isProxy: false,
isResidentialProxy: false,
isTor: false,
isRelay: false,
percentDaysSeen: null,
},
});
},
});
await setUserACLs(harness, admin, ['admin:authenticate', 'user:delete', 'ban:ip:check', 'ban:email:check']);
await createBuilder(harness, `${admin.token}`)
.put(`/admin/users/${targetUser.userId}/deletion`)
.header('x-forwarded-for', adminIp)
.body({reason_code: DeletionReasons.SPAM, days_until_deletion: 60})
.execute();
const ipBan = await createBuilder<{banned: boolean}>(harness, `${admin.token}`)
.get(`/admin/blocklists/ip/entries/${encodeURIComponent(targetIp)}`)
.execute();
const suspiciousIp = await new CassandraSuspiciousIpRepository().findActiveByIp(targetIp);
expect(ipBan.banned).toBe(false);
expect(suspiciousIp).toBeNull();
});
test('moderation scheduled deletion does not mark mobile carrier IPs suspicious', async () => {
const adminIp = createUniqueTestIp();
const targetIp = createUniqueTestIp();
const admin = await createTestAccount(harness, {ipAddress: adminIp});
const targetUser = await createTestAccount(harness, {ipAddress: targetIp});
setInjectedIpInfoService({
async lookup(ip: string) {
return ipInfoResult(ip, {
asn: {
asn: 'AS64501',
number: 64501,
name: 'Test Mobile',
domain: null,
type: 'mobile',
},
mobile: {
name: 'Test Mobile',
mcc: '001',
mnc: '01',
},
flags: {
isAnycast: false,
isHosting: false,
isMobile: true,
isSatellite: false,
},
});
},
});
await setUserACLs(harness, admin, ['admin:authenticate', 'user:delete', 'ban:ip:check', 'ban:email:check']);
await createBuilder(harness, `${admin.token}`)
.put(`/admin/users/${targetUser.userId}/deletion`)
.header('x-forwarded-for', adminIp)
.body({reason_code: DeletionReasons.SPAM, days_until_deletion: 60})
.execute();
const ipBan = await createBuilder<{banned: boolean}>(harness, `${admin.token}`)
.get(`/admin/blocklists/ip/entries/${encodeURIComponent(targetIp)}`)
.execute();
const suspiciousIp = await new CassandraSuspiciousIpRepository().findActiveByIp(targetIp);
expect(ipBan.banned).toBe(false);
expect(suspiciousIp).toBeNull();
});
test('rejects a scheduled deletion reason code outside the registry', async () => {
const admin = await createTestAccount(harness);
@@ -317,4 +251,155 @@ describe('Admin Deletion Queue', () => {
.executeWithResponse();
expect(json.errors.some((entry) => entry.path === 'reason_code')).toBe(true);
});
describe('naming the deletion', () => {
interface ScheduledUser {
user: {
pending_deletion_at: string | null;
deletion_scheduled_by: string | null;
deletion_scheduled_at: string | null;
deletion_audit_log_reason: string | null;
};
}
async function scheduleAs(admin: TestAccount, target: TestAccount, reasonCode: number, reason: string) {
return createBuilder<ScheduledUser>(harness, admin.token)
.put(`/admin/users/${target.userId}/deletion`)
.header('X-Audit-Log-Reason', reason)
.body({reason_code: reasonCode, days_until_deletion: 60})
.expect(HTTP_STATUS.OK)
.execute();
}
async function createAdmin(acls: Array<string> = []): Promise<TestAccount> {
return setUserACLs(harness, await createTestAccount(harness), ['admin:authenticate', 'user:delete', ...acls]);
}
test('records who scheduled the deletion and shows the private reason to audit log viewers', async () => {
const admin = await createAdmin(['audit_log:view']);
const target = await createTestAccount(harness);
const scheduled = await scheduleAs(admin, target, DeletionReasons.SPAM, 'Batch review');
expect(scheduled.user.deletion_scheduled_by).toBe(admin.userId);
expect(scheduled.user.deletion_scheduled_at).not.toBeNull();
expect(scheduled.user.deletion_audit_log_reason).toBe('Batch review');
const other = await createAdmin(['user:lookup']);
const viewed = await createBuilder<{users: Array<ScheduledUser['user']>}>(harness, other.token)
.get(`/admin/users/${target.userId}`)
.expect(HTTP_STATUS.OK)
.execute();
expect(viewed.users[0]?.deletion_scheduled_by).toBe(admin.userId);
expect(viewed.users[0]?.deletion_audit_log_reason).toBeNull();
});
test('a second schedule without naming the pending deletion is refused and keeps it', async () => {
const first = await createAdmin();
const second = await createAdmin();
const target = await createTestAccount(harness);
const scheduled = await scheduleAs(first, target, DeletionReasons.SPAM, 'First review');
await createBuilder(harness, second.token)
.put(`/admin/users/${target.userId}/deletion`)
.body({reason_code: DeletionReasons.SPAM, days_until_deletion: 60})
.expect(HTTP_STATUS.CONFLICT)
.execute();
await createBuilder(harness, first.token)
.put(`/admin/users/${target.userId}/deletion`)
.body({reason_code: DeletionReasons.OTHER, days_until_deletion: 60})
.expect(HTTP_STATUS.CONFLICT)
.execute();
const user = await new UserRepository().findUnique(createUserID(BigInt(target.userId)));
expect(user?.pendingDeletionAt?.toISOString()).toBe(scheduled.user.pending_deletion_at);
expect(user?.deletionScheduledBy?.toString()).toBe(first.userId);
});
test('replacing a named deletion records the deletion it replaced', async () => {
const first = await createAdmin();
const second = await createAdmin();
const target = await createTestAccount(harness);
const scheduled = await scheduleAs(first, target, DeletionReasons.SPAM, 'First review');
const replaced = await createBuilder<ScheduledUser>(harness, second.token)
.put(`/admin/users/${target.userId}/deletion`)
.body({
reason_code: DeletionReasons.OTHER,
days_until_deletion: 90,
replace_pending_deletion_at: scheduled.user.pending_deletion_at,
})
.expect(HTTP_STATUS.OK)
.execute();
expect(replaced.user.deletion_scheduled_by).toBe(second.userId);
const logs = await getAdminRepository().listAllAuditLogsPaginated(1000);
const log = logs.find(
(entry) =>
entry.action === 'schedule_deletion' &&
entry.targetId.toString() === target.userId &&
entry.adminUserId.toString() === second.userId,
);
expect(log?.metadata.get('replaced_pending_deletion_at')).toBe(scheduled.user.pending_deletion_at);
expect(log?.metadata.get('replaced_scheduled_by')).toBe(first.userId);
expect(log?.metadata.get('replaced_reason_code')).toBe(DeletionReasons.SPAM.toString());
expect(log?.metadata.get('pending_deletion_at')).toBe(replaced.user.pending_deletion_at);
});
test('cancel requires the pending deletion it cancels', async () => {
const admin = await createAdmin();
const target = await createTestAccount(harness);
await scheduleAs(admin, target, DeletionReasons.SPAM, 'Review');
await createBuilder(harness, admin.token)
.delete(`/admin/users/${target.userId}/deletion`)
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
await createBuilder(harness, admin.token)
.delete(`/admin/users/${target.userId}/deletion`)
.body({expected_pending_deletion_at: new Date(Date.now() + 86_400_000).toISOString()})
.expect(HTTP_STATUS.CONFLICT)
.execute();
const user = await new UserRepository().findUnique(createUserID(BigInt(target.userId)));
expect(user?.pendingDeletionAt).not.toBeNull();
expect(await harness.kvProvider.zcard('deletion_queue')).toBe(1);
});
test('cancel refuses when nothing is pending', async () => {
const admin = await createAdmin();
const target = await createTestAccount(harness);
await createBuilder(harness, admin.token)
.delete(`/admin/users/${target.userId}/deletion`)
.body({expected_pending_deletion_at: new Date().toISOString()})
.expect(HTTP_STATUS.BAD_REQUEST, 'NO_PENDING_DELETION')
.execute();
});
test('cancel records whose deletion it cancelled and emails only on request', async () => {
const scheduler = await createAdmin();
const canceller = await createAdmin();
const target = await createTestAccount(harness);
const scheduled = await scheduleAs(scheduler, target, DeletionReasons.SPAM, 'Review');
await clearTestEmails(harness);
await createBuilder(harness, canceller.token)
.delete(`/admin/users/${target.userId}/deletion`)
.header('X-Audit-Log-Reason', 'Private cancel note')
.body({expected_pending_deletion_at: scheduled.user.pending_deletion_at})
.expect(HTTP_STATUS.OK)
.execute();
expect(
findLastTestEmail(await listTestEmails(harness, {recipient: target.email}), 'unban_notification'),
).toBeNull();
const logs = await getAdminRepository().listAllAuditLogsPaginated(1000);
const log = logs.find(
(entry) => entry.action === 'cancel_deletion' && entry.targetId.toString() === target.userId,
);
expect(log?.metadata.get('cancelled_pending_deletion_at')).toBe(scheduled.user.pending_deletion_at);
expect(log?.metadata.get('cancelled_scheduled_by')).toBe(scheduler.userId);
expect(log?.metadata.get('cancelled_reason_code')).toBe(DeletionReasons.SPAM.toString());
expect(log?.metadata.get('notify_user')).toBe('false');
const rescheduled = await scheduleAs(scheduler, target, DeletionReasons.SPAM, 'Review again');
await createBuilder(harness, canceller.token)
.delete(`/admin/users/${target.userId}/deletion`)
.header('X-Audit-Log-Reason', 'Private cancel note')
.body({expected_pending_deletion_at: rescheduled.user.pending_deletion_at, notify_user: true})
.expect(HTTP_STATUS.OK)
.execute();
const email = findLastTestEmail(await listTestEmails(harness, {recipient: target.email}), 'unban_notification');
expect(email).not.toBeNull();
expect(JSON.stringify(email)).not.toContain('Private cancel note');
});
});
});
@@ -80,7 +80,6 @@ describe('service level guards for synthetic accounts', () => {
guildId: GUILD_ID,
skipBanCheck: true,
skipGuildLimitCheck: true,
skipRiskGate: true,
requestCache: new Map(),
} as never,
unusableDependency() as never,
@@ -37,7 +37,7 @@ export const BanAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
action: 'list_blocklists',
targetType: 'blocklist',
targetId: '0',
metadata: {result_count: '9'},
metadata: {result_count: '8'},
},
};
},
@@ -20,7 +20,7 @@ import {
} from '@app/api/auth/tests/WebAuthnTestUtils';
import {createUserID} from '@app/api/BrandedTypes';
import {createFriendship} from '@app/api/channel/tests/ChannelTestUtils';
import {getUserRepository} from '@app/api/middleware/ServiceSingletons';
import {getAdminRepository, getUserRepository} from '@app/api/middleware/ServiceSingletons';
import type {User} from '@app/api/models/User';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
@@ -340,6 +340,34 @@ export const UserWriteAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
};
},
},
{
method: 'POST',
route: '/admin/users/:user_id/ban/notes',
auditLogReason: 'Coverage ban note',
async prepare(context) {
const target = await createTestAccount(context.harness);
await adminBuilder(context)
.put(`/admin/users/${target.userId}/ban`)
.body({duration_hours: 24, reason: 'Coverage ban'})
.execute();
const banLog = (await getAdminRepository().listAllAuditLogsPaginated(1000)).find(
(log) => log.action === 'temp_ban' && log.targetId.toString() === target.userId,
);
return {
request: {
path: `/admin/users/${target.userId}/ban/notes`,
body: {ban_audit_log_id: banLog!.logId.toString(), note: 'Coverage ban note'},
expectStatus: 204,
},
expected: {
action: 'annotate_ban',
targetType: 'user',
targetId: target.userId,
metadata: {ban_audit_log_id: banLog!.logId.toString()},
},
};
},
},
{
method: 'PUT',
route: '/admin/users/:user_id/deletion',
@@ -354,7 +382,11 @@ export const UserWriteAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
action: 'schedule_deletion',
targetType: 'user',
targetId: target.userId,
metadata: {days: '30', reason_code: DeletionReasons.USER_REQUESTED.toString()},
metadata: {
days: '30',
reason_code: DeletionReasons.USER_REQUESTED.toString(),
pending_deletion_at: expect.any(String),
},
},
};
},
@@ -368,13 +400,23 @@ export const UserWriteAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
.put(`/admin/users/${target.userId}/deletion`)
.body({reason_code: DeletionReasons.USER_REQUESTED, days_until_deletion: 30})
.execute();
const pendingDeletionAt = (await loadUser(target)).pendingDeletionAt!.toISOString();
return {
request: {path: `/admin/users/${target.userId}/deletion`},
request: {
path: `/admin/users/${target.userId}/deletion`,
body: {expected_pending_deletion_at: pendingDeletionAt},
},
expected: {
action: 'cancel_deletion',
targetType: 'user',
targetId: target.userId,
metadata: {},
metadata: {
cancelled_pending_deletion_at: pendingDeletionAt,
cancelled_scheduled_by: context.admin.userId,
cancelled_scheduled_at: expect.any(String),
cancelled_reason_code: DeletionReasons.USER_REQUESTED.toString(),
notify_user: 'false',
},
},
};
},