Feat/s3 storage #4
@@ -159,6 +159,75 @@ export class MessageService {
|
|||||||
return { threadId, participantCount: participantCount + 1 };
|
return { threadId, participantCount: participantCount + 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* the subject; "group settings" meaning lives in the app + policy, not here.
|
||||||
|
*/
|
||||||
|
async renameThread(threadId: string, principal: MessagePrincipal, subject: string): Promise<{ threadId: string; subject: string }> {
|
||||||
|
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 membership = (thread.metadata as { membership?: string } | null)?.membership;
|
||||||
|
|
||||||
|
await decideOrThrow(this.ports, {
|
||||||
|
action: 'iios.thread.update',
|
||||||
|
threadId,
|
||||||
|
scopeId: thread.scopeId,
|
||||||
|
membership,
|
||||||
|
callerRole: callerP?.participantRole,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.prisma.iiosThread.update({ where: { id: threadId }, data: { subject } });
|
||||||
|
return { threadId, subject };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Governed participant removal. Policy decides who may remove — for a membership thread the dev
|
||||||
|
* OPA requires the caller be a group ADMIN. Removing a non-participant is a no-op success.
|
||||||
|
*/
|
||||||
|
async removeParticipant(threadId: string, principal: MessagePrincipal, targetUserId: string): Promise<{ threadId: string; participantCount: number }> {
|
||||||
|
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 membership = (thread.metadata as { membership?: string } | null)?.membership;
|
||||||
|
|
||||||
|
await decideOrThrow(this.ports, {
|
||||||
|
action: 'iios.thread.participant.remove',
|
||||||
|
threadId,
|
||||||
|
scopeId: thread.scopeId,
|
||||||
|
membership,
|
||||||
|
callerRole: callerP?.participantRole,
|
||||||
|
targetUserId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const scope = await this.actors.resolveScope(principal);
|
||||||
|
const target = await this.actors.resolveActor(scope.id, {
|
||||||
|
userId: targetUserId, appId: principal.appId, orgId: principal.orgId, tenantId: principal.tenantId, displayName: targetUserId,
|
||||||
|
});
|
||||||
|
await this.prisma.iiosThreadParticipant.deleteMany({ where: { threadId, actorId: target.id } });
|
||||||
|
const participantCount = await this.prisma.iiosThreadParticipant.count({ where: { threadId } });
|
||||||
|
return { threadId, participantCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Members of a thread with their role — drives the group settings member list. Read is policy-scoped. */
|
||||||
|
async listParticipants(threadId: string, principal: MessagePrincipal): Promise<Array<{ userId: string; displayName: string; role: string }>> {
|
||||||
|
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
|
||||||
|
if (!thread) throw new NotFoundException('thread not found');
|
||||||
|
await decideOrThrow(this.ports, { action: 'iios.thread.read', threadId, scopeId: thread.scopeId });
|
||||||
|
const parts = await this.prisma.iiosThreadParticipant.findMany({
|
||||||
|
where: { threadId },
|
||||||
|
include: { actor: { include: { sourceHandle: true } } },
|
||||||
|
});
|
||||||
|
return parts.map((p) => ({
|
||||||
|
userId: p.actor?.sourceHandle?.externalId ?? '',
|
||||||
|
displayName: p.actor?.displayName ?? p.actor?.sourceHandle?.externalId ?? 'unknown',
|
||||||
|
role: p.participantRole ?? 'MEMBER',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/** Toggle the caller's per-thread notification mute flag. */
|
/** Toggle the caller's per-thread notification mute flag. */
|
||||||
async muteThread(threadId: string, principal: MessagePrincipal, muted: boolean): Promise<{ threadId: string; muted: boolean }> {
|
async muteThread(threadId: string, principal: MessagePrincipal, muted: boolean): Promise<{ threadId: string; muted: boolean }> {
|
||||||
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
|
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
|
||||||
|
|||||||
@@ -124,6 +124,28 @@ describe('Governed membership + replies (v1.1, policy-enforced)', () => {
|
|||||||
await expect(s.addParticipant(threadId, bob, 'carol')).rejects.toBeInstanceOf(PolicyDeniedError); // bob is MEMBER
|
await expect(s.addParticipant(threadId, bob, 'carol')).rejects.toBeInstanceOf(PolicyDeniedError); // bob is MEMBER
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('group settings: admin renames + lists members + removes; a plain member cannot rename/remove', async () => {
|
||||||
|
const s = gov();
|
||||||
|
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN', subject: 'Design' });
|
||||||
|
await s.addParticipant(threadId, alice, 'bob');
|
||||||
|
|
||||||
|
// admin renames
|
||||||
|
expect((await s.renameThread(threadId, alice, 'Design Team')).subject).toBe('Design Team');
|
||||||
|
// a plain member cannot rename
|
||||||
|
await expect(s.renameThread(threadId, bob, 'Hacked')).rejects.toBeInstanceOf(PolicyDeniedError);
|
||||||
|
|
||||||
|
// member list carries roles
|
||||||
|
const members = await s.listParticipants(threadId, alice);
|
||||||
|
expect(members.map((m) => m.userId).sort()).toEqual(['alice', 'bob']);
|
||||||
|
expect(members.find((m) => m.userId === 'alice')?.role).toBe('ADMIN');
|
||||||
|
|
||||||
|
// a plain member cannot remove
|
||||||
|
await expect(s.removeParticipant(threadId, bob, 'alice')).rejects.toBeInstanceOf(PolicyDeniedError);
|
||||||
|
// admin removes bob
|
||||||
|
expect((await s.removeParticipant(threadId, alice, 'bob')).participantCount).toBe(1);
|
||||||
|
expect(await prisma.iiosThreadParticipant.count({ where: { threadId } })).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
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' });
|
||||||
|
|||||||
@@ -33,6 +33,22 @@ describe('DevOpaPort (dev policy plane — membership rules)', () => {
|
|||||||
expect(asAdmin.allow).toBe(true);
|
expect(asAdmin.allow).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('group rename (thread.update) requires ADMIN; ungoverned threads allow it', async () => {
|
||||||
|
const asMember = await opa.decide({ action: 'iios.thread.update', membership: 'group', callerRole: 'MEMBER' });
|
||||||
|
expect(asMember.allow).toBe(false);
|
||||||
|
expect(asMember.obligations[0]?.reason).toMatch(/admin/);
|
||||||
|
expect((await opa.decide({ action: 'iios.thread.update', membership: 'group', callerRole: 'ADMIN' })).allow).toBe(true);
|
||||||
|
expect((await opa.decide({ action: 'iios.thread.update' })).allow).toBe(true); // no membership attr → allow
|
||||||
|
});
|
||||||
|
|
||||||
|
it('group remove requires ADMIN; a member on an ungoverned thread may remove', async () => {
|
||||||
|
const asMember = await opa.decide({ action: 'iios.thread.participant.remove', membership: 'group', callerRole: 'MEMBER' });
|
||||||
|
expect(asMember.allow).toBe(false);
|
||||||
|
expect(asMember.obligations[0]?.reason).toMatch(/admin/);
|
||||||
|
expect((await opa.decide({ action: 'iios.thread.participant.remove', membership: 'group', callerRole: 'ADMIN' })).allow).toBe(true);
|
||||||
|
expect((await opa.decide({ action: 'iios.thread.participant.remove', callerRole: 'MEMBER' })).allow).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('self-join is governed only on membership threads', async () => {
|
it('self-join is governed only on membership threads', async () => {
|
||||||
// generic / support thread (no membership attr) → open join, unchanged
|
// generic / support thread (no membership attr) → open join, unchanged
|
||||||
expect((await opa.decide({ action: 'iios.thread.join', alreadyMember: false })).allow).toBe(true);
|
expect((await opa.decide({ action: 'iios.thread.join', alreadyMember: false })).allow).toBe(true);
|
||||||
|
|||||||
@@ -42,6 +42,18 @@ export class DevOpaPort {
|
|||||||
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.remove': {
|
||||||
|
// Same governance as add: for a group, only an ADMIN removes members. Ungoverned
|
||||||
|
// (no membership attr) threads allow removal by any member.
|
||||||
|
if (i.membership === 'group' && i.callerRole !== 'ADMIN') return deny('only a group admin can add or remove members');
|
||||||
|
if (i.membership && i.callerRole !== 'MEMBER' && i.callerRole !== 'ADMIN') return deny('only a member can remove participants');
|
||||||
|
return allow();
|
||||||
|
}
|
||||||
|
case 'iios.thread.update': {
|
||||||
|
// Rename / settings change: for a group, only an ADMIN. Ungoverned threads allow it.
|
||||||
|
if (i.membership === 'group' && i.callerRole !== 'ADMIN') return deny('only a group admin can change group settings');
|
||||||
|
return allow();
|
||||||
|
}
|
||||||
case 'iios.thread.join': {
|
case 'iios.thread.join': {
|
||||||
// Only threads that opted into a membership model (chat dm/group) are governed;
|
// Only threads that opted into a membership model (chat dm/group) are governed;
|
||||||
// generic/support threads (no membership attr) keep open-join. For a governed
|
// generic/support threads (no membership attr) keep open-join. For a governed
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
Headers,
|
Headers,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
Param,
|
Param,
|
||||||
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
@@ -56,6 +58,27 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Members of a thread with their role — drives the group settings member list. */
|
||||||
|
@Get(':id/participants')
|
||||||
|
async listParticipants(@Param('id') id: string, @Headers('authorization') auth?: string) {
|
||||||
|
return this.messages.listParticipants(id, this.principal(auth));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Governed removal: remove a user from a thread — policy enforces group-admin. */
|
||||||
|
@Delete(':id/participants/:userId')
|
||||||
|
@HttpCode(200)
|
||||||
|
async removeParticipant(@Param('id') id: string, @Param('userId') userId: string, @Headers('authorization') auth?: string) {
|
||||||
|
return this.messages.removeParticipant(id, this.principal(auth), userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Governed rename (group settings): change a thread subject — policy enforces group-admin. */
|
||||||
|
@Patch(':id')
|
||||||
|
@HttpCode(200)
|
||||||
|
async updateThread(@Param('id') id: string, @Body() body: { subject?: string }, @Headers('authorization') auth?: string) {
|
||||||
|
if (body?.subject == null) throw new BadRequestException('subject is required');
|
||||||
|
return this.messages.renameThread(id, this.principal(auth), body.subject);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id/messages')
|
@Get(':id/messages')
|
||||||
async listMessages(@Param('id') id: string) {
|
async listMessages(@Param('id') id: string) {
|
||||||
return this.threads.getMessages(id);
|
return this.threads.getMessages(id);
|
||||||
|
|||||||
Reference in New Issue
Block a user