Files
fluxer/fluxer_api/src/api/admin/AdminRepository.ts
T

435 lines
14 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
import {createUserID} from '../BrandedTypes';
import {deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '../database/CassandraQueryExecution';
import type {
AdminAuditLogRow,
BannedAvatarHashRow,
BannedFileShaRow,
BannedProfileSubstringRow,
BannedProfileSubstringScope,
BannedUrlDomainRow,
BannedUrlRow,
} from '../database/types/AdminArchiveTypes';
import {isAccountPolicyContactDomainReputationExempt} from '../risk/AccountPolicyService';
import {isIpBanExempt} from '../risk/IpBanExemptions';
import {
AdminAuditLogs,
BannedAvatarHashes,
BannedEmails,
BannedFileShas,
BannedIps,
BannedPhonePrefixes,
BannedPhrases,
BannedProfileSubstrings,
BannedUrlDomains,
BannedUrls,
DisposableEmailDomains,
SuspiciousEmailDomains,
} from '../Tables';
import {parseIpBanEntry, tryParseSingleIp} from '../utils/IpRangeUtils';
import {canonicalizeStoredPhrase} from '../utils/PhraseBlocklistNormalization';
import type {AdminAuditLog, BannedIpEntry, BannedIpKind, IAdminRepository} from './IAdminRepository';
const FETCH_AUDIT_LOG_BY_ID_QUERY = AdminAuditLogs.select({
where: AdminAuditLogs.where.eq('log_id'),
});
const FETCH_AUDIT_LOGS_BY_IDS_QUERY = AdminAuditLogs.select({
where: AdminAuditLogs.where.in('log_id', 'log_ids'),
});
const LOAD_ALL_BANNED_IPS_QUERY = BannedIps.select();
const IS_EMAIL_BANNED_QUERY = BannedEmails.select({
where: BannedEmails.where.eq('email_lower'),
});
const IS_EMAIL_DOMAIN_SUSPICIOUS_QUERY = SuspiciousEmailDomains.select({
where: SuspiciousEmailDomains.where.eq('domain'),
});
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'),
});
const LOAD_ALL_BANNED_URLS_QUERY = BannedUrls.select();
const IS_URL_DOMAIN_BANNED_QUERY = BannedUrlDomains.select({
where: BannedUrlDomains.where.eq('domain'),
});
const LOAD_ALL_BANNED_URL_DOMAINS_QUERY = BannedUrlDomains.select();
const IS_FILE_SHA_BANNED_QUERY = BannedFileShas.select({
where: BannedFileShas.where.eq('sha256_hex'),
});
const LOAD_ALL_BANNED_FILE_SHAS_QUERY = BannedFileShas.select();
const IS_AVATAR_HASH_BANNED_QUERY = BannedAvatarHashes.select({
where: BannedAvatarHashes.where.eq('hash_short'),
});
const LOAD_ALL_BANNED_AVATAR_HASHES_QUERY = BannedAvatarHashes.select();
const LOAD_ALL_BANNED_PROFILE_SUBSTRINGS_QUERY = BannedProfileSubstrings.select();
const createListAllAuditLogsPaginatedQuery = (limit: number) =>
AdminAuditLogs.select({
where: AdminAuditLogs.where.tokenGt('log_id', 'last_log_id'),
limit,
});
const createListAllAuditLogsFirstPageQuery = (limit: number) =>
AdminAuditLogs.select({
limit,
});
function parseBannedIpKind(value: string | null | undefined): BannedIpKind {
return value === 'temporary_24h' ? 'temporary_24h' : 'permanent';
}
function canonicalizeBannedIpEntry(value: string): string {
return parseIpBanEntry(value)?.canonical ?? value;
}
export class AdminRepository implements IAdminRepository {
async createAuditLog(log: AdminAuditLogRow): Promise<AdminAuditLog> {
await upsertOne(AdminAuditLogs.insert(log));
return this.mapRowToAuditLog(log);
}
async getAuditLog(logId: bigint): Promise<AdminAuditLog | null> {
const row = await fetchOne<AdminAuditLogRow>(FETCH_AUDIT_LOG_BY_ID_QUERY.bind({log_id: logId}));
return row ? this.mapRowToAuditLog(row) : null;
}
async listAuditLogsByIds(logIds: Array<bigint>): Promise<Array<AdminAuditLog>> {
if (logIds.length === 0) {
return [];
}
const rows = await fetchMany<AdminAuditLogRow>(FETCH_AUDIT_LOGS_BY_IDS_QUERY.bind({log_ids: logIds}));
return rows.map((row) => this.mapRowToAuditLog(row));
}
async listAllAuditLogsPaginated(limit: number, lastLogId?: bigint): Promise<Array<AdminAuditLog>> {
let rows: Array<AdminAuditLogRow>;
if (lastLogId) {
const query = createListAllAuditLogsPaginatedQuery(limit);
rows = await fetchMany<AdminAuditLogRow>(query.bind({last_log_id: lastLogId}));
} else {
const query = createListAllAuditLogsFirstPageQuery(limit);
rows = await fetchMany<AdminAuditLogRow>(query.bind({}));
}
return rows.map((row) => this.mapRowToAuditLog(row));
}
async isIpBanned(ip: string): Promise<boolean> {
if (isIpBanExempt(ip)) {
return false;
}
const candidate = tryParseSingleIp(ip);
if (!candidate) {
return false;
}
const sameIpDecisionKey = getSameIpDecisionKey(candidate.canonical);
const entries = await this.loadAllBannedIpEntries();
for (const entry of entries) {
const parsed = parseIpBanEntry(entry.ip);
if (!parsed) {
continue;
}
if (parsed.type === 'single') {
if (parsed.family === candidate.family && parsed.canonical === candidate.canonical) {
return true;
}
if (sameIpDecisionKey && getSameIpDecisionKey(parsed.canonical) === sameIpDecisionKey) {
return true;
}
continue;
}
if (parsed.family === candidate.family && candidate.value >= parsed.start && candidate.value <= parsed.end) {
return true;
}
}
return false;
}
async banIp(ip: string): Promise<void> {
if (isIpBanExempt(ip)) {
return;
}
const canonicalIp = canonicalizeBannedIpEntry(ip);
await upsertOne(
BannedIps.insert({
ip: canonicalIp,
ban_kind: 'permanent',
reason: 'platform_admin_enforcement',
expires_at: null,
created_at: new Date(),
}),
);
}
async banIpTemp(ip: string, ttlSeconds: number): Promise<void> {
if (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0) {
throw new RangeError('Temporary IP ban TTL must be a positive integer');
}
if (isIpBanExempt(ip)) {
return;
}
const canonicalIp = canonicalizeBannedIpEntry(ip);
await upsertOne(
BannedIps.insertWithTtl(
{
ip: canonicalIp,
ban_kind: 'temporary_24h',
reason: 'abusive_api_access_patterns',
expires_at: new Date(Date.now() + ttlSeconds * 1000),
created_at: new Date(),
},
ttlSeconds,
),
);
}
async unbanIp(ip: string): Promise<void> {
const deleteKeys = new Set<string>([ip]);
const parsed = parseIpBanEntry(ip);
if (parsed) {
deleteKeys.add(parsed.canonical);
const entries = await this.loadAllBannedIpEntries();
for (const entry of entries) {
const stored = parseIpBanEntry(entry.ip);
if (stored?.canonical === parsed.canonical) {
deleteKeys.add(entry.ip);
}
}
}
await Promise.all(Array.from(deleteKeys).map((key) => deleteOneOrMany(BannedIps.deleteByPk({ip: key}))));
}
async loadAllBannedIps(): Promise<Set<string>> {
const entries = await this.loadAllBannedIpEntries();
return new Set(entries.map((row) => row.ip));
}
async loadAllBannedIpEntries(): Promise<Array<BannedIpEntry>> {
const rows = await fetchMany<{
ip: string;
ban_kind?: string | null;
reason?: string | null;
expires_at?: Date | null;
created_at?: Date | null;
}>(LOAD_ALL_BANNED_IPS_QUERY.bind({}));
return rows.map((row) => ({
ip: row.ip,
kind: parseBannedIpKind(row.ban_kind),
reason: row.reason ?? null,
expiresAt: row.expires_at ?? null,
createdAt: row.created_at ?? null,
}));
}
async isEmailBanned(email: string): Promise<boolean> {
const emailLower = email.toLowerCase();
const result = await fetchOne<{
email_lower: string;
}>(IS_EMAIL_BANNED_QUERY.bind({email_lower: emailLower}));
return !!result;
}
async banEmail(email: string): Promise<void> {
const emailLower = email.toLowerCase();
await upsertOne(BannedEmails.insert({email_lower: emailLower}));
}
async unbanEmail(email: string): Promise<void> {
const emailLower = email.toLowerCase();
await deleteOneOrMany(BannedEmails.deleteByPk({email_lower: emailLower}));
}
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 isEmailDomainDisposable(domain: string): Promise<boolean> {
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<{
phrase: string;
}>(IS_PHRASE_BANNED_QUERY.bind({phrase: phraseLower}));
return !!result;
}
async banPhrase(phrase: string): Promise<void> {
const phraseLower = canonicalizeStoredPhrase(phrase);
await upsertOne(BannedPhrases.insert({phrase: phraseLower}));
}
async unbanPhrase(phrase: string): Promise<void> {
const phraseLower = canonicalizeStoredPhrase(phrase);
await deleteOneOrMany(BannedPhrases.deleteByPk({phrase: phraseLower}));
}
async loadAllBannedPhrases(): Promise<Array<string>> {
const rows = await fetchMany<{
phrase: string;
}>(LOAD_ALL_BANNED_PHRASES_QUERY.bind({}));
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<{
url_canonical: string;
}>(IS_URL_BANNED_QUERY.bind({url_canonical: canonical}));
return !!result;
}
async banUrl(row: BannedUrlRow): Promise<void> {
await upsertOne(BannedUrls.insert({...row, url_canonical: row.url_canonical.toLowerCase()}));
}
async unbanUrl(url: string): Promise<void> {
await deleteOneOrMany(BannedUrls.deleteByPk({url_canonical: url.toLowerCase()}));
}
async loadAllBannedUrls(): Promise<Array<BannedUrlRow>> {
return fetchMany<BannedUrlRow>(LOAD_ALL_BANNED_URLS_QUERY.bind({}));
}
async isUrlDomainBanned(domain: string): Promise<boolean> {
const d = domain.toLowerCase();
const result = await fetchOne<{
domain: string;
}>(IS_URL_DOMAIN_BANNED_QUERY.bind({domain: d}));
return !!result;
}
async banUrlDomain(row: BannedUrlDomainRow): Promise<void> {
await upsertOne(BannedUrlDomains.insert({...row, domain: row.domain.toLowerCase()}));
}
async unbanUrlDomain(domain: string): Promise<void> {
await deleteOneOrMany(BannedUrlDomains.deleteByPk({domain: domain.toLowerCase()}));
}
async loadAllBannedUrlDomains(): Promise<Array<BannedUrlDomainRow>> {
return fetchMany<BannedUrlDomainRow>(LOAD_ALL_BANNED_URL_DOMAINS_QUERY.bind({}));
}
async isFileShaBanned(sha256Hex: string): Promise<boolean> {
const h = sha256Hex.toLowerCase();
const result = await fetchOne<{
sha256_hex: string;
}>(IS_FILE_SHA_BANNED_QUERY.bind({sha256_hex: h}));
return !!result;
}
async banFileSha(row: BannedFileShaRow): Promise<void> {
await upsertOne(BannedFileShas.insert({...row, sha256_hex: row.sha256_hex.toLowerCase()}));
}
async unbanFileSha(sha256Hex: string): Promise<void> {
await deleteOneOrMany(BannedFileShas.deleteByPk({sha256_hex: sha256Hex.toLowerCase()}));
}
async loadAllBannedFileShas(): Promise<Array<BannedFileShaRow>> {
return fetchMany<BannedFileShaRow>(LOAD_ALL_BANNED_FILE_SHAS_QUERY.bind({}));
}
async isAvatarHashBanned(hashShort: string): Promise<boolean> {
const h = hashShort.toLowerCase();
const result = await fetchOne<{
hash_short: string;
}>(IS_AVATAR_HASH_BANNED_QUERY.bind({hash_short: h}));
return !!result;
}
async banAvatarHash(row: BannedAvatarHashRow): Promise<void> {
await upsertOne(BannedAvatarHashes.insert({...row, hash_short: row.hash_short.toLowerCase()}));
}
async unbanAvatarHash(hashShort: string): Promise<void> {
await deleteOneOrMany(BannedAvatarHashes.deleteByPk({hash_short: hashShort.toLowerCase()}));
}
async loadAllBannedAvatarHashes(): Promise<Array<BannedAvatarHashRow>> {
return fetchMany<BannedAvatarHashRow>(LOAD_ALL_BANNED_AVATAR_HASHES_QUERY.bind({}));
}
async banProfileSubstring(row: BannedProfileSubstringRow): Promise<void> {
const canonical = canonicalizeStoredPhrase(row.substring);
if (!canonical) return;
await upsertOne(BannedProfileSubstrings.insert({...row, substring: canonical}));
}
async unbanProfileSubstring(scope: BannedProfileSubstringScope, substring: string): Promise<void> {
const canonical = canonicalizeStoredPhrase(substring);
if (!canonical) return;
await deleteOneOrMany(BannedProfileSubstrings.deleteByPk({scope, substring: canonical}));
}
async loadAllBannedProfileSubstrings(): Promise<Array<BannedProfileSubstringRow>> {
return fetchMany<BannedProfileSubstringRow>(LOAD_ALL_BANNED_PROFILE_SUBSTRINGS_QUERY.bind({}));
}
private mapRowToAuditLog(row: AdminAuditLogRow): AdminAuditLog {
return {
logId: row.log_id,
adminUserId: createUserID(row.admin_user_id),
targetType: row.target_type,
targetId: row.target_id,
action: row.action,
auditLogReason: row.audit_log_reason,
metadata: row.metadata || new Map(),
createdAt: row.created_at,
};
}
}