Skip to content

Commit 07905fd

Browse files
committed
fix(msteams): require admin for group actions
1 parent 8b84e95 commit 07905fd

2 files changed

Lines changed: 194 additions & 0 deletions

File tree

extensions/msteams/src/channel.actions.test.ts

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
44
import { msteamsPlugin } from "./channel.js";
55

66
const {
7+
addParticipantMSTeamsMock,
78
editMessageMSTeamsMock,
89
deleteMessageMSTeamsMock,
910
getChannelInfoMSTeamsMock,
@@ -13,11 +14,14 @@ const {
1314
listReactionsMSTeamsMock,
1415
pinMessageMSTeamsMock,
1516
reactMessageMSTeamsMock,
17+
removeParticipantMSTeamsMock,
18+
renameGroupMSTeamsMock,
1619
searchMessagesMSTeamsMock,
1720
sendAdaptiveCardMSTeamsMock,
1821
sendMessageMSTeamsMock,
1922
unpinMessageMSTeamsMock,
2023
} = vi.hoisted(() => ({
24+
addParticipantMSTeamsMock: vi.fn(),
2125
editMessageMSTeamsMock: vi.fn(),
2226
deleteMessageMSTeamsMock: vi.fn(),
2327
getChannelInfoMSTeamsMock: vi.fn(),
@@ -27,6 +31,8 @@ const {
2731
listReactionsMSTeamsMock: vi.fn(),
2832
pinMessageMSTeamsMock: vi.fn(),
2933
reactMessageMSTeamsMock: vi.fn(),
34+
removeParticipantMSTeamsMock: vi.fn(),
35+
renameGroupMSTeamsMock: vi.fn(),
3036
searchMessagesMSTeamsMock: vi.fn(),
3137
sendAdaptiveCardMSTeamsMock: vi.fn(),
3238
sendMessageMSTeamsMock: vi.fn(),
@@ -35,6 +41,7 @@ const {
3541

3642
vi.mock("./channel.runtime.js", () => ({
3743
msTeamsChannelRuntime: {
44+
addParticipantMSTeams: addParticipantMSTeamsMock,
3845
editMessageMSTeams: editMessageMSTeamsMock,
3946
deleteMessageMSTeams: deleteMessageMSTeamsMock,
4047
getChannelInfoMSTeams: getChannelInfoMSTeamsMock,
@@ -44,6 +51,8 @@ vi.mock("./channel.runtime.js", () => ({
4451
listReactionsMSTeams: listReactionsMSTeamsMock,
4552
pinMessageMSTeams: pinMessageMSTeamsMock,
4653
reactMessageMSTeams: reactMessageMSTeamsMock,
54+
removeParticipantMSTeams: removeParticipantMSTeamsMock,
55+
renameGroupMSTeams: renameGroupMSTeamsMock,
4756
searchMessagesMSTeams: searchMessagesMSTeamsMock,
4857
sendAdaptiveCardMSTeams: sendAdaptiveCardMSTeamsMock,
4958
sendMessageMSTeams: sendMessageMSTeamsMock,
@@ -52,6 +61,7 @@ vi.mock("./channel.runtime.js", () => ({
5261
}));
5362

5463
const actionMocks = [
64+
addParticipantMSTeamsMock,
5565
editMessageMSTeamsMock,
5666
deleteMessageMSTeamsMock,
5767
getChannelInfoMSTeamsMock,
@@ -61,6 +71,8 @@ const actionMocks = [
6171
listReactionsMSTeamsMock,
6272
pinMessageMSTeamsMock,
6373
reactMessageMSTeamsMock,
74+
removeParticipantMSTeamsMock,
75+
renameGroupMSTeamsMock,
6476
searchMessagesMSTeamsMock,
6577
sendAdaptiveCardMSTeamsMock,
6678
sendMessageMSTeamsMock,
@@ -82,6 +94,8 @@ const reactMissingEmojiError =
8294
"React requires an emoji (reaction type). Valid types: like, heart, laugh, surprised, sad, angry.";
8395
const reactMissingEmojiDetail = "React requires an emoji (reaction type).";
8496
const searchMissingQueryError = "Search requires a target (to) and query.";
97+
const groupManagementAuthError =
98+
"Microsoft Teams group management requires an owner or operator.admin requester.";
8599

86100
function padded(value: string) {
87101
return ` ${value} `;
@@ -114,6 +128,9 @@ async function runAction(params: {
114128
toolContext?: Record<string, unknown>;
115129
mediaLocalRoots?: readonly string[];
116130
mediaReadFile?: (filePath: string) => Promise<Buffer>;
131+
requesterSenderId?: string | null;
132+
senderIsOwner?: boolean;
133+
gatewayClientScopes?: readonly string[];
117134
}) {
118135
const handleAction = requireMSTeamsHandleAction();
119136
return await handleAction({
@@ -124,6 +141,9 @@ async function runAction(params: {
124141
mediaLocalRoots: params.mediaLocalRoots,
125142
mediaReadFile: params.mediaReadFile,
126143
toolContext: params.toolContext,
144+
requesterSenderId: params.requesterSenderId,
145+
senderIsOwner: params.senderIsOwner,
146+
gatewayClientScopes: params.gatewayClientScopes,
127147
} as Parameters<ReturnType<typeof requireMSTeamsHandleAction>>[0]);
128148
}
129149

@@ -182,6 +202,9 @@ async function expectSuccessfulAction(params: {
182202
toolContext?: Parameters<typeof runAction>[0]["toolContext"];
183203
mediaLocalRoots?: Parameters<typeof runAction>[0]["mediaLocalRoots"];
184204
mediaReadFile?: Parameters<typeof runAction>[0]["mediaReadFile"];
205+
requesterSenderId?: Parameters<typeof runAction>[0]["requesterSenderId"];
206+
senderIsOwner?: Parameters<typeof runAction>[0]["senderIsOwner"];
207+
gatewayClientScopes?: Parameters<typeof runAction>[0]["gatewayClientScopes"];
185208
runtimeParams: Record<string, unknown>;
186209
details: Record<string, unknown>;
187210
contentDetails?: Record<string, unknown>;
@@ -193,6 +216,9 @@ async function expectSuccessfulAction(params: {
193216
mediaLocalRoots: params.mediaLocalRoots,
194217
mediaReadFile: params.mediaReadFile,
195218
toolContext: params.toolContext,
219+
requesterSenderId: params.requesterSenderId,
220+
senderIsOwner: params.senderIsOwner,
221+
gatewayClientScopes: params.gatewayClientScopes,
196222
});
197223
expectActionRuntimeCall(params.mockFn, params.runtimeParams);
198224
expectActionSuccess(result, params.details, params.contentDetails);
@@ -351,6 +377,147 @@ describe("msteamsPlugin message actions", () => {
351377
});
352378
});
353379

380+
it("requires trusted requester sender for Teams group-management actions from Teams turns", () => {
381+
const requiresTrustedRequesterSender = msteamsPlugin.actions?.requiresTrustedRequesterSender;
382+
if (!requiresTrustedRequesterSender) {
383+
throw new Error("msteams actions.requiresTrustedRequesterSender unavailable");
384+
}
385+
386+
for (const action of ["addParticipant", "removeParticipant", "renameGroup"] as const) {
387+
expect(
388+
requiresTrustedRequesterSender({
389+
action,
390+
toolContext: { currentChannelProvider: "msteams" },
391+
}),
392+
).toBe(true);
393+
}
394+
expect(
395+
requiresTrustedRequesterSender({
396+
action: "addParticipant",
397+
toolContext: { currentChannelProvider: "discord" },
398+
}),
399+
).toBe(false);
400+
expect(
401+
requiresTrustedRequesterSender({
402+
action: "read",
403+
toolContext: { currentChannelProvider: "msteams" },
404+
}),
405+
).toBe(false);
406+
});
407+
408+
it("rejects group-management actions from non-owner non-admin callers", async () => {
409+
const cases = [
410+
{
411+
action: "addParticipant",
412+
mockFn: addParticipantMSTeamsMock,
413+
params: { target: targetChannelId, userId: "user-1" },
414+
},
415+
{
416+
action: "removeParticipant",
417+
mockFn: removeParticipantMSTeamsMock,
418+
params: { target: targetChannelId, userId: "user-1" },
419+
},
420+
{
421+
action: "renameGroup",
422+
mockFn: renameGroupMSTeamsMock,
423+
params: { target: targetChannelId, name: "Renamed group" },
424+
},
425+
] as const;
426+
427+
for (const testCase of cases) {
428+
await expectActionError(
429+
{
430+
action: testCase.action,
431+
params: testCase.params,
432+
senderIsOwner: false,
433+
gatewayClientScopes: ["operator.write"],
434+
},
435+
groupManagementAuthError,
436+
);
437+
expect(testCase.mockFn).not.toHaveBeenCalled();
438+
}
439+
});
440+
441+
it("allows owner-authorized group-management actions", async () => {
442+
await expectSuccessfulAction({
443+
mockFn: addParticipantMSTeamsMock,
444+
mockResult: { added: { userId: "user-1", chatId: targetChannelId } },
445+
action: "addParticipant",
446+
actionParams: {
447+
target: targetChannelId,
448+
userId: " user-1 ",
449+
role: " owner ",
450+
},
451+
senderIsOwner: true,
452+
runtimeParams: {
453+
to: targetChannelId,
454+
userId: "user-1",
455+
role: "owner",
456+
},
457+
details: okMSTeamsActionDetails("addParticipant", {
458+
added: { userId: "user-1", chatId: targetChannelId },
459+
}),
460+
contentDetails: {
461+
ok: true,
462+
channel: "msteams",
463+
action: "addParticipant",
464+
added: { userId: "user-1", chatId: targetChannelId },
465+
},
466+
});
467+
});
468+
469+
it("allows operator.admin group-management actions without owner sender status", async () => {
470+
await expectSuccessfulAction({
471+
mockFn: removeParticipantMSTeamsMock,
472+
mockResult: { removed: { userId: "user-1", chatId: targetChannelId } },
473+
action: "removeParticipant",
474+
actionParams: {
475+
target: targetChannelId,
476+
userId: " user-1 ",
477+
},
478+
senderIsOwner: false,
479+
gatewayClientScopes: ["operator.admin"],
480+
runtimeParams: {
481+
to: targetChannelId,
482+
userId: "user-1",
483+
},
484+
details: okMSTeamsActionDetails("removeParticipant", {
485+
removed: { userId: "user-1", chatId: targetChannelId },
486+
}),
487+
contentDetails: {
488+
ok: true,
489+
channel: "msteams",
490+
action: "removeParticipant",
491+
removed: { userId: "user-1", chatId: targetChannelId },
492+
},
493+
});
494+
495+
await expectSuccessfulAction({
496+
mockFn: renameGroupMSTeamsMock,
497+
mockResult: { renamed: { chatId: targetChannelId, newName: "Renamed group" } },
498+
action: "renameGroup",
499+
actionParams: {
500+
target: targetChannelId,
501+
name: " Renamed group ",
502+
},
503+
senderIsOwner: false,
504+
gatewayClientScopes: ["operator.admin"],
505+
runtimeParams: {
506+
to: targetChannelId,
507+
name: "Renamed group",
508+
},
509+
details: okMSTeamsActionDetails("renameGroup", {
510+
renamed: { chatId: targetChannelId, newName: "Renamed group" },
511+
}),
512+
contentDetails: {
513+
ok: true,
514+
channel: "msteams",
515+
action: "renameGroup",
516+
renamed: { chatId: targetChannelId, newName: "Renamed group" },
517+
},
518+
});
519+
});
520+
354521
it("accepts target as an alias for pin actions", async () => {
355522
await expectSuccessfulAction({
356523
mockFn: pinMessageMSTeamsMock,

extensions/msteams/src/channel.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,12 @@ const TEAMS_GRAPH_PERMISSION_HINTS: Record<string, string> = {
8989
"Files.Read.All": "files (OneDrive)",
9090
};
9191

92+
const MSTEAMS_GROUP_MANAGEMENT_ACTIONS = new Set<ChannelMessageActionName>([
93+
"addParticipant",
94+
"removeParticipant",
95+
"renameGroup",
96+
]);
97+
9298
const collectMSTeamsSecurityWarnings = createAllowlistProviderGroupPolicyWarningCollector<{
9399
cfg: OpenClawConfig;
94100
}>({
@@ -178,6 +184,18 @@ function actionError(message: string) {
178184
};
179185
}
180186

187+
function requireMSTeamsGroupManagementAuthorization(ctx: {
188+
senderIsOwner?: boolean;
189+
gatewayClientScopes?: readonly string[];
190+
}): ReturnType<typeof actionError> | null {
191+
if (ctx.senderIsOwner === true || ctx.gatewayClientScopes?.includes("operator.admin")) {
192+
return null;
193+
}
194+
return actionError(
195+
"Microsoft Teams group management requires an owner or operator.admin requester.",
196+
);
197+
}
198+
181199
function resolveActionTarget(
182200
params: Record<string, unknown>,
183201
currentChannelId?: string | null,
@@ -690,7 +708,16 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsRe
690708
},
691709
actions: {
692710
describeMessageTool: describeMSTeamsMessageTool,
711+
requiresTrustedRequesterSender: ({ action, toolContext }) =>
712+
normalizeOptionalString(toolContext?.currentChannelProvider)?.toLowerCase() ===
713+
"msteams" && MSTEAMS_GROUP_MANAGEMENT_ACTIONS.has(action),
693714
handleAction: async (ctx) => {
715+
if (MSTEAMS_GROUP_MANAGEMENT_ACTIONS.has(ctx.action)) {
716+
const authError = requireMSTeamsGroupManagementAuthorization(ctx);
717+
if (authError) {
718+
return authError;
719+
}
720+
}
694721
const presentation =
695722
ctx.action === "send"
696723
? normalizeMessagePresentation(ctx.params.presentation)

0 commit comments

Comments
 (0)