refactor: normalize runtime group sender gating decisions

This commit is contained in:
Peter Steinberger
2026-03-07 21:50:19 +00:00
parent 5eba663c38
commit 27dad962fe
8 changed files with 190 additions and 109 deletions

View File

@@ -1,5 +1,6 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost"; import type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost";
import { import {
evaluateSenderGroupAccessForPolicy,
isDangerousNameMatchingEnabled, isDangerousNameMatchingEnabled,
resolveAllowlistMatchSimple, resolveAllowlistMatchSimple,
resolveControlCommandGate, resolveControlCommandGate,
@@ -231,7 +232,20 @@ export function authorizeMattermostCommandInvocation(params: {
}; };
} }
} else { } else {
if (groupPolicy === "disabled") { const senderGroupAccess = evaluateSenderGroupAccessForPolicy({
groupPolicy,
groupAllowFrom: effectiveGroupAllowFrom,
senderId,
isSenderAllowed: (_senderId, allowFrom) =>
isMattermostSenderAllowed({
senderId,
senderName,
allowFrom,
allowNameMatching,
}),
});
if (!senderGroupAccess.allowed && senderGroupAccess.reason === "disabled") {
return { return {
ok: false, ok: false,
denyReason: "channels-disabled", denyReason: "channels-disabled",
@@ -245,33 +259,32 @@ export function authorizeMattermostCommandInvocation(params: {
}; };
} }
if (groupPolicy === "allowlist") { if (!senderGroupAccess.allowed && senderGroupAccess.reason === "empty_allowlist") {
if (effectiveGroupAllowFrom.length === 0) { return {
return { ok: false,
ok: false, denyReason: "channel-no-allowlist",
denyReason: "channel-no-allowlist", commandAuthorized: false,
commandAuthorized: false, channelInfo,
channelInfo, kind,
kind, chatType,
chatType, channelName,
channelName, channelDisplay,
channelDisplay, roomLabel,
roomLabel, };
}; }
}
if (!groupAllowedForCommands) { if (!senderGroupAccess.allowed && senderGroupAccess.reason === "sender_not_allowlisted") {
return { return {
ok: false, ok: false,
denyReason: "unauthorized", denyReason: "unauthorized",
commandAuthorized: false, commandAuthorized: false,
channelInfo, channelInfo,
kind, kind,
chatType, chatType,
channelName, channelName,
channelDisplay, channelDisplay,
roomLabel, roomLabel,
}; };
}
} }
if (commandGate.shouldBlock) { if (commandGate.shouldBlock) {

View File

@@ -6,6 +6,7 @@ import {
DEFAULT_GROUP_HISTORY_LIMIT, DEFAULT_GROUP_HISTORY_LIMIT,
createScopedPairingAccess, createScopedPairingAccess,
logInboundDrop, logInboundDrop,
evaluateSenderGroupAccessForPolicy,
recordPendingHistoryEntryIfEnabled, recordPendingHistoryEntryIfEnabled,
resolveControlCommandGate, resolveControlCommandGate,
resolveDefaultGroupPolicy, resolveDefaultGroupPolicy,
@@ -230,46 +231,57 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
} }
if (!isDirectMessage && msteamsCfg) { if (!isDirectMessage && msteamsCfg) {
if (groupPolicy === "disabled") { if (channelGate.allowlistConfigured && !channelGate.allowed) {
log.debug?.("dropping group message (not in team/channel allowlist)", {
conversationId,
teamKey: channelGate.teamKey ?? "none",
channelKey: channelGate.channelKey ?? "none",
channelMatchKey: channelGate.channelMatchKey ?? "none",
channelMatchSource: channelGate.channelMatchSource ?? "none",
});
return;
}
const senderGroupAccess = evaluateSenderGroupAccessForPolicy({
groupPolicy,
groupAllowFrom:
effectiveGroupAllowFrom.length > 0 || !channelGate.allowlistConfigured
? effectiveGroupAllowFrom
: ["*"],
senderId,
isSenderAllowed: (_senderId, allowFrom) =>
resolveMSTeamsAllowlistMatch({
allowFrom,
senderId,
senderName,
allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
}).allowed,
});
if (!senderGroupAccess.allowed && senderGroupAccess.reason === "disabled") {
log.debug?.("dropping group message (groupPolicy: disabled)", { log.debug?.("dropping group message (groupPolicy: disabled)", {
conversationId, conversationId,
}); });
return; return;
} }
if (!senderGroupAccess.allowed && senderGroupAccess.reason === "empty_allowlist") {
if (groupPolicy === "allowlist") { log.debug?.("dropping group message (groupPolicy: allowlist, no allowlist)", {
if (channelGate.allowlistConfigured && !channelGate.allowed) { conversationId,
log.debug?.("dropping group message (not in team/channel allowlist)", { });
conversationId, return;
teamKey: channelGate.teamKey ?? "none", }
channelKey: channelGate.channelKey ?? "none", if (!senderGroupAccess.allowed && senderGroupAccess.reason === "sender_not_allowlisted") {
channelMatchKey: channelGate.channelMatchKey ?? "none", const allowMatch = resolveMSTeamsAllowlistMatch({
channelMatchSource: channelGate.channelMatchSource ?? "none", allowFrom: effectiveGroupAllowFrom,
}); senderId,
return; senderName,
} allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
if (effectiveGroupAllowFrom.length === 0 && !channelGate.allowlistConfigured) { });
log.debug?.("dropping group message (groupPolicy: allowlist, no allowlist)", { log.debug?.("dropping group message (not in groupAllowFrom)", {
conversationId, sender: senderId,
}); label: senderName,
return; allowlistMatch: formatAllowlistMatchMeta(allowMatch),
} });
if (effectiveGroupAllowFrom.length > 0 && access.decision !== "allow") { return;
const allowMatch = resolveMSTeamsAllowlistMatch({
allowFrom: effectiveGroupAllowFrom,
senderId,
senderName,
allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
});
if (!allowMatch.allowed) {
log.debug?.("dropping group message (not in groupAllowFrom)", {
sender: senderId,
label: senderName,
allowlistMatch: formatAllowlistMatchMeta(allowMatch),
});
return;
}
}
} }
} }

View File

@@ -30,6 +30,7 @@ import {
readChannelAllowFromStore, readChannelAllowFromStore,
upsertChannelPairingRequest, upsertChannelPairingRequest,
} from "../pairing/pairing-store.js"; } from "../pairing/pairing-store.js";
import { evaluateSenderGroupAccessForPolicy } from "../plugin-sdk/group-access.js";
import { resolveAgentRoute } from "../routing/resolve-route.js"; import { resolveAgentRoute } from "../routing/resolve-route.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { import {
@@ -344,23 +345,31 @@ async function shouldProcessLineEvent(
return denied; return denied;
} }
} }
if (groupPolicy === "disabled") { if (groupPolicy === "allowlist" && !senderId) {
logVerbose("Blocked line group message (no sender ID, groupPolicy: allowlist)");
return denied;
}
const senderGroupAccess = evaluateSenderGroupAccessForPolicy({
groupPolicy,
groupAllowFrom: effectiveGroupAllow.entries,
senderId,
isSenderAllowed: (candidateSenderId, allowFrom) =>
isSenderAllowed({
allow: normalizeAllowFrom(allowFrom),
senderId: candidateSenderId,
}),
});
if (!senderGroupAccess.allowed && senderGroupAccess.reason === "disabled") {
logVerbose("Blocked line group message (groupPolicy: disabled)"); logVerbose("Blocked line group message (groupPolicy: disabled)");
return denied; return denied;
} }
if (groupPolicy === "allowlist") { if (!senderGroupAccess.allowed && senderGroupAccess.reason === "empty_allowlist") {
if (!senderId) { logVerbose("Blocked line group message (groupPolicy: allowlist, no groupAllowFrom)");
logVerbose("Blocked line group message (no sender ID, groupPolicy: allowlist)"); return denied;
return denied; }
} if (!senderGroupAccess.allowed && senderGroupAccess.reason === "sender_not_allowlisted") {
if (!effectiveGroupAllow.hasEntries) { logVerbose(`Blocked line group message from ${senderId} (groupPolicy: allowlist)`);
logVerbose("Blocked line group message (groupPolicy: allowlist, no groupAllowFrom)"); return denied;
return denied;
}
if (!isSenderAllowed({ allow: effectiveGroupAllow, senderId })) {
logVerbose(`Blocked line group message from ${senderId} (groupPolicy: allowlist)`);
return denied;
}
} }
return { return {
allowed: true, allowed: true,

View File

@@ -1,5 +1,33 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { evaluateSenderGroupAccess } from "./group-access.js"; import { evaluateSenderGroupAccess, evaluateSenderGroupAccessForPolicy } from "./group-access.js";
describe("evaluateSenderGroupAccessForPolicy", () => {
it("blocks disabled policy", () => {
const decision = evaluateSenderGroupAccessForPolicy({
groupPolicy: "disabled",
groupAllowFrom: ["123"],
senderId: "123",
isSenderAllowed: () => true,
});
expect(decision).toMatchObject({ allowed: false, reason: "disabled", groupPolicy: "disabled" });
});
it("blocks allowlist with empty list", () => {
const decision = evaluateSenderGroupAccessForPolicy({
groupPolicy: "allowlist",
groupAllowFrom: [],
senderId: "123",
isSenderAllowed: () => true,
});
expect(decision).toMatchObject({
allowed: false,
reason: "empty_allowlist",
groupPolicy: "allowlist",
});
});
});
describe("evaluateSenderGroupAccess", () => { describe("evaluateSenderGroupAccess", () => {
it("defaults missing provider config to allowlist", () => { it("defaults missing provider config to allowlist", () => {

View File

@@ -14,6 +14,48 @@ export type SenderGroupAccessDecision = {
reason: SenderGroupAccessReason; reason: SenderGroupAccessReason;
}; };
export function evaluateSenderGroupAccessForPolicy(params: {
groupPolicy: GroupPolicy;
providerMissingFallbackApplied?: boolean;
groupAllowFrom: string[];
senderId: string;
isSenderAllowed: (senderId: string, allowFrom: string[]) => boolean;
}): SenderGroupAccessDecision {
if (params.groupPolicy === "disabled") {
return {
allowed: false,
groupPolicy: params.groupPolicy,
providerMissingFallbackApplied: Boolean(params.providerMissingFallbackApplied),
reason: "disabled",
};
}
if (params.groupPolicy === "allowlist") {
if (params.groupAllowFrom.length === 0) {
return {
allowed: false,
groupPolicy: params.groupPolicy,
providerMissingFallbackApplied: Boolean(params.providerMissingFallbackApplied),
reason: "empty_allowlist",
};
}
if (!params.isSenderAllowed(params.senderId, params.groupAllowFrom)) {
return {
allowed: false,
groupPolicy: params.groupPolicy,
providerMissingFallbackApplied: Boolean(params.providerMissingFallbackApplied),
reason: "sender_not_allowlisted",
};
}
}
return {
allowed: true,
groupPolicy: params.groupPolicy,
providerMissingFallbackApplied: Boolean(params.providerMissingFallbackApplied),
reason: "allowed",
};
}
export function evaluateSenderGroupAccess(params: { export function evaluateSenderGroupAccess(params: {
providerConfigPresent: boolean; providerConfigPresent: boolean;
configuredGroupPolicy?: GroupPolicy; configuredGroupPolicy?: GroupPolicy;
@@ -28,37 +70,11 @@ export function evaluateSenderGroupAccess(params: {
defaultGroupPolicy: params.defaultGroupPolicy, defaultGroupPolicy: params.defaultGroupPolicy,
}); });
if (groupPolicy === "disabled") { return evaluateSenderGroupAccessForPolicy({
return {
allowed: false,
groupPolicy,
providerMissingFallbackApplied,
reason: "disabled",
};
}
if (groupPolicy === "allowlist") {
if (params.groupAllowFrom.length === 0) {
return {
allowed: false,
groupPolicy,
providerMissingFallbackApplied,
reason: "empty_allowlist",
};
}
if (!params.isSenderAllowed(params.senderId, params.groupAllowFrom)) {
return {
allowed: false,
groupPolicy,
providerMissingFallbackApplied,
reason: "sender_not_allowlisted",
};
}
}
return {
allowed: true,
groupPolicy, groupPolicy,
providerMissingFallbackApplied, providerMissingFallbackApplied,
reason: "allowed", groupAllowFrom: params.groupAllowFrom,
}; senderId: params.senderId,
isSenderAllowed: params.isSenderAllowed,
});
} }

View File

@@ -277,6 +277,7 @@ export {
} from "./allow-from.js"; } from "./allow-from.js";
export { export {
evaluateSenderGroupAccess, evaluateSenderGroupAccess,
evaluateSenderGroupAccessForPolicy,
type SenderGroupAccessDecision, type SenderGroupAccessDecision,
type SenderGroupAccessReason, type SenderGroupAccessReason,
} from "./group-access.js"; } from "./group-access.js";

View File

@@ -95,6 +95,7 @@ export {
resolveDmGroupAccessWithLists, resolveDmGroupAccessWithLists,
resolveEffectiveAllowFromLists, resolveEffectiveAllowFromLists,
} from "../security/dm-policy-shared.js"; } from "../security/dm-policy-shared.js";
export { evaluateSenderGroupAccessForPolicy } from "./group-access.js";
export type { WizardPrompter } from "../wizard/prompts.js"; export type { WizardPrompter } from "../wizard/prompts.js";
export { buildAgentMediaPayload } from "./agent-media-payload.js"; export { buildAgentMediaPayload } from "./agent-media-payload.js";
export { loadOutboundMediaFromUrl } from "./outbound-media.js"; export { loadOutboundMediaFromUrl } from "./outbound-media.js";

View File

@@ -91,6 +91,7 @@ export {
resolveDmGroupAccessWithLists, resolveDmGroupAccessWithLists,
resolveEffectiveAllowFromLists, resolveEffectiveAllowFromLists,
} from "../security/dm-policy-shared.js"; } from "../security/dm-policy-shared.js";
export { evaluateSenderGroupAccessForPolicy } from "./group-access.js";
export { formatDocsLink } from "../terminal/links.js"; export { formatDocsLink } from "../terminal/links.js";
export { sleep } from "../utils.js"; export { sleep } from "../utils.js";
export { loadWebMedia } from "../web/media.js"; export { loadWebMedia } from "../web/media.js";