feat(p9): fence AI + calendar + message by-id leaks (tenant scope)
Task T3.2: AiJobService.getArtifact/accept/reject assertOwns on artifact.job.scopeId; listArtifacts scoped to caller. CalendarService.getMeeting/setConsent/generateTranscript/ summarize/listActionItems assertOwns on meeting.scopeId (optional principal → asserts when supplied by a controller, skipped for internal calls). MessageService.getMessageById gains the same optional fence. Controllers thread the principal through. All 46 ai/calendar/routing/messaging tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,14 +27,12 @@ export class AiController {
|
|||||||
@Query('status') status?: string,
|
@Query('status') status?: string,
|
||||||
@Headers('authorization') auth?: string,
|
@Headers('authorization') auth?: string,
|
||||||
) {
|
) {
|
||||||
this.principal(auth);
|
return this.ai.listArtifacts(this.principal(auth), { interactionId, status });
|
||||||
return this.ai.listArtifacts({ interactionId, status });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('artifacts/:id')
|
@Get('artifacts/:id')
|
||||||
async getArtifact(@Param('id') id: string, @Headers('authorization') auth?: string) {
|
async getArtifact(@Param('id') id: string, @Headers('authorization') auth?: string) {
|
||||||
this.principal(auth);
|
return this.ai.getArtifact(id, this.principal(auth));
|
||||||
return this.ai.getArtifact(id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('artifacts/:id/accept')
|
@Post('artifacts/:id/accept')
|
||||||
|
|||||||
@@ -166,11 +166,13 @@ export class AiJobService {
|
|||||||
return { job, artifact };
|
return { job, artifact };
|
||||||
}
|
}
|
||||||
|
|
||||||
async listArtifacts(opts: { interactionId?: string; status?: string } = {}) {
|
async listArtifacts(principal: MessagePrincipal, opts: { interactionId?: string; status?: string } = {}) {
|
||||||
|
const scope = await this.actors.findScope(principal);
|
||||||
|
if (!scope) return [];
|
||||||
return this.prisma.iiosAiArtifact.findMany({
|
return this.prisma.iiosAiArtifact.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
job: { scopeId: scope.id, ...(opts.interactionId ? { interactionId: opts.interactionId } : {}) },
|
||||||
...(opts.status ? { status: opts.status as Prisma.EnumIiosAiArtifactStatusFilter } : {}),
|
...(opts.status ? { status: opts.status as Prisma.EnumIiosAiArtifactStatusFilter } : {}),
|
||||||
...(opts.interactionId ? { job: { interactionId: opts.interactionId } } : {}),
|
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 100,
|
take: 100,
|
||||||
@@ -178,12 +180,13 @@ export class AiJobService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getArtifact(id: string) {
|
async getArtifact(id: string, principal: MessagePrincipal) {
|
||||||
const artifact = await this.prisma.iiosAiArtifact.findUnique({
|
const artifact = await this.prisma.iiosAiArtifact.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: { claims: true, evidence: true, modelRun: true, job: true },
|
include: { claims: true, evidence: true, modelRun: true, job: true },
|
||||||
});
|
});
|
||||||
if (!artifact) throw new NotFoundException('artifact not found');
|
if (!artifact) throw new NotFoundException('artifact not found');
|
||||||
|
await this.actors.assertOwns(principal, artifact.job.scopeId); // tenant fence (KG-02)
|
||||||
return artifact;
|
return artifact;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,6 +194,7 @@ export class AiJobService {
|
|||||||
async accept(artifactId: string, principal: MessagePrincipal) {
|
async accept(artifactId: string, principal: MessagePrincipal) {
|
||||||
const artifact = await this.prisma.iiosAiArtifact.findUnique({ where: { id: artifactId }, include: { job: true } });
|
const artifact = await this.prisma.iiosAiArtifact.findUnique({ where: { id: artifactId }, include: { job: true } });
|
||||||
if (!artifact) throw new NotFoundException('artifact not found');
|
if (!artifact) throw new NotFoundException('artifact not found');
|
||||||
|
await this.actors.assertOwns(principal, artifact.job.scopeId); // tenant fence (KG-02)
|
||||||
const actor = await this.actors.resolveActor(artifact.job.scopeId, principal);
|
const actor = await this.actors.resolveActor(artifact.job.scopeId, principal);
|
||||||
const updated = await this.prisma.iiosAiArtifact.update({
|
const updated = await this.prisma.iiosAiArtifact.update({
|
||||||
where: { id: artifactId },
|
where: { id: artifactId },
|
||||||
@@ -207,6 +211,7 @@ export class AiJobService {
|
|||||||
async reject(artifactId: string, principal: MessagePrincipal) {
|
async reject(artifactId: string, principal: MessagePrincipal) {
|
||||||
const artifact = await this.prisma.iiosAiArtifact.findUnique({ where: { id: artifactId }, include: { job: true } });
|
const artifact = await this.prisma.iiosAiArtifact.findUnique({ where: { id: artifactId }, include: { job: true } });
|
||||||
if (!artifact) throw new NotFoundException('artifact not found');
|
if (!artifact) throw new NotFoundException('artifact not found');
|
||||||
|
await this.actors.assertOwns(principal, artifact.job.scopeId); // tenant fence (KG-02)
|
||||||
const actor = await this.actors.resolveActor(artifact.job.scopeId, principal);
|
const actor = await this.actors.resolveActor(artifact.job.scopeId, principal);
|
||||||
const updated = await this.prisma.iiosAiArtifact.update({
|
const updated = await this.prisma.iiosAiArtifact.update({
|
||||||
where: { id: artifactId },
|
where: { id: artifactId },
|
||||||
|
|||||||
@@ -44,14 +44,12 @@ export class CalendarController {
|
|||||||
|
|
||||||
@Get('meetings/:id')
|
@Get('meetings/:id')
|
||||||
async get(@Param('id') id: string, @Headers('authorization') auth?: string) {
|
async get(@Param('id') id: string, @Headers('authorization') auth?: string) {
|
||||||
this.principal(auth);
|
return this.calendar.getMeeting(id, this.principal(auth));
|
||||||
return this.calendar.getMeeting(id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('meetings/:id/consent')
|
@Post('meetings/:id/consent')
|
||||||
async consent(@Param('id') id: string, @Body() body: ConsentDto, @Headers('authorization') auth?: string) {
|
async consent(@Param('id') id: string, @Body() body: ConsentDto, @Headers('authorization') auth?: string) {
|
||||||
this.principal(auth);
|
return this.calendar.setConsent(id, body.actorRefId, body.status as IiosConsentStatus, this.principal(auth));
|
||||||
return this.calendar.setConsent(id, body.actorRefId, body.status as IiosConsentStatus);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('meetings/:id/transcript')
|
@Post('meetings/:id/transcript')
|
||||||
@@ -66,8 +64,7 @@ export class CalendarController {
|
|||||||
|
|
||||||
@Get('meetings/:id/action-items')
|
@Get('meetings/:id/action-items')
|
||||||
async actionItems(@Param('id') id: string, @Headers('authorization') auth?: string) {
|
async actionItems(@Param('id') id: string, @Headers('authorization') auth?: string) {
|
||||||
this.principal(auth);
|
return this.calendar.listActionItems(id, this.principal(auth));
|
||||||
return this.calendar.listActionItems(id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('providers')
|
@Post('providers')
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ export class CalendarService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMeeting(id: string) {
|
async getMeeting(id: string, principal?: MessagePrincipal) {
|
||||||
const meeting = await this.prisma.iiosMeeting.findUnique({
|
const meeting = await this.prisma.iiosMeeting.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
@@ -231,11 +231,17 @@ export class CalendarService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!meeting) throw new NotFoundException('meeting not found');
|
if (!meeting) throw new NotFoundException('meeting not found');
|
||||||
|
if (principal) await this.actors.assertOwns(principal, meeting.scopeId); // tenant fence (KG-02)
|
||||||
return meeting;
|
return meeting;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Consent + transcript + summary (the KG-14 safety spine) ──────
|
// ── Consent + transcript + summary (the KG-14 safety spine) ──────
|
||||||
async setConsent(meetingId: string, actorRefId: string, status: IiosConsentStatus) {
|
async setConsent(meetingId: string, actorRefId: string, status: IiosConsentStatus, principal?: MessagePrincipal) {
|
||||||
|
if (principal) {
|
||||||
|
const meeting = await this.prisma.iiosMeeting.findUnique({ where: { id: meetingId } });
|
||||||
|
if (!meeting) throw new NotFoundException('meeting not found');
|
||||||
|
await this.actors.assertOwns(principal, meeting.scopeId);
|
||||||
|
}
|
||||||
return this.prisma.iiosMeetingParticipant.update({
|
return this.prisma.iiosMeetingParticipant.update({
|
||||||
where: { meetingId_actorRefId: { meetingId, actorRefId } },
|
where: { meetingId_actorRefId: { meetingId, actorRefId } },
|
||||||
data: { recordingConsent: status },
|
data: { recordingConsent: status },
|
||||||
@@ -246,9 +252,10 @@ export class CalendarService {
|
|||||||
* Create the transcript ONLY if every attendee consented + CMP allows; otherwise
|
* Create the transcript ONLY if every attendee consented + CMP allows; otherwise
|
||||||
* a BLOCKED transcript with no segments (fail-closed, KG-14). Idempotent per meeting.
|
* a BLOCKED transcript with no segments (fail-closed, KG-14). Idempotent per meeting.
|
||||||
*/
|
*/
|
||||||
async generateTranscript(meetingId: string, _principal: MessagePrincipal) {
|
async generateTranscript(meetingId: string, principal?: MessagePrincipal) {
|
||||||
const meeting = await this.prisma.iiosMeeting.findUnique({ where: { id: meetingId }, include: { participants: true } });
|
const meeting = await this.prisma.iiosMeeting.findUnique({ where: { id: meetingId }, include: { participants: true } });
|
||||||
if (!meeting) throw new NotFoundException('meeting not found');
|
if (!meeting) throw new NotFoundException('meeting not found');
|
||||||
|
if (principal) await this.actors.assertOwns(principal, meeting.scopeId); // tenant fence (KG-02)
|
||||||
|
|
||||||
const existing = await this.prisma.iiosMeetingTranscript.findFirst({ where: { meetingId }, include: { segments: true } });
|
const existing = await this.prisma.iiosMeetingTranscript.findFirst({ where: { meetingId }, include: { segments: true } });
|
||||||
if (existing && existing.status === 'READY') return existing;
|
if (existing && existing.status === 'READY') return existing;
|
||||||
@@ -288,12 +295,13 @@ export class CalendarService {
|
|||||||
* revoked consent blocks summarization even if a transcript already exists.
|
* revoked consent blocks summarization even if a transcript already exists.
|
||||||
* Attendees with visibility=NONE are excluded from inbox exposure (Scenario 6).
|
* Attendees with visibility=NONE are excluded from inbox exposure (Scenario 6).
|
||||||
*/
|
*/
|
||||||
async summarize(meetingId: string, _principal: MessagePrincipal) {
|
async summarize(meetingId: string, principal?: MessagePrincipal) {
|
||||||
const meeting = await this.prisma.iiosMeeting.findUnique({
|
const meeting = await this.prisma.iiosMeeting.findUnique({
|
||||||
where: { id: meetingId },
|
where: { id: meetingId },
|
||||||
include: { participants: true, transcripts: { include: { segments: true } } },
|
include: { participants: true, transcripts: { include: { segments: true } } },
|
||||||
});
|
});
|
||||||
if (!meeting) throw new NotFoundException('meeting not found');
|
if (!meeting) throw new NotFoundException('meeting not found');
|
||||||
|
if (principal) await this.actors.assertOwns(principal, meeting.scopeId); // tenant fence (KG-02)
|
||||||
|
|
||||||
const verdict = await checkMeetingConsent(this.ports, meetingId, meeting.participants);
|
const verdict = await checkMeetingConsent(this.ports, meetingId, meeting.participants);
|
||||||
if (!verdict.allowed) throw new BadRequestException(`recording consent not satisfied: ${verdict.reason}`);
|
if (!verdict.allowed) throw new BadRequestException(`recording consent not satisfied: ${verdict.reason}`);
|
||||||
@@ -355,7 +363,12 @@ export class CalendarService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async listActionItems(meetingId: string) {
|
async listActionItems(meetingId: string, principal?: MessagePrincipal) {
|
||||||
|
if (principal) {
|
||||||
|
const meeting = await this.prisma.iiosMeeting.findUnique({ where: { id: meetingId } });
|
||||||
|
if (!meeting) throw new NotFoundException('meeting not found');
|
||||||
|
await this.actors.assertOwns(principal, meeting.scopeId);
|
||||||
|
}
|
||||||
return this.prisma.iiosMeetingActionItem.findMany({ where: { meetingId }, include: { actionItem: true } });
|
return this.prisma.iiosMeetingActionItem.findMany({ where: { meetingId }, include: { actionItem: true } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -217,12 +217,13 @@ export class MessageService {
|
|||||||
return { interactionId, actorId: actor.id };
|
return { interactionId, actorId: actor.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMessageById(id: string): Promise<MessageDto | null> {
|
async getMessageById(id: string, principal?: MessagePrincipal): Promise<MessageDto | null> {
|
||||||
const i = await this.prisma.iiosInteraction.findUnique({
|
const i = await this.prisma.iiosInteraction.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: { parts: { orderBy: { partIndex: 'asc' } } },
|
include: { parts: { orderBy: { partIndex: 'asc' } } },
|
||||||
});
|
});
|
||||||
if (!i || !i.threadId) return null;
|
if (!i || !i.threadId) return null;
|
||||||
|
if (principal) await this.actors.assertOwns(principal, i.scopeId); // tenant fence (KG-02) when called on behalf of a caller
|
||||||
return this.toDto(i, i.threadId);
|
return this.toDto(i, i.threadId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user