feat(p9): iios_audit_link table + audit binding at decision points
Task O.3: IiosAuditLink (traceId/correlationId/scopeId/actorRefId/action/ resourceType/resourceId) + recordAudit() helper (reads the current trace from ALS, best-effort). Written at the decision/mutation points — route approve/deny, AI accept/reject, meeting summarize, outbound send — so the trace appears in DB and a decision can be replayed / traced across services (mandated iios_audit_link). Migration audit-link; resetDb truncates it. 2 tests bind approve/accept to a trace. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CapabilityBroker } from '../capability/capability.broker';
|
||||
import { recordAudit } from '../observability/audit';
|
||||
|
||||
/**
|
||||
* Outbound command layer (P5). Owns idempotency + per-(channelType,target) rate
|
||||
@@ -69,6 +70,7 @@ export class OutboundService {
|
||||
data: { commandId: cmd.id, attemptNo: 1, status: result.outcome, providerRef: result.providerRef, latencyMs: result.latencyMs, errorCode: result.errorCode },
|
||||
}),
|
||||
]);
|
||||
await recordAudit(this.prisma, { action: 'channel.send', resourceType: 'outbound_command', resourceId: cmd.id, scopeId });
|
||||
return updated;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
InferenceResult,
|
||||
} from '@insignia/iios-contracts';
|
||||
import { currentTraceId, newTraceId, toTraceparent } from '../observability/trace-context';
|
||||
import { recordAudit } from '../observability/audit';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PLATFORM_PORTS } from '../platform/platform-ports';
|
||||
import { decideOrThrow } from '../platform/fail-closed';
|
||||
@@ -206,6 +207,7 @@ export class AiJobService {
|
||||
data: { validationStatus: 'ACCEPTED', acceptedByRef: actor.id },
|
||||
});
|
||||
await this.emit(IIOS_EVENTS.aiArtifactAccepted, artifact.job.scopeId, artifactId, { artifactId, acceptedByActorId: actor.id });
|
||||
await recordAudit(this.prisma, { action: 'ai.artifact.accepted', resourceType: 'ai_artifact', resourceId: artifactId, scopeId: artifact.job.scopeId, actorRefId: actor.id });
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -219,6 +221,7 @@ export class AiJobService {
|
||||
data: { status: 'REJECTED', acceptedByActorId: actor.id },
|
||||
});
|
||||
await this.prisma.iiosAiClaim.updateMany({ where: { artifactId }, data: { validationStatus: 'REJECTED', acceptedByRef: actor.id } });
|
||||
await recordAudit(this.prisma, { action: 'ai.artifact.rejected', resourceType: 'ai_artifact', resourceId: artifactId, scopeId: artifact.job.scopeId, actorRefId: actor.id });
|
||||
return updated;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BadRequestException, Inject, Injectable, NotFoundException } from '@nes
|
||||
import { Prisma, IiosMeetingType, IiosConsentStatus } from '@prisma/client';
|
||||
import { CloudEvent, IIOS_EVENTS, IiosPlatformPorts } from '@insignia/iios-contracts';
|
||||
import { currentTraceId, newTraceId, toTraceparent } from '../observability/trace-context';
|
||||
import { recordAudit } from '../observability/audit';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PLATFORM_PORTS } from '../platform/platform-ports';
|
||||
import { decideOrThrow } from '../platform/fail-closed';
|
||||
@@ -351,6 +352,7 @@ export class CalendarService {
|
||||
}
|
||||
|
||||
await this.emit(IIOS_EVENTS.meetingSummarized, scopeId, meetingId, { meetingId, summaryId: summary.id });
|
||||
await recordAudit(this.prisma, { action: 'meeting.summarized', resourceType: 'meeting', resourceId: meetingId, scopeId });
|
||||
return this.getMeeting(meetingId);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { resetDb } from '../test-utils/reset-db';
|
||||
import { makeFakePorts, makeFakeInference } from '@insignia/iios-testkit';
|
||||
import { MessageService, type MessagePrincipal } from '../messaging/message.service';
|
||||
import { ActorResolver } from '../identity/actor.resolver';
|
||||
import { RuleEngine } from '../routing/rule-engine';
|
||||
import { RouteSimulator } from '../routing/route-simulator';
|
||||
import { RouteService } from '../routing/route.service';
|
||||
import { AiJobService } from '../ai/ai.service';
|
||||
import { AiBudgetGuard } from '../ai/ai-budget.guard';
|
||||
import { OutboundService } from '../adapters/outbound.service';
|
||||
import { CapabilityBroker } from '../capability/capability.broker';
|
||||
import { CapabilityProviderRegistry } from '../capability/capability.registry';
|
||||
import { runWithTrace } from './trace-context';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
|
||||
const prisma = new PrismaClient({ datasources: { db: { url } } });
|
||||
const asService = prisma as unknown as PrismaService;
|
||||
const actors = new ActorResolver(asService);
|
||||
const user: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo' };
|
||||
|
||||
const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors);
|
||||
const outbound = () => new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()));
|
||||
const routeSvc = () => new RouteService(asService, new RouteSimulator(asService, new RuleEngine()), outbound(), actors);
|
||||
|
||||
async function makeInteraction(text: string): Promise<{ interactionId: string; scopeId: string }> {
|
||||
const m = new MessageService(asService, makeFakePorts(), actors);
|
||||
const { threadId } = await m.openThread(null, user);
|
||||
const msg = await m.send(threadId, user, { content: text }, `k-${randomUUID()}`);
|
||||
const i = await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: msg.id } });
|
||||
return { interactionId: msg.id, scopeId: i.scopeId };
|
||||
}
|
||||
|
||||
beforeAll(async () => { await prisma.$connect(); });
|
||||
afterAll(async () => { await prisma.$disconnect(); });
|
||||
beforeEach(async () => { await resetDb(prisma); });
|
||||
|
||||
describe('audit link (P9 — trace appears in DB at decision points)', () => {
|
||||
it('approving a route decision writes an audit link carrying the request trace id', async () => {
|
||||
const { interactionId, scopeId } = await makeInteraction('party at 9 PM');
|
||||
await prisma.iiosRouteBinding.create({
|
||||
data: { scopeId, originChannelType: 'WHATSAPP', originRef: 'g', destinationChannelType: 'PORTAL', destinationRef: 'adults', requiresReview: true, enabled: true },
|
||||
});
|
||||
const { decisions } = await routeSvc().simulate(interactionId, { channelType: 'WHATSAPP', ref: 'g' });
|
||||
const decisionId = decisions[0]!.id;
|
||||
|
||||
await runWithTrace('trace-approve-1', async () => {
|
||||
await routeSvc().approve(decisionId, user);
|
||||
});
|
||||
|
||||
const link = await prisma.iiosAuditLink.findFirstOrThrow({ where: { resourceId: decisionId } });
|
||||
expect(link.action).toBe('route.decision.approved');
|
||||
expect(link.resourceType).toBe('route_decision');
|
||||
expect(link.traceId).toBe('trace-approve-1');
|
||||
expect(link.actorRefId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('accepting an AI artifact writes an audit link for it', async () => {
|
||||
const { interactionId } = await makeInteraction('party at 9 PM');
|
||||
const { artifact } = await ai().runJob({ interactionId, jobType: 'CLASSIFY', principal: user });
|
||||
await runWithTrace('trace-accept-1', async () => {
|
||||
await ai().accept(artifact!.id, user);
|
||||
});
|
||||
const link = await prisma.iiosAuditLink.findFirstOrThrow({ where: { resourceId: artifact!.id, action: 'ai.artifact.accepted' } });
|
||||
expect(link.traceId).toBe('trace-accept-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
import { currentTraceId } from './trace-context';
|
||||
|
||||
export interface AuditInput {
|
||||
action: string;
|
||||
resourceType: string;
|
||||
resourceId: string;
|
||||
scopeId?: string;
|
||||
actorRefId?: string;
|
||||
correlationId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an audit link (P9): binds an action to its trace + actor + resource so a
|
||||
* decision can be replayed / a request traced across services. Plain function — reads
|
||||
* the current request's trace id from AsyncLocalStorage (no DI). Best-effort: never
|
||||
* throws into the caller's happy path.
|
||||
*/
|
||||
export async function recordAudit(prisma: PrismaService, input: AuditInput): Promise<void> {
|
||||
try {
|
||||
await prisma.iiosAuditLink.create({
|
||||
data: {
|
||||
action: input.action,
|
||||
resourceType: input.resourceType,
|
||||
resourceId: input.resourceId,
|
||||
scopeId: input.scopeId,
|
||||
actorRefId: input.actorRefId,
|
||||
traceId: currentTraceId(),
|
||||
correlationId: input.correlationId ?? currentTraceId(),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
/* audit is best-effort; do not break the request */
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
|
||||
import { Prisma, IiosRouteBinding, IiosRouteDecision, IiosRouteMode, IiosOutputFormat } from '@prisma/client';
|
||||
import { CloudEvent, IIOS_EVENTS } from '@insignia/iios-contracts';
|
||||
import { currentTraceId, newTraceId, toTraceparent } from '../observability/trace-context';
|
||||
import { recordAudit } from '../observability/audit';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RouteSimulator, type OriginRef } from './route-simulator';
|
||||
import { OutboundService } from '../adapters/outbound.service';
|
||||
@@ -71,6 +72,7 @@ export class RouteService {
|
||||
data: { decisionState: 'ALLOW', decidedByActorId: actor.id },
|
||||
});
|
||||
await this.emitApproved(decision.routeBinding.scopeId, decisionId);
|
||||
await recordAudit(this.prisma, { action: 'route.decision.approved', resourceType: 'route_decision', resourceId: decisionId, scopeId: decision.routeBinding.scopeId, actorRefId: actor.id });
|
||||
await this.execute(updated, decision.routeBinding);
|
||||
return this.prisma.iiosRouteDecision.findUniqueOrThrow({ where: { id: decisionId } });
|
||||
}
|
||||
@@ -80,10 +82,12 @@ export class RouteService {
|
||||
if (!decision) throw new NotFoundException('route decision not found');
|
||||
await this.actors.assertOwns(principal, decision.routeBinding.scopeId); // tenant fence (KG-02)
|
||||
const actor = await this.actors.resolveActor(decision.routeBinding.scopeId, principal);
|
||||
return this.prisma.iiosRouteDecision.update({
|
||||
const updated = await this.prisma.iiosRouteDecision.update({
|
||||
where: { id: decisionId },
|
||||
data: { decisionState: 'DENY', decidedByActorId: actor.id },
|
||||
});
|
||||
await recordAudit(this.prisma, { action: 'route.decision.denied', resourceType: 'route_decision', resourceId: decisionId, scopeId: decision.routeBinding.scopeId, actorRefId: actor.id });
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Dispatch an ALLOW decision to the P5 sandbox (no real network). Idempotent. */
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { PrismaClient } from '@prisma/client';
|
||||
export async function resetDb(prisma: PrismaClient): Promise<void> {
|
||||
await prisma.$executeRawUnsafe(
|
||||
`TRUNCATE TABLE
|
||||
"IiosAuditLink",
|
||||
"IiosMeetingActionItem","IiosActionItem","IiosTranscriptSegment","IiosMeetingTranscript","IiosMeetingSummary","IiosMeetingParticipant","IiosMeeting","IiosMeetingRequest",
|
||||
"IiosCalendarSyncCursor","IiosCalendarEvent","IiosCalendarProviderAccount","IiosAvailabilityWindow",
|
||||
"IiosAiEvidenceLink","IiosAiClaim","IiosAiToolCall","IiosAiArtifact","IiosAiModelRun","IiosAiJob","IiosEmbeddingRef",
|
||||
|
||||
Reference in New Issue
Block a user