Files
core2026/app/gateway-api/src/adminAudit.ts
T

165 lines
6.1 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import type { GatewayPrisma, GatewayPrismaClient } from '@sammo-ts/infra';
export type AdminAuditOutcome = 'STARTED' | 'SUCCEEDED' | 'FAILED';
export interface AdminAuditEventRecord {
id: string;
correlationId: string;
actorUserId: string;
actorUsername: string;
credentialKind: string;
capability?: string;
scope?: string;
action: string;
targetType?: string;
targetId?: string;
profileName?: string;
reason?: string;
outcome: AdminAuditOutcome;
summary: Record<string, unknown>;
errorCode?: string;
errorMessage?: string;
createdAt: string;
}
export interface AdminAuditWrite {
correlationId: string;
actorUserId: string;
actorUsername: string;
capability?: string;
scope?: string;
action: string;
targetType?: string;
targetId?: string;
profileName?: string;
reason?: string;
outcome: AdminAuditOutcome;
summary?: Record<string, unknown>;
errorCode?: string;
errorMessage?: string;
}
export interface AdminAuditStore {
append(event: AdminAuditWrite): Promise<void>;
list(input?: {
actorUserId?: string;
targetType?: string;
targetId?: string;
profileName?: string;
limit?: number;
}): Promise<AdminAuditEventRecord[]>;
}
type AuditDelegate = {
create(args: { data: Record<string, unknown> }): Promise<unknown>;
findMany(args: Record<string, unknown>): Promise<Array<Record<string, unknown>>>;
};
const asAuditDelegate = (prisma: GatewayPrismaClient): AuditDelegate | null => {
const delegate = (prisma as unknown as { adminAuditEvent?: AuditDelegate }).adminAuditEvent;
return delegate ?? null;
};
const toRecord = (row: Record<string, unknown>): AdminAuditEventRecord => ({
id: String(row.id),
correlationId: String(row.correlationId),
actorUserId: String(row.actorUserId),
actorUsername: String(row.actorUsername),
credentialKind: String(row.credentialKind),
...(typeof row.capability === 'string' ? { capability: row.capability } : {}),
...(typeof row.scope === 'string' ? { scope: row.scope } : {}),
action: String(row.action),
...(typeof row.targetType === 'string' ? { targetType: row.targetType } : {}),
...(typeof row.targetId === 'string' ? { targetId: row.targetId } : {}),
...(typeof row.profileName === 'string' ? { profileName: row.profileName } : {}),
...(typeof row.reason === 'string' ? { reason: row.reason } : {}),
outcome: row.outcome as AdminAuditOutcome,
summary:
row.summary && typeof row.summary === 'object' && !Array.isArray(row.summary)
? (row.summary as Record<string, unknown>)
: {},
...(typeof row.errorCode === 'string' ? { errorCode: row.errorCode } : {}),
...(typeof row.errorMessage === 'string' ? { errorMessage: row.errorMessage } : {}),
createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
});
export const createAdminAuditStore = (prisma: GatewayPrismaClient): AdminAuditStore => ({
async append(event) {
const delegate = asAuditDelegate(prisma);
// Partial Prisma mocks in router tests intentionally omit the audit model.
if (!delegate) return;
await delegate.create({
data: {
...event,
summary: (event.summary ?? {}) as GatewayPrisma.JsonObject,
},
});
},
async list(input = {}) {
const delegate = asAuditDelegate(prisma);
if (!delegate) return [];
const rows = await delegate.findMany({
where: {
...(input.actorUserId ? { actorUserId: input.actorUserId } : {}),
...(input.targetType ? { targetType: input.targetType } : {}),
...(input.targetId ? { targetId: input.targetId } : {}),
...(input.profileName ? { profileName: input.profileName } : {}),
},
orderBy: { createdAt: 'desc' },
take: Math.min(Math.max(input.limit ?? 100, 1), 200),
});
return rows.map(toRecord);
},
});
const REDACTED_KEYS = /password|credential|token|secret|oauth|authorization|email/i;
export const sanitizeAdminAuditValue = (value: unknown, depth = 0): unknown => {
if (depth > 4) return '[DEPTH_LIMIT]';
if (value === null || typeof value === 'boolean' || typeof value === 'number') return value;
if (typeof value === 'string') return value.length > 500 ? `${value.slice(0, 500)}…` : value;
if (Array.isArray(value)) return value.slice(0, 50).map((entry) => sanitizeAdminAuditValue(entry, depth + 1));
if (!value || typeof value !== 'object') return String(value);
const result: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value).slice(0, 80)) {
result[key] = REDACTED_KEYS.test(key) ? '[REDACTED]' : sanitizeAdminAuditValue(entry, depth + 1);
}
return result;
};
export const buildAdminAuditTarget = (
rawInput: unknown
): {
targetType?: string;
targetId?: string;
profileName?: string;
reason?: string;
scope?: string;
summary: Record<string, unknown>;
} => {
const input =
rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)
? (rawInput as Record<string, unknown>)
: {};
const userId = typeof input.userId === 'string' ? input.userId : undefined;
const profileName = typeof input.profileName === 'string' ? input.profileName : undefined;
const operationId = typeof input.id === 'string' ? input.id : undefined;
const reason = typeof input.reason === 'string' ? input.reason : undefined;
return {
...(userId
? { targetType: 'USER', targetId: userId }
: profileName
? { targetType: 'PROFILE', targetId: profileName }
: operationId
? { targetType: 'OPERATION', targetId: operationId }
: {}),
...(profileName ? { profileName, scope: profileName } : {}),
...(reason ? { reason } : {}),
summary: sanitizeAdminAuditValue(input) as Record<string, unknown>,
};
};
export const newAdminAuditCorrelationId = (): string => randomUUID();