import * as plugins from '../../plugins.js'; import { DcRouterDb } from '../classes.dcrouter-db.js'; import type { IAuthenticationEvent, TAuthenticationFailureReason, TAuthenticationSource, TResolvedAuthenticationSource, } from '../../../ts_interfaces/data/stats.js'; const DB_OPERATION_TIMEOUT_MS = 5_000; const getDb = () => DcRouterDb.getInstance().getDb(); @plugins.smartdata.Collection(() => getDb()) export class AuthenticationEventDoc extends plugins.smartdata.SmartDataDbDoc< AuthenticationEventDoc, AuthenticationEventDoc > implements IAuthenticationEvent { @plugins.smartdata.unI() @plugins.smartdata.svDb() public id!: string; @plugins.smartdata.svDb() public timestamp!: number; @plugins.smartdata.svDb() public username!: string; @plugins.smartdata.svDb() public userId?: string; @plugins.smartdata.svDb() public success!: boolean; @plugins.smartdata.svDb() public requestedAuthSource!: TAuthenticationSource; @plugins.smartdata.svDb() public resolvedAuthSource?: TResolvedAuthenticationSource; @plugins.smartdata.svDb() public failureReason?: TAuthenticationFailureReason; constructor() { super(); } public toApiObject(): IAuthenticationEvent { return { id: this.id, timestamp: this.timestamp, username: this.username, success: this.success, requestedAuthSource: this.requestedAuthSource, ...(this.userId ? { userId: this.userId } : {}), ...(this.resolvedAuthSource ? { resolvedAuthSource: this.resolvedAuthSource } : {}), ...(this.failureReason ? { failureReason: this.failureReason } : {}), }; } private static async getNativeCollection() { const smartdataCollection = (AuthenticationEventDoc as typeof AuthenticationEventDoc & { collection: plugins.smartdata.SmartdataCollection; }).collection; await smartdataCollection.init(); const probe = new AuthenticationEventDoc(); await smartdataCollection.markUniqueIndexes(probe.uniqueIndexes || []); await smartdataCollection.createRegularIndexes(probe.regularIndexes || []); return smartdataCollection.mongoDbCollection; } private static toPersistedFields(eventArg: IAuthenticationEvent): IAuthenticationEvent { return { id: eventArg.id, timestamp: eventArg.timestamp, username: eventArg.username, success: eventArg.success, requestedAuthSource: eventArg.requestedAuthSource, ...(eventArg.userId !== undefined ? { userId: eventArg.userId } : {}), ...(eventArg.resolvedAuthSource !== undefined ? { resolvedAuthSource: eventArg.resolvedAuthSource } : {}), ...(eventArg.failureReason !== undefined ? { failureReason: eventArg.failureReason } : {}), }; } private static validateEvent(eventArg: IAuthenticationEvent): void { if ( typeof eventArg.id !== 'string' || !eventArg.id.trim() || !Number.isSafeInteger(eventArg.timestamp) || eventArg.timestamp < 0 || typeof eventArg.username !== 'string' || !eventArg.username.trim() || typeof eventArg.success !== 'boolean' || !['auto', 'local', 'idp.global'].includes(eventArg.requestedAuthSource) ) { throw new Error('Invalid authentication event'); } if ( eventArg.userId !== undefined && (typeof eventArg.userId !== 'string' || !eventArg.userId.trim()) ) { throw new Error('Invalid authentication event user ID'); } if ( eventArg.resolvedAuthSource !== undefined && eventArg.resolvedAuthSource !== 'local' && eventArg.resolvedAuthSource !== 'idp.global' ) { throw new Error('Invalid resolved authentication source'); } if ( eventArg.failureReason !== undefined && ![ 'invalidCredentials', 'serviceUnavailable', 'identityIssuanceFailed', 'internalError', ].includes(eventArg.failureReason) ) { throw new Error('Invalid authentication failure reason'); } if (eventArg.success && eventArg.failureReason) { throw new Error('Successful authentication events cannot carry a failure reason'); } if (!eventArg.success && !eventArg.failureReason) { throw new Error('Failed authentication events require a failure reason'); } } public static async upsertMany(eventsArg: IAuthenticationEvent[]): Promise { if (eventsArg.length === 0) return; if (eventsArg.length > 500) { throw new Error('AuthenticationEventDoc.upsertMany accepts at most 500 events'); } for (const event of eventsArg) { AuthenticationEventDoc.validateEvent(event); } const collection = await AuthenticationEventDoc.getNativeCollection(); await collection.bulkWrite( eventsArg.map((event) => { const timestamp = new Date(event.timestamp).toISOString(); const persistedEvent = AuthenticationEventDoc.toPersistedFields(event); return { updateOne: { filter: { id: event.id }, update: { $setOnInsert: { ...persistedEvent, _createdAt: timestamp, _updatedAt: timestamp, }, }, upsert: true, }, }; }), { ordered: false, timeoutMS: DB_OPERATION_TIMEOUT_MS }, ); } public static async getWindowSummary( cutoffArg: number, limitArg = 100, ): Promise<{ successes: number; failures: number; events: IAuthenticationEvent[]; }> { if (!Number.isSafeInteger(cutoffArg) || cutoffArg < 0) { throw new Error('AuthenticationEventDoc.getWindowSummary requires a valid cutoff'); } if (!Number.isSafeInteger(limitArg) || limitArg <= 0 || limitArg > 500) { throw new Error('AuthenticationEventDoc.getWindowSummary requires a limit from 1 to 500'); } const collection = await AuthenticationEventDoc.getNativeCollection(); const selector = { timestamp: { $gte: cutoffArg } }; const cursor = collection .find(selector, { timeoutMS: DB_OPERATION_TIMEOUT_MS }) .sort({ timestamp: -1, id: -1 }) .limit(limitArg); try { const [successes, failures, rows] = await Promise.all([ collection.countDocuments( { ...selector, success: true }, { timeoutMS: DB_OPERATION_TIMEOUT_MS }, ), collection.countDocuments( { ...selector, success: false }, { timeoutMS: DB_OPERATION_TIMEOUT_MS }, ), cursor.toArray(), ]); return { successes, failures, events: rows.map((row) => AuthenticationEventDoc .createInstanceFromMongoDbNativeDoc(row as any) .toApiObject()), }; } finally { await cursor.close({ timeoutMS: DB_OPERATION_TIMEOUT_MS }); } } public static async findExistingIds(idsArg: string[]): Promise> { const ids = [...new Set(idsArg)]; if (ids.length === 0) return new Set(); if (ids.length > 500) { throw new Error('AuthenticationEventDoc.findExistingIds accepts at most 500 ids'); } const collection = await AuthenticationEventDoc.getNativeCollection(); const rows = await collection .find( { id: { $in: ids } }, { projection: { id: 1 }, timeoutMS: DB_OPERATION_TIMEOUT_MS }, ) .toArray(); return new Set(rows.map((row) => String(row.id))); } public static async pruneBefore(cutoffArg: number): Promise { if (!Number.isSafeInteger(cutoffArg) || cutoffArg < 0) { throw new Error('AuthenticationEventDoc.pruneBefore requires a valid cutoff'); } const collection = await AuthenticationEventDoc.getNativeCollection(); let deletedCount = 0; while (true) { const rows = await collection .find( { timestamp: { $lt: cutoffArg } }, { projection: { _id: 1 }, timeoutMS: DB_OPERATION_TIMEOUT_MS }, ) .sort({ timestamp: 1, _id: 1 }) .limit(500) .toArray(); if (rows.length === 0) break; const result = await collection.deleteMany( { _id: { $in: rows.map((row) => row._id) } }, { timeoutMS: DB_OPERATION_TIMEOUT_MS }, ); deletedCount += result.deletedCount; if (rows.length < 500) break; } return deletedCount; } }