diff --git a/package-lock.json b/package-lock.json index 5da44bb..e14b4c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "husky": "^9.1.7", "jest": "^29.7.0", "lint-staged": "^16.2.7", + "mongoose": "^8.11.3", "prettier": "^3.4.2", "ts-jest": "^29.2.5", "ts-node": "^10.9.2", @@ -34,6 +35,7 @@ "peerDependencies": { "@nestjs/common": "^10 || ^11", "@nestjs/core": "^10 || ^11", + "mongoose": "^8", "reflect-metadata": "^0.2.2", "rxjs": "^7" } @@ -2452,6 +2454,16 @@ "node": ">= 4.0.0" } }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz", + "integrity": "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, "node_modules/@nestjs/common": { "version": "11.1.14", "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.14.tgz", @@ -3149,6 +3161,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -3970,6 +3999,16 @@ "node-int64": "^0.4.0" } }, + "node_modules/bson": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", + "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.20.1" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -7365,6 +7404,16 @@ "node": ">=6" } }, + "node_modules/kareem": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz", + "integrity": "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -7677,6 +7726,13 @@ "node": ">= 0.4" } }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "dev": true, + "license": "MIT" + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -7780,6 +7836,110 @@ "ufo": "^1.6.1" } }, + "node_modules/mongodb": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.20.0.tgz", + "integrity": "sha512-Tl6MEIU3K4Rq3TSHd+sZQqRBoGlFsOgNrH5ltAcFBV62Re3Fd+FcaVf8uSEQFOJ51SDowDVttBTONMfoYWrWlQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^6.10.4", + "mongodb-connection-string-url": "^3.0.2" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.3.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", + "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^14.1.0 || ^13.0.0" + } + }, + "node_modules/mongoose": { + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.23.0.tgz", + "integrity": "sha512-Bul4Ha6J8IqzFrb0B1xpVzkC3S0sk43dmLSnhFOn8eJlZiLwL5WO6cRymmjaADdCMjUcCpj2ce8hZI6O4ZFSug==", + "dev": true, + "license": "MIT", + "dependencies": { + "bson": "^6.10.4", + "kareem": "2.6.3", + "mongodb": "~6.20.0", + "mpath": "0.9.0", + "mquery": "5.0.0", + "ms": "2.1.3", + "sift": "17.1.3" + }, + "engines": { + "node": ">=16.20.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mpath": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mquery": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz", + "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4.x" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -9076,6 +9236,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sift": { + "version": "17.1.3", + "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", + "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", + "dev": true, + "license": "MIT" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -9152,6 +9319,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, "node_modules/spawndamnit": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/spawndamnit/-/spawndamnit-3.0.1.tgz", @@ -9566,6 +9743,19 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -10084,6 +10274,30 @@ "makeerror": "1.0.12" } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 65a907c..3f89f45 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "peerDependencies": { "@nestjs/common": "^10 || ^11", "@nestjs/core": "^10 || ^11", + "mongoose": "^8", "reflect-metadata": "^0.2.2", "rxjs": "^7" }, @@ -61,6 +62,7 @@ "husky": "^9.1.7", "jest": "^29.7.0", "lint-staged": "^16.2.7", + "mongoose": "^8.11.3", "prettier": "^3.4.2", "ts-jest": "^29.2.5", "ts-node": "^10.9.2", diff --git a/src/core/types.ts b/src/core/types.ts index da07c1a..ba8118c 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -316,6 +316,9 @@ export interface AuditLogFilters { /** Filter by action type */ action?: AuditActionType | string; + /** Filter by multiple actions (OR condition) */ + actions?: (AuditActionType | string)[]; + /** Filter by resource type */ resourceType?: string; @@ -331,6 +334,12 @@ export interface AuditLogFilters { /** Filter by IP address */ ipAddress?: string; + /** Filter by request ID */ + requestId?: string; + + /** Filter by session ID */ + sessionId?: string; + /** Free-text search across multiple fields */ search?: string; diff --git a/src/index.ts b/src/index.ts index 57b076b..a2dda64 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,3 @@ export * from "./core"; +export * from "./infra"; export * from "./nest"; diff --git a/src/infra/index.ts b/src/infra/index.ts new file mode 100644 index 0000000..c9e624a --- /dev/null +++ b/src/infra/index.ts @@ -0,0 +1,17 @@ +/** + * ============================================================================ + * INFRASTRUCTURE LAYER - PUBLIC EXPORTS + * ============================================================================ + * + * Exports for all infrastructure adapters. + * + * Components: + * - Repositories: Persistence implementations + * - Senders: (Future) Channel delivery implementations + * - Providers: (Future) Utility implementations + * + * @packageDocumentation + */ + +// Repository implementations +export * from "./repositories"; diff --git a/src/infra/repositories/in-memory/in-memory-audit.repository.ts b/src/infra/repositories/in-memory/in-memory-audit.repository.ts new file mode 100644 index 0000000..4bc5389 --- /dev/null +++ b/src/infra/repositories/in-memory/in-memory-audit.repository.ts @@ -0,0 +1,393 @@ +/** + * ============================================================================ + * IN-MEMORY AUDIT REPOSITORY + * ============================================================================ + * + * In-memory implementation of the IAuditLogRepository port. + * + * Purpose: + * - Testing without database dependencies + * - Prototyping and development + * - Simple deployments without database + * - Demo and educational purposes + * + * Characteristics: + * - Fast (no I/O) + * - Volatile (data lost on restart) + * - Single-process only (no distributed support) + * - Full query support (filtering, pagination, sorting) + * + * Use Cases: + * - Unit/integration testing + * - Local development + * - Serverless functions (short-lived) + * - POCs and demos + * + * DO NOT USE FOR: + * - Production with data persistence requirements + * - Multi-instance deployments + * - Long-running processes + * + * @packageDocumentation + */ + +import type { IAuditLogRepository } from "../../../core/ports/audit-repository.port"; +import type { AuditLog, AuditLogFilters, PageOptions, PageResult } from "../../../core/types"; + +/** + * In-memory implementation of audit log repository. + * + * Stores audit logs in a Map for O(1) lookups by ID. + * Supports all query operations through in-memory filtering. + * + * @example Basic usage + * ```typescript + * const repository = new InMemoryAuditRepository(); + * + * // Create audit log + * await repository.create(auditLog); + * + * // Query + * const logs = await repository.findByActor('user-123'); + * ``` + * + * @example Testing + * ```typescript + * describe('AuditService', () => { + * let repository: InMemoryAuditRepository; + * + * beforeEach(() => { + * repository = new InMemoryAuditRepository(); + * }); + * + * it('should create audit log', async () => { + * const log = await repository.create(testAuditLog); + * expect(log.id).toBe(testAuditLog.id); + * }); + * }); + * ``` + */ +export class InMemoryAuditRepository implements IAuditLogRepository { + /** + * Internal storage: Map + * Using Map for O(1) lookups by ID. + */ + private readonly logs = new Map(); + + /** + * Creates a new in-memory repository. + * + * @param initialData - Optional initial audit logs (for testing) + */ + constructor(initialData?: AuditLog[]) { + if (initialData) { + initialData.forEach((log) => this.logs.set(log.id, log)); + } + } + + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + // CREATE OPERATIONS + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + /** + * Creates (stores) a new audit log entry. + * + * @param log - The audit log to persist + * @returns The persisted audit log (deep copy to ensure immutability) + * @throws Error if log with same ID already exists + */ + async create(log: AuditLog): Promise { + if (this.logs.has(log.id)) { + throw new Error(`Audit log with ID "${log.id}" already exists`); + } + + // Deep copy to prevent external mutations + const copy = this.deepCopy(log); + this.logs.set(log.id, copy); + + // Return another copy to prevent mutations + return this.deepCopy(copy); + } + + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + // READ OPERATIONS - Single Entity + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + /** + * Finds a single audit log by ID. + * + * @param id - The audit log ID + * @returns The audit log if found, null otherwise + */ + async findById(id: string): Promise { + const log = this.logs.get(id); + return log ? this.deepCopy(log) : null; + } + + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + // READ OPERATIONS - Collections + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + /** + * Finds all audit logs for a specific actor. + * + * @param actorId - The actor's unique identifier + * @param filters - Optional additional filters + * @returns Array of audit logs (newest first) + */ + async findByActor(actorId: string, filters?: Partial): Promise { + const allLogs = Array.from(this.logs.values()); + const filtered = allLogs.filter((log) => { + if (log.actor.id !== actorId) return false; + return this.matchesFilters(log, filters || {}); + }); + + // Sort newest first + return this.sortByTimestamp(filtered, "desc").map((log) => this.deepCopy(log)); + } + + /** + * Finds all audit logs for a specific resource. + * + * @param resourceType - The type of resource + * @param resourceId - The resource's unique identifier + * @param filters - Optional additional filters + * @returns Array of audit logs (chronological order) + */ + async findByResource( + resourceType: string, + resourceId: string, + filters?: Partial, + ): Promise { + const allLogs = Array.from(this.logs.values()); + const filtered = allLogs.filter((log) => { + if (log.resource.type !== resourceType || log.resource.id !== resourceId) { + return false; + } + return this.matchesFilters(log, filters || {}); + }); + + // Sort chronologically (oldest first) for resource history + return this.sortByTimestamp(filtered, "asc").map((log) => this.deepCopy(log)); + } + + /** + * Queries audit logs with complex filters and pagination. + * + * @param filters - Filter criteria and pagination options + * @returns Paginated result with data and metadata + */ + async query( + filters: Partial & Partial, + ): Promise> { + const { page = 1, limit = 20, sort = "-timestamp", ...queryFilters } = filters; + + // Filter all logs + const allLogs = Array.from(this.logs.values()); + const filtered = allLogs.filter((log) => this.matchesFilters(log, queryFilters)); + + // Sort + const sorted = this.sortLogs(filtered, sort); + + // Paginate + const total = sorted.length; + const pages = Math.ceil(total / limit); + const skip = (page - 1) * limit; + const data = sorted.slice(skip, skip + limit).map((log) => this.deepCopy(log)); + + return { + data, + page, + limit, + total, + pages, + }; + } + + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + // READ OPERATIONS - Aggregation + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + /** + * Counts audit logs matching the given filters. + * + * @param filters - Optional filter criteria + * @returns Number of matching audit logs + */ + async count(filters?: Partial): Promise { + if (!filters || Object.keys(filters).length === 0) { + return this.logs.size; + } + + const allLogs = Array.from(this.logs.values()); + return allLogs.filter((log) => this.matchesFilters(log, filters)).length; + } + + /** + * Checks if any audit log exists matching the filters. + * + * @param filters - Filter criteria + * @returns True if at least one audit log matches + */ + async exists(filters: Partial): Promise { + const allLogs = Array.from(this.logs.values()); + return allLogs.some((log) => this.matchesFilters(log, filters)); + } + + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + // OPTIONAL OPERATIONS + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + /** + * Deletes audit logs older than the specified date. + * + * @param beforeDate - Delete logs older than this date + * @returns Number of audit logs deleted + */ + async deleteOlderThan(beforeDate: Date): Promise { + const allLogs = Array.from(this.logs.entries()); + let deleted = 0; + + for (const [id, log] of allLogs) { + if (log.timestamp < beforeDate) { + this.logs.delete(id); + deleted++; + } + } + + return deleted; + } + + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + // UTILITY METHODS (Testing Support) + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + /** + * Clears all audit logs. + * Useful for cleanup between tests. + */ + clear(): void { + this.logs.clear(); + } + + /** + * Returns all audit logs. + * Useful for testing and debugging. + */ + getAll(): AuditLog[] { + return Array.from(this.logs.values()).map((log) => this.deepCopy(log)); + } + + /** + * Returns the number of stored audit logs. + * Useful for testing. + */ + size(): number { + return this.logs.size; + } + + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + // PRIVATE HELPER METHODS + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + /** + * Checks if an audit log matches the given filters. + * + * @param log - The audit log to check + * @param filters - Filter criteria + * @returns True if log matches all filters + */ + private matchesFilters(log: AuditLog, filters: Partial): boolean { + // Actor filters + if (filters.actorId && log.actor.id !== filters.actorId) return false; + if (filters.actorType && log.actor.type !== filters.actorType) return false; + + // Resource filters + if (filters.resourceType && log.resource.type !== filters.resourceType) return false; + if (filters.resourceId && log.resource.id !== filters.resourceId) return false; + + // Action filter + if (filters.action && log.action !== filters.action) return false; + if (filters.actions && !filters.actions.includes(log.action)) return false; + + // Date range + if (filters.startDate && log.timestamp < filters.startDate) return false; + if (filters.endDate && log.timestamp > filters.endDate) return false; + + // Other filters + if (filters.ipAddress && log.ipAddress !== filters.ipAddress) return false; + if (filters.requestId && log.requestId !== filters.requestId) return false; + if (filters.sessionId && log.sessionId !== filters.sessionId) return false; + + // Simple text search (searches in action, resource type, actor name) + if (filters.search) { + const searchLower = filters.search.toLowerCase(); + const searchableText = [ + log.action, + log.resource.type, + log.actor.name || "", + log.actionDescription || "", + log.reason || "", + ] + .join(" ") + .toLowerCase(); + + if (!searchableText.includes(searchLower)) return false; + } + + return true; + } + + /** + * Sorts audit logs by timestamp. + * + * @param logs - Audit logs to sort + * @param direction - "asc" for ascending, "desc" for descending + * @returns Sorted audit logs + */ + private sortByTimestamp(logs: AuditLog[], direction: "asc" | "desc"): AuditLog[] { + return [...logs].sort((a, b) => { + const diff = a.timestamp.getTime() - b.timestamp.getTime(); + return direction === "asc" ? diff : -diff; + }); + } + + /** + * Sorts audit logs based on sort string. + * + * @param logs - Audit logs to sort + * @param sort - Sort string (e.g., "-timestamp", "+action") + * @returns Sorted audit logs + */ + private sortLogs(logs: AuditLog[], sort: string): AuditLog[] { + const direction = sort.startsWith("-") ? "desc" : "asc"; + const field = sort.replace(/^[+-]/, ""); + + return [...logs].sort((a, b) => { + let aVal: any = a[field as keyof AuditLog]; + let bVal: any = b[field as keyof AuditLog]; + + // Handle nested fields (e.g., "actor.id") + if (field.includes(".")) { + const parts = field.split("."); + aVal = parts.reduce((obj: any, key) => obj?.[key], a); + bVal = parts.reduce((obj: any, key) => obj?.[key], b); + } + + // Compare + if (aVal < bVal) return direction === "asc" ? -1 : 1; + if (aVal > bVal) return direction === "asc" ? 1 : -1; + return 0; + }); + } + + /** + * Deep copy an audit log to ensure immutability. + * + * @param log - Audit log to copy + * @returns Deep copy of the audit log + */ + private deepCopy(log: AuditLog): AuditLog { + return JSON.parse(JSON.stringify(log)); + } +} diff --git a/src/infra/repositories/in-memory/index.ts b/src/infra/repositories/in-memory/index.ts new file mode 100644 index 0000000..bcd8248 --- /dev/null +++ b/src/infra/repositories/in-memory/index.ts @@ -0,0 +1,11 @@ +/** + * ============================================================================ + * IN-MEMORY REPOSITORY - PUBLIC EXPORTS + * ============================================================================ + * + * Exports for in-memory audit repository implementation. + * + * @packageDocumentation + */ + +export { InMemoryAuditRepository } from "./in-memory-audit.repository"; diff --git a/src/infra/repositories/index.ts b/src/infra/repositories/index.ts new file mode 100644 index 0000000..4f2b7a7 --- /dev/null +++ b/src/infra/repositories/index.ts @@ -0,0 +1,19 @@ +/** + * ============================================================================ + * AUDIT REPOSITORIES - PUBLIC EXPORTS + * ============================================================================ + * + * Exports for all audit repository implementations. + * + * Available implementations: + * - MongoDB (via Mongoose) + * - In-Memory (testing, simple deployments) + * + * @packageDocumentation + */ + +// MongoDB implementation +export * from "./mongodb"; + +// In-Memory implementation +export * from "./in-memory"; diff --git a/src/infra/repositories/mongodb/audit-log.schema.ts b/src/infra/repositories/mongodb/audit-log.schema.ts new file mode 100644 index 0000000..54f8ad9 --- /dev/null +++ b/src/infra/repositories/mongodb/audit-log.schema.ts @@ -0,0 +1,209 @@ +/** + * ============================================================================ + * MONGOOSE SCHEMA FOR AUDIT LOGS + * ============================================================================ + * + * MongoDB schema definition for audit log persistence. + * + * Purpose: + * - Define MongoDB collection structure + * - Ensure data validation at database level + * - Configure indexes for optimal query performance + * - Enable TypeScript type safety with Mongoose + * + * Schema Design Principles: + * - **Immutable**: No update operations, audit logs never change + * - **Append-only**: Optimized for inserts and reads + * - **Query-optimized**: Indexes on common access patterns + * - **Time-series friendly**: Can use MongoDB time-series collections + * + * @packageDocumentation + */ + +import { Schema, type Document } from "mongoose"; + +import type { AuditLog } from "../../../core/types"; + +/** + * MongoDB document type for AuditLog. + * Extends Mongoose Document for database operations. + */ +export type AuditLogDocument = AuditLog & Document; + +/** + * Actor sub-schema (actor information). + * Embedded document for who performed the action. + */ +const ActorSchema = new Schema( + { + id: { type: String, required: true, index: true }, + type: { + type: String, + required: true, + enum: ["user", "system", "service"], + index: true, + }, + name: { type: String }, + email: { type: String }, + metadata: { type: Schema.Types.Mixed }, + }, + { _id: false }, +); + +/** + * Resource sub-schema (what was acted upon). + * Embedded document for the target of the action. + */ +const ResourceSchema = new Schema( + { + type: { type: String, required: true, index: true }, + id: { type: String, required: true, index: true }, + label: { type: String }, + metadata: { type: Schema.Types.Mixed }, + }, + { _id: false }, +); + +/** + * Main AuditLog schema. + * + * Indexes: + * - id: Primary key, unique identifier + * - timestamp: Time-series queries, retention policies + * - actor.id: "What did this user do?" + * - actor.type: "All system actions" + * - resource.type + resource.id: "Complete resource history" + * - action: "All DELETE actions" + * - ipAddress: Security investigations + * - requestId: Distributed tracing + * + * Compound indexes for common query patterns: + * - {timestamp: -1}: Newest-first sorting (most common) + * - {actor.id: 1, timestamp: -1}: User activity timeline + * - {resource.type: 1, resource.id: 1, timestamp: 1}: Resource history chronologically + */ +export const AuditLogSchema = new Schema( + { + id: { + type: String, + required: true, + unique: true, + index: true, + }, + timestamp: { + type: Date, + required: true, + index: true, + }, + actor: { + type: ActorSchema, + required: true, + }, + action: { + type: String, + required: true, + index: true, + }, + actionDescription: { + type: String, + }, + resource: { + type: ResourceSchema, + required: true, + }, + changes: { + type: Schema.Types.Mixed, + }, + metadata: { + type: Schema.Types.Mixed, + }, + ipAddress: { + type: String, + index: true, + }, + userAgent: { + type: String, + }, + requestId: { + type: String, + index: true, + }, + sessionId: { + type: String, + index: true, + }, + reason: { + type: String, + }, + }, + { + collection: "audit_logs", + timestamps: false, // We manage timestamp ourselves + versionKey: false, // Audit logs are immutable, no versioning needed + }, +); + +/** + * Compound indexes for optimized query patterns. + * These support the most common access patterns from IAuditLogRepository. + */ + +// Timeline queries: newest first (default sorting in most UIs) +AuditLogSchema.index({ timestamp: -1 }); + +// User activity timeline +AuditLogSchema.index({ "actor.id": 1, timestamp: -1 }); + +// Resource history (chronological order for complete story) +AuditLogSchema.index({ "resource.type": 1, "resource.id": 1, timestamp: 1 }); + +// Action-based queries with time filtering +AuditLogSchema.index({ action: 1, timestamp: -1 }); + +// Security investigations by IP +AuditLogSchema.index({ ipAddress: 1, timestamp: -1 }); + +// Distributed tracing +AuditLogSchema.index({ requestId: 1 }); + +/** + * Schema options for production use. + * + * Consider enabling: + * - Time-series collection (MongoDB 5.0+) for better performance + * - Capped collection for automatic old data removal + * - Expiration via TTL index for retention policies + */ + +// Example: TTL index for automatic deletion after 7 years +// Uncomment if you want automatic expiration: +// AuditLogSchema.index({ timestamp: 1 }, { expireAfterSeconds: 220752000 }); // 7 years + +/** + * Prevents modification of audit logs after creation. + * MongoDB middleware to enforce immutability. + */ +AuditLogSchema.pre("save", function (next) { + // Allow only new documents (inserts) + if (!this.isNew) { + return next(new Error("Audit logs are immutable and cannot be modified")); + } + next(); +}); + +// Prevent updates and deletes +AuditLogSchema.pre("updateOne", function (next) { + next(new Error("Audit logs cannot be updated")); +}); + +AuditLogSchema.pre("findOneAndUpdate", function (next) { + next(new Error("Audit logs cannot be updated")); +}); + +AuditLogSchema.pre("deleteOne", function (next) { + next(new Error("Audit logs cannot be deleted (append-only)")); +}); + +AuditLogSchema.pre("findOneAndDelete", function (next) { + next(new Error("Audit logs cannot be deleted (append-only)")); +}); diff --git a/src/infra/repositories/mongodb/index.ts b/src/infra/repositories/mongodb/index.ts new file mode 100644 index 0000000..2336e50 --- /dev/null +++ b/src/infra/repositories/mongodb/index.ts @@ -0,0 +1,12 @@ +/** + * ============================================================================ + * MONGODB REPOSITORY - PUBLIC EXPORTS + * ============================================================================ + * + * Exports for MongoDB audit repository implementation. + * + * @packageDocumentation + */ + +export { AuditLogSchema, type AuditLogDocument } from "./audit-log.schema"; +export { MongoAuditRepository } from "./mongo-audit.repository"; diff --git a/src/infra/repositories/mongodb/mongo-audit.repository.ts b/src/infra/repositories/mongodb/mongo-audit.repository.ts new file mode 100644 index 0000000..ff0b8ba --- /dev/null +++ b/src/infra/repositories/mongodb/mongo-audit.repository.ts @@ -0,0 +1,303 @@ +/** + * ============================================================================ + * MONGODB AUDIT REPOSITORY ADAPTER + * ============================================================================ + * + * MongoDB implementation of the IAuditLogRepository port. + * + * Purpose: + * - Persist audit logs to MongoDB + * - Implement all query methods defined in the port + * - Leverage Mongoose for type safety and validation + * - Optimize queries with proper indexing + * + * Architecture: + * - Implements IAuditLogRepository (core port) + * - Uses Mongoose models and schemas + * - Can be swapped with other implementations (PostgreSQL, etc.) + * + * @packageDocumentation + */ + +import type { Model } from "mongoose"; + +import type { IAuditLogRepository } from "../../../core/ports/audit-repository.port"; +import type { AuditLog, AuditLogFilters, PageOptions, PageResult } from "../../../core/types"; + +import type { AuditLogDocument } from "./audit-log.schema"; + +/** + * MongoDB implementation of audit log repository. + * + * Uses Mongoose for: + * - Type-safe queries + * - Schema validation + * - Automatic connection management + * - Query optimization + * + * Key Features: + * - Immutable audit logs (no updates/deletes) + * - Optimized indexes for common query patterns + * - Supports complex filtering and pagination + * - Full-text search ready (if text index configured) + * + * @example + * ```typescript + * import mongoose from 'mongoose'; + * import { AuditLogSchema } from './audit-log.schema'; + * import { MongoAuditRepository } from './mongo-audit.repository'; + * + * const AuditLogModel = mongoose.model('AuditLog', AuditLogSchema); + * const repository = new MongoAuditRepository(AuditLogModel); + * ``` + */ +export class MongoAuditRepository implements IAuditLogRepository { + /** + * Creates a new MongoDB audit repository. + * + * @param model - Mongoose model for AuditLog + */ + // eslint-disable-next-line no-unused-vars + constructor(private readonly model: Model) {} + + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + // CREATE OPERATIONS + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + /** + * Creates (persists) a new audit log entry. + * + * @param log - The audit log to persist + * @returns The persisted audit log + * @throws Error if persistence fails + */ + async create(log: AuditLog): Promise { + const document = new this.model(log); + const saved = await document.save(); + return this.toPlainObject(saved); + } + + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + // READ OPERATIONS - Single Entity + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + /** + * Finds a single audit log by ID. + * + * @param id - The audit log ID + * @returns The audit log if found, null otherwise + */ + async findById(id: string): Promise { + const document = await this.model.findOne({ id }).lean().exec(); + return document ? this.toPlainObject(document) : null; + } + + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + // READ OPERATIONS - Collections + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + /** + * Finds all audit logs for a specific actor. + * + * @param actorId - The actor's unique identifier + * @param filters - Optional additional filters + * @returns Array of audit logs + */ + async findByActor(actorId: string, filters?: Partial): Promise { + const query = this.buildQuery({ ...filters, actorId }); + const documents = await this.model.find(query).sort({ timestamp: -1 }).lean().exec(); + return documents.map((doc) => this.toPlainObject(doc)); + } + + /** + * Finds all audit logs for a specific resource. + * + * @param resourceType - The type of resource + * @param resourceId - The resource's unique identifier + * @param filters - Optional additional filters + * @returns Array of audit logs (chronological order) + */ + async findByResource( + resourceType: string, + resourceId: string, + filters?: Partial, + ): Promise { + const query = this.buildQuery({ ...filters, resourceType, resourceId }); + // Resource history should be chronological (oldest first) + const documents = await this.model.find(query).sort({ timestamp: 1 }).lean().exec(); + return documents.map((doc) => this.toPlainObject(doc)); + } + + /** + * Queries audit logs with complex filters and pagination. + * + * @param filters - Filter criteria and pagination options + * @returns Paginated result with data and metadata + */ + async query( + filters: Partial & Partial, + ): Promise> { + const { page = 1, limit = 20, sort = "-timestamp", ...queryFilters } = filters; + + // Build query + const query = this.buildQuery(queryFilters); + + // Parse sort (e.g., "-timestamp" or "+action") + const sortObject = this.parseSort(sort); + + // Execute query with pagination + const skip = (page - 1) * limit; + const [documents, total] = await Promise.all([ + this.model.find(query).sort(sortObject).skip(skip).limit(limit).lean().exec(), + this.model.countDocuments(query).exec(), + ]); + + const data = documents.map((doc) => this.toPlainObject(doc)); + const pages = Math.ceil(total / limit); + + return { + data, + page, + limit, + total, + pages, + }; + } + + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + // READ OPERATIONS - Aggregation + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + /** + * Counts audit logs matching the given filters. + * + * @param filters - Optional filter criteria + * @returns Number of matching audit logs + */ + async count(filters?: Partial): Promise { + const query = this.buildQuery(filters || {}); + return this.model.countDocuments(query).exec(); + } + + /** + * Checks if any audit log exists matching the filters. + * + * @param filters - Filter criteria + * @returns True if at least one audit log matches + */ + async exists(filters: Partial): Promise { + const query = this.buildQuery(filters); + const document = await this.model.findOne(query).lean().exec(); + return document !== null; + } + + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + // OPTIONAL OPERATIONS - Advanced Features + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + /** + * Deletes audit logs older than the specified date. + * + * ⚠️ CAUTION: This violates audit log immutability! + * Only use for compliance-mandated retention policies. + * + * @param beforeDate - Delete logs older than this date + * @returns Number of audit logs deleted + */ + async deleteOlderThan(beforeDate: Date): Promise { + const result = await this.model.deleteMany({ timestamp: { $lt: beforeDate } }).exec(); + return result.deletedCount || 0; + } + + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + // PRIVATE HELPER METHODS + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + /** + * Builds MongoDB query from filters. + * + * Converts IAuditLogFilters to MongoDB query object. + * Handles nested fields (actor.id, resource.type, etc.). + * + * @param filters - Filter criteria + * @returns MongoDB query object + */ + private buildQuery(filters: Partial): Record { + const query: Record = {}; + + // Actor filters + if (filters.actorId) query["actor.id"] = filters.actorId; + if (filters.actorType) query["actor.type"] = filters.actorType; + + // Resource filters + if (filters.resourceType) query["resource.type"] = filters.resourceType; + if (filters.resourceId) query["resource.id"] = filters.resourceId; + + // Action filter (can be single action or array) + if (filters.action) { + query.action = filters.action; + } else if (filters.actions && filters.actions.length > 0) { + query.action = { $in: filters.actions }; + } + + // Date range filters + if (filters.startDate || filters.endDate) { + query.timestamp = {}; + if (filters.startDate) query.timestamp.$gte = filters.startDate; + if (filters.endDate) query.timestamp.$lte = filters.endDate; + } + + // Other filters + if (filters.ipAddress) query.ipAddress = filters.ipAddress; + if (filters.requestId) query.requestId = filters.requestId; + if (filters.sessionId) query.sessionId = filters.sessionId; + + // Full-text search (if text index is configured) + if (filters.search) { + query.$text = { $search: filters.search }; + } + + return query; + } + + /** + * Parses sort string into MongoDB sort object. + * + * Supports: + * - "-timestamp" → { timestamp: -1 } (descending) + * - "+action" → { action: 1 } (ascending) + * - "timestamp" → { timestamp: 1 } (ascending, default) + * + * @param sort - Sort string + * @returns MongoDB sort object + */ + private parseSort(sort: string): Record { + if (sort.startsWith("-")) { + return { [sort.substring(1)]: -1 }; + } + if (sort.startsWith("+")) { + return { [sort.substring(1)]: 1 }; + } + return { [sort]: 1 }; + } + + /** + * Converts Mongoose document to plain AuditLog object. + * + * Removes Mongoose-specific properties (_id, __v, etc.). + * Ensures type safety and clean API responses. + * + * @param document - Mongoose document or lean object + * @returns Plain AuditLog object + */ + private toPlainObject(document: any): AuditLog { + // If it's a Mongoose document, convert to plain object + const plain = document.toObject ? document.toObject() : document; + + // Remove Mongoose-specific fields + // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars + const { _id, __v, ...rest } = plain; + + return rest as AuditLog; + } +}