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:
@@ -0,0 +1,23 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "IiosAuditLink" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"traceId" TEXT,
|
||||||
|
"correlationId" TEXT,
|
||||||
|
"scopeId" TEXT,
|
||||||
|
"actorRefId" TEXT,
|
||||||
|
"action" TEXT NOT NULL,
|
||||||
|
"resourceType" TEXT NOT NULL,
|
||||||
|
"resourceId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "IiosAuditLink_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "IiosAuditLink_traceId_idx" ON "IiosAuditLink"("traceId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "IiosAuditLink_resourceType_resourceId_idx" ON "IiosAuditLink"("resourceType", "resourceId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "IiosAuditLink_scopeId_idx" ON "IiosAuditLink"("scopeId");
|
||||||
@@ -1158,3 +1158,23 @@ model IiosCalendarSyncCursor {
|
|||||||
|
|
||||||
@@unique([providerAccountId])
|
@@unique([providerAccountId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Observability (P9) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Binds an IIOS action to its trace / actor / resource for cross-service causality
|
||||||
|
/// and "replay a decision" (mandated iios_audit_link). Written at decision points.
|
||||||
|
model IiosAuditLink {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
traceId String?
|
||||||
|
correlationId String?
|
||||||
|
scopeId String?
|
||||||
|
actorRefId String?
|
||||||
|
action String
|
||||||
|
resourceType String
|
||||||
|
resourceId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([traceId])
|
||||||
|
@@index([resourceType, resourceId])
|
||||||
|
@@index([scopeId])
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { CapabilityBroker } from '../capability/capability.broker';
|
import { CapabilityBroker } from '../capability/capability.broker';
|
||||||
|
import { recordAudit } from '../observability/audit';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Outbound command layer (P5). Owns idempotency + per-(channelType,target) rate
|
* 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 },
|
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;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
InferenceResult,
|
InferenceResult,
|
||||||
} from '@insignia/iios-contracts';
|
} from '@insignia/iios-contracts';
|
||||||
import { currentTraceId, newTraceId, toTraceparent } from '../observability/trace-context';
|
import { currentTraceId, newTraceId, toTraceparent } from '../observability/trace-context';
|
||||||
|
import { recordAudit } from '../observability/audit';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { PLATFORM_PORTS } from '../platform/platform-ports';
|
import { PLATFORM_PORTS } from '../platform/platform-ports';
|
||||||
import { decideOrThrow } from '../platform/fail-closed';
|
import { decideOrThrow } from '../platform/fail-closed';
|
||||||
@@ -206,6 +207,7 @@ export class AiJobService {
|
|||||||
data: { validationStatus: 'ACCEPTED', acceptedByRef: actor.id },
|
data: { validationStatus: 'ACCEPTED', acceptedByRef: actor.id },
|
||||||
});
|
});
|
||||||
await this.emit(IIOS_EVENTS.aiArtifactAccepted, artifact.job.scopeId, artifactId, { artifactId, acceptedByActorId: 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;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,6 +221,7 @@ export class AiJobService {
|
|||||||
data: { status: 'REJECTED', acceptedByActorId: actor.id },
|
data: { status: 'REJECTED', acceptedByActorId: actor.id },
|
||||||
});
|
});
|
||||||
await this.prisma.iiosAiClaim.updateMany({ where: { artifactId }, data: { validationStatus: 'REJECTED', acceptedByRef: 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;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { BadRequestException, Inject, Injectable, NotFoundException } from '@nes
|
|||||||
import { Prisma, IiosMeetingType, IiosConsentStatus } from '@prisma/client';
|
import { Prisma, IiosMeetingType, IiosConsentStatus } from '@prisma/client';
|
||||||
import { CloudEvent, IIOS_EVENTS, IiosPlatformPorts } from '@insignia/iios-contracts';
|
import { CloudEvent, IIOS_EVENTS, IiosPlatformPorts } from '@insignia/iios-contracts';
|
||||||
import { currentTraceId, newTraceId, toTraceparent } from '../observability/trace-context';
|
import { currentTraceId, newTraceId, toTraceparent } from '../observability/trace-context';
|
||||||
|
import { recordAudit } from '../observability/audit';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { PLATFORM_PORTS } from '../platform/platform-ports';
|
import { PLATFORM_PORTS } from '../platform/platform-ports';
|
||||||
import { decideOrThrow } from '../platform/fail-closed';
|
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 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);
|
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 { Prisma, IiosRouteBinding, IiosRouteDecision, IiosRouteMode, IiosOutputFormat } from '@prisma/client';
|
||||||
import { CloudEvent, IIOS_EVENTS } from '@insignia/iios-contracts';
|
import { CloudEvent, IIOS_EVENTS } from '@insignia/iios-contracts';
|
||||||
import { currentTraceId, newTraceId, toTraceparent } from '../observability/trace-context';
|
import { currentTraceId, newTraceId, toTraceparent } from '../observability/trace-context';
|
||||||
|
import { recordAudit } from '../observability/audit';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { RouteSimulator, type OriginRef } from './route-simulator';
|
import { RouteSimulator, type OriginRef } from './route-simulator';
|
||||||
import { OutboundService } from '../adapters/outbound.service';
|
import { OutboundService } from '../adapters/outbound.service';
|
||||||
@@ -71,6 +72,7 @@ export class RouteService {
|
|||||||
data: { decisionState: 'ALLOW', decidedByActorId: actor.id },
|
data: { decisionState: 'ALLOW', decidedByActorId: actor.id },
|
||||||
});
|
});
|
||||||
await this.emitApproved(decision.routeBinding.scopeId, decisionId);
|
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);
|
await this.execute(updated, decision.routeBinding);
|
||||||
return this.prisma.iiosRouteDecision.findUniqueOrThrow({ where: { id: decisionId } });
|
return this.prisma.iiosRouteDecision.findUniqueOrThrow({ where: { id: decisionId } });
|
||||||
}
|
}
|
||||||
@@ -80,10 +82,12 @@ export class RouteService {
|
|||||||
if (!decision) throw new NotFoundException('route decision not found');
|
if (!decision) throw new NotFoundException('route decision not found');
|
||||||
await this.actors.assertOwns(principal, decision.routeBinding.scopeId); // tenant fence (KG-02)
|
await this.actors.assertOwns(principal, decision.routeBinding.scopeId); // tenant fence (KG-02)
|
||||||
const actor = await this.actors.resolveActor(decision.routeBinding.scopeId, principal);
|
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 },
|
where: { id: decisionId },
|
||||||
data: { decisionState: 'DENY', decidedByActorId: actor.id },
|
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. */
|
/** 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> {
|
export async function resetDb(prisma: PrismaClient): Promise<void> {
|
||||||
await prisma.$executeRawUnsafe(
|
await prisma.$executeRawUnsafe(
|
||||||
`TRUNCATE TABLE
|
`TRUNCATE TABLE
|
||||||
|
"IiosAuditLink",
|
||||||
"IiosMeetingActionItem","IiosActionItem","IiosTranscriptSegment","IiosMeetingTranscript","IiosMeetingSummary","IiosMeetingParticipant","IiosMeeting","IiosMeetingRequest",
|
"IiosMeetingActionItem","IiosActionItem","IiosTranscriptSegment","IiosMeetingTranscript","IiosMeetingSummary","IiosMeetingParticipant","IiosMeeting","IiosMeetingRequest",
|
||||||
"IiosCalendarSyncCursor","IiosCalendarEvent","IiosCalendarProviderAccount","IiosAvailabilityWindow",
|
"IiosCalendarSyncCursor","IiosCalendarEvent","IiosCalendarProviderAccount","IiosAvailabilityWindow",
|
||||||
"IiosAiEvidenceLink","IiosAiClaim","IiosAiToolCall","IiosAiArtifact","IiosAiModelRun","IiosAiJob","IiosEmbeddingRef",
|
"IiosAiEvidenceLink","IiosAiClaim","IiosAiToolCall","IiosAiArtifact","IiosAiModelRun","IiosAiJob","IiosEmbeddingRef",
|
||||||
|
|||||||
Reference in New Issue
Block a user