feat: Phase 1 MVP — complete AgentIdP implementation
Implements all P0 features per OpenSpec change phase-1-mvp-implementation: - Agent Registry Service (CRUD) — full lifecycle management - OAuth 2.0 Token Service (Client Credentials flow) - Credential Management (generate, rotate, revoke) - Immutable Audit Log Service Tech: Node.js 18+, TypeScript 5.3+ strict, Express 4.18+, PostgreSQL 14+, Redis 7+ Standards: OpenAPI 3.0 specs, DRY/SOLID, zero `any` types Quality: 18 unit test suites, 244 tests passing, 97%+ coverage OpenAPI: 4 complete specs (14 endpoints total) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
136
src/services/AuditService.ts
Normal file
136
src/services/AuditService.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Audit Log Service for SentryAgent.ai AgentIdP.
|
||||
* Provides methods for logging and querying immutable audit events.
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @returns Promise resolving to the created audit event.
|
||||
*/
|
||||
async logEvent(
|
||||
agentId: string,
|
||||
action: AuditAction,
|
||||
outcome: AuditOutcome,
|
||||
ipAddress: string,
|
||||
userAgent: string,
|
||||
metadata: Record<string, unknown>,
|
||||
): Promise<IAuditEvent> {
|
||||
return this.auditRepository.create({
|
||||
agentId,
|
||||
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<IPaginatedAuditEventsResponse> {
|
||||
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<IAuditEvent> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user