/** * Audit Log Service for SentryAgent.ai AgentIdP. * Provides methods for logging and querying immutable audit events. * * SOC 2 CC7.2 — Audit Log Integrity: * Each event is cryptographically linked to the previous one via a SHA-256 hash chain. * The hash is computed as: * SHA-256(eventId + timestamp.toISOString() + action + outcome + agentId + organizationId + previousHash) * This makes any tampering, deletion, or insertion detectable via AuditVerificationService. */ import crypto from 'crypto'; import { AuditRepository } from '../repositories/AuditRepository.js'; import { IAuditEvent, IAuditListFilters, IPaginatedAuditEventsResponse, AuditAction, AuditOutcome, } from '../types/index.js'; import { AuditEventNotFoundError, RetentionWindowError, ValidationError, } from '../utils/errors.js'; const FREE_TIER_RETENTION_DAYS = 90; /** * Service for creating and querying audit log events. * Enforces 90-day retention window on all queries. */ export class AuditService { /** * @param auditRepository - The audit event repository. */ constructor(private readonly auditRepository: AuditRepository) {} /** * Computes the earliest allowed timestamp for audit queries (90-day retention). * * @returns The retention cutoff Date. */ private getRetentionCutoff(): Date { const cutoff = new Date(); cutoff.setUTCDate(cutoff.getUTCDate() - FREE_TIER_RETENTION_DAYS); cutoff.setUTCHours(0, 0, 0, 0); return cutoff; } /** * Computes the SHA-256 hash for an audit event in the chain. * Used internally and by AuditVerificationService. * * @param eventId - The event UUID. * @param timestamp - The event timestamp. * @param action - The audit action. * @param outcome - The audit outcome. * @param agentId - The agent UUID. * @param organizationId - The organization UUID. * @param previousHash - The hash of the preceding event ('' for the first event). * @returns 64-character hex SHA-256 hash. */ static computeHash( eventId: string, timestamp: Date, action: string, outcome: string, agentId: string, organizationId: string, previousHash: string, ): string { return crypto .createHash('sha256') .update( eventId + timestamp.toISOString() + action + outcome + agentId + organizationId + previousHash, ) .digest('hex'); } /** * Logs an audit event. This is a fire-and-forget async insert for token * endpoints (do not await). For DB-backed operations, await this method. * * @param agentId - The agent that triggered the event. * @param action - The action that occurred. * @param outcome - Whether the action succeeded or failed. * @param ipAddress - The client IP address. * @param userAgent - The client User-Agent header. * @param metadata - Action-specific structured context data. * @param organizationId - Optional organization UUID for hash chain computation. * @returns Promise resolving to the created audit event. */ async logEvent( agentId: string, action: AuditAction, outcome: AuditOutcome, ipAddress: string, userAgent: string, metadata: Record, organizationId?: string, ): Promise { return this.auditRepository.create({ agentId, organizationId, action, outcome, ipAddress, userAgent, metadata, }); } /** * Queries the audit log with optional filters, pagination, and retention enforcement. * * @param filters - Query filters and pagination parameters. * @returns Paginated audit events response. * @throws RetentionWindowError if fromDate is before the 90-day retention cutoff. * @throws ValidationError if fromDate is after toDate. */ async queryEvents(filters: IAuditListFilters): Promise { const retentionCutoff = this.getRetentionCutoff(); if (filters.fromDate !== undefined) { const fromDate = new Date(filters.fromDate); if (fromDate < retentionCutoff) { throw new RetentionWindowError( FREE_TIER_RETENTION_DAYS, retentionCutoff.toISOString(), ); } } if (filters.fromDate !== undefined && filters.toDate !== undefined) { const fromDate = new Date(filters.fromDate); const toDate = new Date(filters.toDate); if (fromDate > toDate) { throw new ValidationError('Invalid date range.', { reason: 'fromDate must be before or equal to toDate.', }); } } const { events, total } = await this.auditRepository.findAll(filters, retentionCutoff); return { data: events, total, page: filters.page, limit: filters.limit, }; } /** * Retrieves a single audit event by its UUID. * * @param eventId - The audit event UUID. * @returns The audit event record. * @throws AuditEventNotFoundError if the event does not exist. */ async getEventById(eventId: string): Promise { const event = await this.auditRepository.findById(eventId); if (!event) { throw new AuditEventNotFoundError(eventId); } // Check retention window — events older than 90 days are not accessible const retentionCutoff = this.getRetentionCutoff(); if (event.timestamp < retentionCutoff) { throw new AuditEventNotFoundError(eventId); } return event; } }