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:
@@ -146,6 +146,61 @@ describe('Governed membership + replies (v1.1, policy-enforced)', () => {
|
||||
expect(await prisma.iiosThreadParticipant.count({ where: { threadId } })).toBe(1);
|
||||
});
|
||||
|
||||
it('roster reads are membership-governed: a non-member cannot list a private channel’s 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 () => {
|
||||
const s = gov();
|
||||
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
|
||||
|
||||
Reference in New Issue
Block a user