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:
SentryAgent.ai Developer
2026-03-28 09:14:41 +00:00
parent 245f8df427
commit d3530285b9
78 changed files with 20590 additions and 1 deletions

View 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;
}
}