import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; /** * One backoffice action against a state-changing endpoint. * * Deliberately does NOT extend `BaseEntity`, which is the repo standard * everywhere else. `BaseEntity` carries `updatedAt` and `deletedAt`, and both * are wrong here: * * - `updatedAt` implies an audit row can be edited. A record that can be * rewritten after the fact is not evidence. * - `deletedAt` (soft delete) would let anyone who can delete erase their own * trail, and TypeORM would then hide those rows from every default query — * the failure would be silent, which is the worst property an audit log can * have. * * Rows are insert-only: nothing in this module updates or deletes them. * * `userId` is a bare uuid with NO foreign key into the `iam` schema. Two * reasons: cross-schema FKs are forbidden platform-wide, and a FK would let * deleting a user cascade away the record of what that user did — exactly * backwards. `userName` / `userRole` are point-in-time snapshots for the same * reason: resolving them at read time would rewrite history whenever somebody * is renamed or changes role. */ @Entity({ schema: 'freight', name: 'audit_logs' }) // Every audit query is time-bounded, so created_at leads most indexes. @Index('IDX_audit_logs_created_at', ['createdAt']) @Index('IDX_audit_logs_user_id_created_at', ['userId', 'createdAt']) @Index('IDX_audit_logs_type_created_at', ['type', 'createdAt']) @Index('IDX_audit_logs_type_resource_id', ['type', 'resourceId']) @Index('IDX_audit_logs_route_path_created_at', ['routePath', 'createdAt']) export class AuditLog { @PrimaryGeneratedColumn('uuid') id!: string; /** * Human-readable action, e.g. "Approve contract" — taken from the matched * entry in `AUDIT_ENDPOINTS`, which sources it from each route's * `@ApiOperation` summary. */ @Column({ name: 'title', type: 'varchar', length: 255 }) title!: string; @Column({ name: 'method', type: 'varchar', length: 10 }) method!: string; /** * The URL as actually called, real ids and query string included * (`/api/contracts/abc-123/cancel?force=true`). Query values run through the * same redaction pass as the body, so a `?token=` never lands here. */ @Column({ name: 'url', type: 'text' }) url!: string; /** * The route template (`/api/contracts/:id/cancel`). * * `url` alone cannot be grouped — every contract cancel is a distinct string. * This column is the join key back to `AUDIT_ENDPOINTS` and makes * "every contract cancellation" one indexed query instead of a regex scan. */ @Column({ name: 'route_path', type: 'varchar', length: 255, nullable: true }) routePath?: string | null; /** Primary entity the action touched: `Contract`, `Booking`, `Locomotive`. */ @Column({ name: 'type', type: 'varchar', length: 50 }) type!: string; @Column({ name: 'is_success', type: 'boolean' }) isSuccess!: boolean; /** IAM user id. Nullable by design — see the class comment. */ @Column({ name: 'user_id', type: 'uuid', nullable: true }) userId?: string | null; /** * Id of the affected record, recovered from the first path parameter of the * matched template. * * `varchar`, not `uuid`: not every identifier is a uuid * (`/api/contract-templates/:code`), and a create has no id at all until it * succeeds. A `uuid NOT NULL` column would throw during the write and lose * the audit row rather than the id. */ @Column({ name: 'resource_id', type: 'varchar', length: 64, nullable: true }) resourceId?: string | null; /** * Sanitized request body. Secrets are replaced with `[REDACTED]` and uploads * are reduced to `{ __file, originalName, mimeType, size }` descriptors — * never raw bytes. See `audit.sanitizer.ts`. */ @Column({ name: 'request', type: 'jsonb', nullable: true }) request?: Record | null; /** * `isSuccess` alone cannot separate 403 (denied — the security signal worth * alerting on) from 500 (broke). Both are simply `false`. */ @Column({ name: 'status_code', type: 'smallint', nullable: true }) statusCode?: number | null; @Column({ name: 'error_message', type: 'text', nullable: true }) errorMessage?: string | null; /** Snapshot of the actor's display name at the time of the action. */ @Column({ name: 'user_name', type: 'varchar', length: 150, nullable: true }) userName?: string | null; /** Snapshot of the actor's role at the time of the action. */ @Column({ name: 'user_role', type: 'varchar', length: 100, nullable: true }) userRole?: string | null; /** Non-repudiation: the first thing asked in any incident review. */ @Column({ name: 'ip_address', type: 'inet', nullable: true }) ipAddress?: string | null; /** Helps separate a real browser session from a script using a stolen token. */ @Column({ name: 'user_agent', type: 'text', nullable: true }) userAgent?: string | null; /** Correlates this row with application logs/traces for the same request. */ @Column({ name: 'request_id', type: 'varchar', length: 64, nullable: true }) requestId?: string | null; @Column({ name: 'duration_ms', type: 'integer', nullable: true }) durationMs?: number | null; @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt!: Date; }