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:
2026-07-02 21:51:10 +05:30
parent 063049b724
commit ed0d4dae80
5 changed files with 33 additions and 19 deletions
@@ -44,14 +44,12 @@ export class CalendarController {
@Get('meetings/:id')
async get(@Param('id') id: string, @Headers('authorization') auth?: string) {
this.principal(auth);
return this.calendar.getMeeting(id);
return this.calendar.getMeeting(id, this.principal(auth));
}
@Post('meetings/:id/consent')
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);
return this.calendar.setConsent(id, body.actorRefId, body.status as IiosConsentStatus, this.principal(auth));
}
@Post('meetings/:id/transcript')
@@ -66,8 +64,7 @@ export class CalendarController {
@Get('meetings/:id/action-items')
async actionItems(@Param('id') id: string, @Headers('authorization') auth?: string) {
this.principal(auth);
return this.calendar.listActionItems(id);
return this.calendar.listActionItems(id, this.principal(auth));
}
@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({
where: { id },
include: {
@@ -231,11 +231,17 @@ export class CalendarService {
},
});
if (!meeting) throw new NotFoundException('meeting not found');
if (principal) await this.actors.assertOwns(principal, meeting.scopeId); // tenant fence (KG-02)
return meeting;
}
// ── 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({
where: { meetingId_actorRefId: { meetingId, actorRefId } },
data: { recordingConsent: status },
@@ -246,9 +252,10 @@ export class CalendarService {
* Create the transcript ONLY if every attendee consented + CMP allows; otherwise
* 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 } });
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 } });
if (existing && existing.status === 'READY') return existing;
@@ -288,12 +295,13 @@ export class CalendarService {
* revoked consent blocks summarization even if a transcript already exists.
* 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({
where: { id: meetingId },
include: { participants: true, transcripts: { include: { segments: true } } },
});
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);
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 } });
}