mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
refactor(api): emit moderation events and apply account actions (#3031)
This commit is contained in:
1 parent
c488906131
commit
364084c819
385 files changed
+8994
-22221
No files matched your search
@@ -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<{
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user