UNPKG

s3db.js

Version:

Use AWS S3, the world's most reliable document storage, as a database with this ORM.

347 lines (301 loc) 11.5 kB
import Plugin from "./plugin.class.js"; import tryFn from "../concerns/try-fn.js"; export class AuditPlugin extends Plugin { constructor(options = {}) { super(options); this.auditResource = null; this.config = { includeData: options.includeData !== false, includePartitions: options.includePartitions !== false, maxDataSize: options.maxDataSize || 10000, ...options }; } async onSetup() { // Create audit resource const [ok, err, auditResource] = await tryFn(() => this.database.createResource({ name: 'audits', attributes: { id: 'string|required', resourceName: 'string|required', operation: 'string|required', recordId: 'string|required', userId: 'string|optional', timestamp: 'string|required', oldData: 'string|optional', newData: 'string|optional', partition: 'string|optional', partitionValues: 'string|optional', metadata: 'string|optional' }, behavior: 'body-overflow' })); this.auditResource = ok ? auditResource : (this.database.resources.audits || null); if (!ok && !this.auditResource) return; // Hook into database for new resources this.database.addHook('afterCreateResource', (context) => { if (context.resource.name !== 'audits') { this.setupResourceAuditing(context.resource); } }); // Setup existing resources for (const resource of Object.values(this.database.resources)) { if (resource.name !== 'audits') { this.setupResourceAuditing(resource); } } } async onStart() { // Ready } async onStop() { // No cleanup needed } setupResourceAuditing(resource) { // Insert resource.on('insert', async (data) => { const partitionValues = this.config.includePartitions ? this.getPartitionValues(data, resource) : null; await this.logAudit({ resourceName: resource.name, operation: 'insert', recordId: data.id || 'auto-generated', oldData: null, newData: this.config.includeData ? JSON.stringify(this.truncateData(data)) : null, partition: partitionValues ? this.getPrimaryPartition(partitionValues) : null, partitionValues: partitionValues ? JSON.stringify(partitionValues) : null }); }); // Update resource.on('update', async (data) => { let oldData = data.$before; if (this.config.includeData && !oldData) { const [ok, err, fetched] = await tryFn(() => resource.get(data.id)); if (ok) oldData = fetched; } const partitionValues = this.config.includePartitions ? this.getPartitionValues(data, resource) : null; await this.logAudit({ resourceName: resource.name, operation: 'update', recordId: data.id, oldData: oldData && this.config.includeData ? JSON.stringify(this.truncateData(oldData)) : null, newData: this.config.includeData ? JSON.stringify(this.truncateData(data)) : null, partition: partitionValues ? this.getPrimaryPartition(partitionValues) : null, partitionValues: partitionValues ? JSON.stringify(partitionValues) : null }); }); // Delete resource.on('delete', async (data) => { let oldData = data; if (this.config.includeData && !oldData) { const [ok, err, fetched] = await tryFn(() => resource.get(data.id)); if (ok) oldData = fetched; } const partitionValues = oldData && this.config.includePartitions ? this.getPartitionValues(oldData, resource) : null; await this.logAudit({ resourceName: resource.name, operation: 'delete', recordId: data.id, oldData: oldData && this.config.includeData ? JSON.stringify(this.truncateData(oldData)) : null, newData: null, partition: partitionValues ? this.getPrimaryPartition(partitionValues) : null, partitionValues: partitionValues ? JSON.stringify(partitionValues) : null }); }); // DeleteMany - We need to intercept before deletion to get the data const originalDeleteMany = resource.deleteMany.bind(resource); const plugin = this; resource.deleteMany = async function(ids) { // Fetch all objects before deletion for audit logging const objectsToDelete = []; for (const id of ids) { const [ok, err, fetched] = await tryFn(() => resource.get(id)); if (ok) { objectsToDelete.push(fetched); } else { objectsToDelete.push({ id }); // Just store the ID if we can't fetch } } // Perform the actual deletion const result = await originalDeleteMany(ids); // Log audit entries after successful deletion for (const oldData of objectsToDelete) { const partitionValues = oldData && plugin.config.includePartitions ? plugin.getPartitionValues(oldData, resource) : null; await plugin.logAudit({ resourceName: resource.name, operation: 'deleteMany', recordId: oldData.id, oldData: oldData && plugin.config.includeData ? JSON.stringify(plugin.truncateData(oldData)) : null, newData: null, partition: partitionValues ? plugin.getPrimaryPartition(partitionValues) : null, partitionValues: partitionValues ? JSON.stringify(partitionValues) : null }); } return result; }; // Store reference for cleanup if needed resource._originalDeleteMany = originalDeleteMany; } // Backward compatibility for tests installEventListenersForResource(resource) { return this.setupResourceAuditing(resource); } async logAudit(auditData) { if (!this.auditResource) { return; } const auditRecord = { id: `audit-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`, userId: this.getCurrentUserId?.() || 'system', timestamp: new Date().toISOString(), metadata: JSON.stringify({ source: 'audit-plugin', version: '2.0' }), resourceName: auditData.resourceName, operation: auditData.operation, recordId: auditData.recordId }; // Only add fields that are not null if (auditData.oldData !== null) { auditRecord.oldData = auditData.oldData; } if (auditData.newData !== null) { auditRecord.newData = auditData.newData; } if (auditData.partition !== null) { auditRecord.partition = auditData.partition; } if (auditData.partitionValues !== null) { auditRecord.partitionValues = auditData.partitionValues; } try { await this.auditResource.insert(auditRecord); } catch (error) { // Silently fail to avoid breaking operations console.warn('Audit logging failed:', error.message); } } getPartitionValues(data, resource) { if (!this.config.includePartitions) return null; // Access partitions from resource.config.partitions, not resource.partitions const partitions = resource.config?.partitions || resource.partitions; if (!partitions) { return null; } const partitionValues = {}; for (const [partitionName, partitionConfig] of Object.entries(partitions)) { const values = {}; for (const field of Object.keys(partitionConfig.fields)) { values[field] = this.getNestedFieldValue(data, field); } if (Object.values(values).some(v => v !== undefined && v !== null)) { partitionValues[partitionName] = values; } } return Object.keys(partitionValues).length > 0 ? partitionValues : null; } getNestedFieldValue(data, fieldPath) { const parts = fieldPath.split('.'); let value = data; for (const part of parts) { if (value && typeof value === 'object' && part in value) { value = value[part]; } else { return undefined; } } return value; } getPrimaryPartition(partitionValues) { if (!partitionValues) return null; const partitionNames = Object.keys(partitionValues); return partitionNames.length > 0 ? partitionNames[0] : null; } truncateData(data) { if (!this.config.includeData) return null; const dataStr = JSON.stringify(data); if (dataStr.length <= this.config.maxDataSize) { return data; } return { ...data, _truncated: true, _originalSize: dataStr.length, _truncatedAt: new Date().toISOString() }; } async getAuditLogs(options = {}) { if (!this.auditResource) return []; const { resourceName, operation, recordId, partition, startDate, endDate, limit = 100, offset = 0 } = options; // If we have specific filters, we need to fetch more items to ensure proper pagination after filtering const hasFilters = resourceName || operation || recordId || partition || startDate || endDate; let items = []; if (hasFilters) { // Fetch enough items to handle filtering const fetchSize = Math.min(10000, Math.max(1000, (limit + offset) * 20)); const result = await this.auditResource.list({ limit: fetchSize }); items = result || []; // Apply filters if (resourceName) { items = items.filter(log => log.resourceName === resourceName); } if (operation) { items = items.filter(log => log.operation === operation); } if (recordId) { items = items.filter(log => log.recordId === recordId); } if (partition) { items = items.filter(log => log.partition === partition); } if (startDate || endDate) { items = items.filter(log => { const timestamp = new Date(log.timestamp); if (startDate && timestamp < new Date(startDate)) return false; if (endDate && timestamp > new Date(endDate)) return false; return true; }); } // Apply offset and limit after filtering return items.slice(offset, offset + limit); } else { // No filters, use direct pagination const result = await this.auditResource.page({ size: limit, offset }); return result.items || []; } } async getRecordHistory(resourceName, recordId) { return await this.getAuditLogs({ resourceName, recordId }); } async getPartitionHistory(resourceName, partitionName, partitionValues) { return await this.getAuditLogs({ resourceName, partition: partitionName, partitionValues: JSON.stringify(partitionValues) }); } async getAuditStats(options = {}) { const logs = await this.getAuditLogs(options); const stats = { total: logs.length, byOperation: {}, byResource: {}, byPartition: {}, byUser: {}, timeline: {} }; for (const log of logs) { // Count by operation stats.byOperation[log.operation] = (stats.byOperation[log.operation] || 0) + 1; // Count by resource stats.byResource[log.resourceName] = (stats.byResource[log.resourceName] || 0) + 1; // Count by partition if (log.partition) { stats.byPartition[log.partition] = (stats.byPartition[log.partition] || 0) + 1; } // Count by user stats.byUser[log.userId] = (stats.byUser[log.userId] || 0) + 1; // Timeline by date const date = log.timestamp.split('T')[0]; stats.timeline[date] = (stats.timeline[date] || 0) + 1; } return stats; } }