feat(threads): govern roster reads + add a bulk participant primitive

Phase A of channel-roster import (add everyone from another channel).

SECURITY: listParticipants was gated by iios.thread.read, which has no case in
DevOpaPort and so fell through to default-allow — any caller in a scope could
enumerate ANY thread's members, including private channels they aren't in. Since
PlatformModule binds LocalDevPorts unconditionally (no real OPA adapter exists),
that was live. Adds iios.thread.participant.list: members only, public channels
exempt, ungoverned threads unchanged. This is also Zoom's rule for this feature
('you must be a member of the channel to invite all of its members').

Adds MessageService.addParticipants(): many users in ONE governed call, capped at
MAX_BULK_PARTICIPANTS (200), idempotent (already-members are 'skipped'), and
deliberately non-atomic so one unresolvable user can't sink an import — outcome
is reported per user as {added, skipped, failed}. The dm two-person cap now reads
targetCount so it holds for a batch, not just one add at a time. New REST route
POST /v1/threads/:id/participants/bulk.

The kernel stays generic: it takes an explicit userId list and never learns where
that list came from — chat meaning lives only in the policy plane.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 16:05:14 +05:30
parent 4f9a252118
commit 0336621c01
5 changed files with 191 additions and 3 deletions
@@ -1,5 +1,5 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { Inject, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { CloudEvent, IIOS_EVENTS, IiosPlatformPorts } from '@insignia/iios-contracts'; import { CloudEvent, IIOS_EVENTS, IiosPlatformPorts } from '@insignia/iios-contracts';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
@@ -74,6 +74,10 @@ export interface OpenThreadResult {
*/ */
@Injectable() @Injectable()
export class MessageService { export class MessageService {
/** Ceiling on one bulk participant import — bounds the work per request and the blast radius of a
* mistaken import. Raise here if a larger roster copy is ever needed. */
static readonly MAX_BULK_PARTICIPANTS = 200;
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
@Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts, @Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts,
@@ -167,6 +171,76 @@ export class MessageService {
return { threadId, participantCount: participantCount + 1 }; return { threadId, participantCount: participantCount + 1 };
} }
/**
* Bulk sibling of {@link addParticipant}: add many users in ONE governed operation. The app uses
* this to import a roster (e.g. "add everyone from that channel"); the kernel stays generic — it
* receives an explicit list of userIds and never learns where the list came from.
*
* Deliberately NOT atomic: a single unresolvable user must not sink the whole import, so each is
* attempted independently and the outcome is reported per user. Already-members are `skipped`,
* which makes a re-run a no-op.
*/
async addParticipants(
threadId: string,
principal: MessagePrincipal,
targetUserIds: string[],
role = 'MEMBER',
): Promise<{ threadId: string; added: string[]; skipped: string[]; failed: string[]; participantCount: number }> {
const unique = [...new Set(targetUserIds.map((u) => u.trim()).filter(Boolean))];
if (unique.length === 0) throw new BadRequestException('at least one userId is required');
if (unique.length > MessageService.MAX_BULK_PARTICIPANTS) {
throw new BadRequestException(`at most ${MessageService.MAX_BULK_PARTICIPANTS} participants can be added at once`);
}
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
if (!thread) throw new NotFoundException('thread not found');
const caller = await this.actors.resolveActor(thread.scopeId, principal);
const callerP = await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: caller.id } } });
const participantCount = await this.prisma.iiosThreadParticipant.count({ where: { threadId } });
const membership = (thread.metadata as { membership?: string } | null)?.membership;
// ONE decision for the whole batch — `targetCount` lets policy reason about the resulting size
// (e.g. the dm two-person cap) instead of being asked the same question N times.
await decideOrThrow(this.ports, {
action: 'iios.thread.participant.add',
threadId,
scopeId: thread.scopeId,
membership,
participantCount,
callerRole: callerP?.participantRole,
targetCount: unique.length,
role,
});
const scope = await this.actors.resolveScope(principal);
const added: string[] = [];
const skipped: string[] = [];
const failed: string[] = [];
for (const targetUserId of unique) {
try {
const target = await this.actors.resolveActor(scope.id, {
userId: targetUserId,
appId: principal.appId,
orgId: principal.orgId,
tenantId: principal.tenantId,
displayName: targetUserId,
});
const existing = await this.prisma.iiosThreadParticipant.findUnique({
where: { threadId_actorId: { threadId, actorId: target.id } },
});
if (existing) {
skipped.push(targetUserId);
continue;
}
await this.actors.ensureParticipant(threadId, target.id, role);
added.push(targetUserId);
} catch {
failed.push(targetUserId);
}
}
return { threadId, added, skipped, failed, participantCount: participantCount + added.length };
}
/** /**
* Governed thread rename (a generic subject update). Policy decides who may rename — for a * Governed thread rename (a generic subject update). Policy decides who may rename — for a
* membership thread the dev OPA requires the caller be a group ADMIN. The kernel only writes * membership thread the dev OPA requires the caller be a group ADMIN. The kernel only writes
@@ -224,7 +298,19 @@ export class MessageService {
async listParticipants(threadId: string, principal: MessagePrincipal): Promise<Array<{ userId: string; displayName: string; role: string }>> { async listParticipants(threadId: string, principal: MessagePrincipal): Promise<Array<{ userId: string; displayName: string; role: string }>> {
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } }); const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
if (!thread) throw new NotFoundException('thread not found'); if (!thread) throw new NotFoundException('thread not found');
await decideOrThrow(this.ports, { action: 'iios.thread.read', threadId, scopeId: thread.scopeId }); // Roster reads are membership-governed (a private channel's members must not be enumerable by
// a non-member), so the decision carries the caller's role + the thread's opaque attributes.
const caller = await this.actors.resolveActor(thread.scopeId, principal);
const callerP = await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: caller.id } } });
const meta = thread.metadata as { membership?: string; visibility?: string } | null;
await decideOrThrow(this.ports, {
action: 'iios.thread.participant.list',
threadId,
scopeId: thread.scopeId,
membership: meta?.membership,
visibility: meta?.visibility,
callerRole: callerP?.participantRole,
});
const parts = await this.prisma.iiosThreadParticipant.findMany({ const parts = await this.prisma.iiosThreadParticipant.findMany({
where: { threadId }, where: { threadId },
include: { actor: { include: { sourceHandle: true } } }, include: { actor: { include: { sourceHandle: true } } },
@@ -146,6 +146,61 @@ describe('Governed membership + replies (v1.1, policy-enforced)', () => {
expect(await prisma.iiosThreadParticipant.count({ where: { threadId } })).toBe(1); expect(await prisma.iiosThreadParticipant.count({ where: { threadId } })).toBe(1);
}); });
it('roster reads are membership-governed: a non-member cannot list a private channels members', async () => {
const s = gov();
const { threadId: priv } = await s.openThread(null, alice, { membership: 'channel', metadata: { visibility: 'private' }, subject: 'deals', creatorRole: 'ADMIN' });
// bob is not a member — he must not be able to enumerate who is.
await expect(s.listParticipants(priv, bob)).rejects.toBeInstanceOf(PolicyDeniedError);
await s.addParticipant(priv, alice, 'bob');
expect((await s.listParticipants(priv, bob)).map((m) => m.userId).sort()).toEqual(['alice', 'bob']);
// a PUBLIC channel's roster stays open (it is discoverable anyway)
const { threadId: pub } = await s.openThread(null, alice, { membership: 'channel', metadata: { visibility: 'public' }, subject: 'general', creatorRole: 'ADMIN' });
expect((await s.listParticipants(pub, bob)).map((m) => m.userId)).toEqual(['alice']);
});
it('addParticipants: bulk-adds in one governed call, skipping existing members (re-run is a no-op)', async () => {
const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'channel', metadata: { visibility: 'private' }, subject: 'ops', creatorRole: 'ADMIN' });
await s.addParticipant(threadId, alice, 'bob'); // bob is already in
const res = await s.addParticipants(threadId, alice, ['bob', 'carol', 'dave']);
expect(res.added.sort()).toEqual(['carol', 'dave']);
expect(res.skipped).toEqual(['bob']);
expect(res.failed).toEqual([]);
expect(res.participantCount).toBe(4); // alice, bob, carol, dave
expect(await prisma.iiosThreadParticipant.count({ where: { threadId } })).toBe(4);
// idempotent: a second identical import adds nobody
const again = await s.addParticipants(threadId, alice, ['bob', 'carol', 'dave']);
expect(again.added).toEqual([]);
expect(again.skipped.sort()).toEqual(['bob', 'carol', 'dave']);
expect(await prisma.iiosThreadParticipant.count({ where: { threadId } })).toBe(4);
});
it('addParticipants is governed by the same policy as a single add (a plain member is denied)', async () => {
const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
await s.addParticipant(threadId, alice, 'bob'); // bob joins as a plain MEMBER
await expect(s.addParticipants(threadId, bob, ['carol', 'dave'])).rejects.toBeInstanceOf(PolicyDeniedError);
});
it('addParticipants rejects an empty list and anything over the batch cap', async () => {
const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'channel', metadata: { visibility: 'private' }, creatorRole: 'ADMIN' });
await expect(s.addParticipants(threadId, alice, [])).rejects.toThrow(/at least one/i);
const tooMany = Array.from({ length: MessageService.MAX_BULK_PARTICIPANTS + 1 }, (_, n) => `user_${n}`);
await expect(s.addParticipants(threadId, alice, tooMany)).rejects.toThrow(/at most/i);
});
it('addParticipants respects the dm two-person cap for the whole batch', async () => {
const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'dm' });
// alice is alone; adding two at once would make three — policy must deny the batch.
await expect(s.addParticipants(threadId, alice, ['bob', 'carol'])).rejects.toBeInstanceOf(PolicyDeniedError);
expect((await s.addParticipants(threadId, alice, ['bob'])).added).toEqual(['bob']);
});
it('self-join is governed: a non-member cannot open a thread by id; after being added, they can', async () => { it('self-join is governed: a non-member cannot open a thread by id; after being added, they can', async () => {
const s = gov(); const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' }); const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
@@ -49,6 +49,29 @@ describe('DevOpaPort (dev policy plane — membership rules)', () => {
expect((await opa.decide({ action: 'iios.thread.participant.remove', callerRole: 'MEMBER' })).allow).toBe(true); expect((await opa.decide({ action: 'iios.thread.participant.remove', callerRole: 'MEMBER' })).allow).toBe(true);
}); });
it('the dm cap counts the whole batch, not one add at a time', async () => {
const add = (participantCount: number, targetCount: number) =>
opa.decide({ action: 'iios.thread.participant.add', membership: 'dm', callerRole: 'MEMBER', participantCount, targetCount });
expect((await add(1, 1)).allow).toBe(true); // 1 + 1 = 2, fine
const batch = await add(1, 2); // 1 + 2 = 3 — must be denied even though count is only 1
expect(batch.allow).toBe(false);
expect(batch.obligations[0]?.reason).toMatch(/two people/);
});
it('listing a roster requires membership, except on a public channel', async () => {
const list = (extra: Record<string, unknown>) => opa.decide({ action: 'iios.thread.participant.list', ...extra });
// a private channel / group / dm: members only
const stranger = await list({ membership: 'channel', visibility: 'private', callerRole: undefined });
expect(stranger.allow).toBe(false);
expect(stranger.obligations[0]?.reason).toMatch(/not a member/);
expect((await list({ membership: 'channel', visibility: 'private', callerRole: 'MEMBER' })).allow).toBe(true);
expect((await list({ membership: 'group', callerRole: 'ADMIN' })).allow).toBe(true);
expect((await list({ membership: 'group', callerRole: undefined })).allow).toBe(false);
// a public channel's roster is open, and ungoverned threads are unchanged
expect((await list({ membership: 'channel', visibility: 'public', callerRole: undefined })).allow).toBe(true);
expect((await list({})).allow).toBe(true);
});
it('media upload allows images + docs (incl. html/markdown/csv), denies unknown types and oversize', async () => { it('media upload allows images + docs (incl. html/markdown/csv), denies unknown types and oversize', async () => {
const up = (mime: string, sizeBytes = 1024) => opa.decide({ action: 'iios.media.upload', mime, sizeBytes }); const up = (mime: string, sizeBytes = 1024) => opa.decide({ action: 'iios.media.upload', mime, sizeBytes });
for (const mime of ['image/png', 'video/mp4', 'audio/mpeg', 'application/pdf', 'text/plain', 'text/markdown', 'text/html', 'text/csv']) { for (const mime of ['image/png', 'video/mp4', 'audio/mpeg', 'application/pdf', 'text/plain', 'text/markdown', 'text/html', 'text/csv']) {
@@ -12,6 +12,7 @@ export interface OpaInput {
membership?: string; // app-set generic thread attribute: 'dm' | 'group' | 'channel' membership?: string; // app-set generic thread attribute: 'dm' | 'group' | 'channel'
visibility?: string; // 'public' | 'private' (channels) visibility?: string; // 'public' | 'private' (channels)
participantCount?: number; participantCount?: number;
targetCount?: number; // how many participants a single add call is inviting (1 for a single add)
callerRole?: string; // 'MEMBER' | 'ADMIN' callerRole?: string; // 'MEMBER' | 'ADMIN'
alreadyMember?: boolean; alreadyMember?: boolean;
isMember?: boolean; isMember?: boolean;
@@ -38,11 +39,23 @@ export class DevOpaPort {
switch (i.action) { switch (i.action) {
case 'iios.thread.participant.add': { case 'iios.thread.participant.add': {
const count = i.participantCount ?? 0; const count = i.participantCount ?? 0;
if (i.membership === 'dm' && count >= 2) return deny('a direct message is limited to two people'); // `targetCount` is how many are being added in this call (1 for a single add), so the dm cap
// holds for a bulk import too instead of being evaluated one-at-a-time.
if (i.membership === 'dm' && count + (i.targetCount ?? 1) > 2) return deny('a direct message is limited to two people');
if (i.callerRole !== 'MEMBER' && i.callerRole !== 'ADMIN') return deny('only a member can add participants'); if (i.callerRole !== 'MEMBER' && i.callerRole !== 'ADMIN') return deny('only a member can add participants');
if (i.membership === 'group' && i.callerRole !== 'ADMIN') return deny('only a group admin can add or remove members'); if (i.membership === 'group' && i.callerRole !== 'ADMIN') return deny('only a group admin can add or remove members');
return allow(); return allow();
} }
case 'iios.thread.participant.list': {
// Reading a thread's ROSTER. A public channel is open (it is discoverable anyway), but every
// other membership thread — dm, group, private channel — requires you to be a member, or any
// caller in the scope could enumerate a private channel's members by id. Ungoverned threads
// (no membership attribute, e.g. support) keep the previous open behaviour.
if (!i.membership) return allow();
if (i.membership === 'channel' && i.visibility === 'public') return allow();
if (i.callerRole === 'MEMBER' || i.callerRole === 'ADMIN') return allow();
return deny('you are not a member of this thread');
}
case 'iios.thread.participant.remove': { case 'iios.thread.participant.remove': {
// Same governance as add: for a group, only an ADMIN removes members. Ungoverned // Same governance as add: for a group, only an ADMIN removes members. Ungoverned
// (no membership attr) threads allow removal by any member. // (no membership attr) threads allow removal by any member.
@@ -70,6 +70,17 @@ export class ThreadsController {
return this.messages.addParticipant(id, this.principal(auth), body.userId, body.role); return this.messages.addParticipant(id, this.principal(auth), body.userId, body.role);
} }
/**
* Governed bulk membership: add many users in one call (e.g. importing another channel's roster).
* Reports per-user outcome `skipped` are already members so a partial result is never silent.
*/
@Post(':id/participants/bulk')
@HttpCode(200)
async addParticipants(@Param('id') id: string, @Body() body: { userIds?: string[]; role?: string }, @Headers('authorization') auth?: string) {
if (!Array.isArray(body?.userIds)) throw new BadRequestException('userIds must be an array');
return this.messages.addParticipants(id, this.principal(auth), body.userIds, body.role);
}
/** Members of a thread with their role — drives the group settings member list. */ /** Members of a thread with their role — drives the group settings member list. */
@Get(':id/participants') @Get(':id/participants')
async listParticipants(@Param('id') id: string, @Headers('authorization') auth?: string) { async listParticipants(@Param('id') id: string, @Headers('authorization') auth?: string) {