mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:40:43 +00:00
feat: generalize message access groups (#75813)
This commit is contained in:
@@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- Providers/OpenAI: add `extraBody`/`extra_body` passthrough for OpenAI-compatible TTS endpoints, so custom speech servers can receive fields such as `lang` in `/audio/speech` requests. Fixes #39900. Thanks @R3NK0R.
|
||||
- Dependencies: refresh workspace dependency pins, including TypeBox 1.1.37, AWS SDK 3.1041.0, Microsoft Teams 2.0.9, and Marked 18.0.3. Thanks @mariozechner, @aws, and @microsoft.
|
||||
- Discord/channels: add reusable message-channel access groups plus Discord channel-audience DM authorization, so allowlists can reference `accessGroup:<name>` across channel auth paths. (#75813) Thanks @clawsweeper.
|
||||
|
||||
### Fixes
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
e14ddc6b9859128d4c5561cf80f322b7b24e0f87dac5bff170afbf2d6a9c3711 config-baseline.json
|
||||
2b1eac57f1b08b461e4cb9931a766f472c668e18aedd78e2af89541d8b476933 config-baseline.core.json
|
||||
74530fefef9ed55cab302802bc0be413ec56929e73c12d4bf4f1e4d290813adc config-baseline.json
|
||||
21db87c2ebec8844e20bf66ea474c08f3adab842234ff334870fe3e8d87995b4 config-baseline.core.json
|
||||
c401cd3450f1737bc92418cfea301d20b54b7fbef9e6049834acc01af338e538 config-baseline.channel.json
|
||||
7731a0b93cb335b56fac4c807447ba659fea51ea7a6cd844dc0ef5616669ee75 config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
edf54c8ce4c65d44ade9953509b1c3264f4ed12c8bf8eb0a13703a76d185f744 plugin-sdk-api-baseline.json
|
||||
2418f2484d2d5b40ec8c9b3b92562c76abae43845bb18af0d59706848422555c plugin-sdk-api-baseline.jsonl
|
||||
0f9284c6349bf03d3d89c1d25031031840dae4ade032622ca212240ed19829f6 plugin-sdk-api-baseline.json
|
||||
33706cf425386717973cc87357ae5e0df432dd5a519b4faea8b38e21d7daae78 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -452,6 +452,29 @@ Example:
|
||||
<Tab title="DM access groups">
|
||||
Discord DMs can use dynamic `accessGroup:<name>` entries in `channels.discord.allowFrom`.
|
||||
|
||||
Access group names are shared across message channels. Use `type: "message.senders"` for a static group whose members are expressed in each channel's normal `allowFrom` syntax, or `type: "discord.channelAudience"` when a Discord channel's current `ViewChannel` audience should define membership dynamically.
|
||||
|
||||
```json5
|
||||
{
|
||||
accessGroups: {
|
||||
operators: {
|
||||
type: "message.senders",
|
||||
members: {
|
||||
"*": ["global-owner-id"],
|
||||
discord: ["discord:123456789012345678"],
|
||||
telegram: ["987654321"],
|
||||
},
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
discord: {
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["accessGroup:operators"],
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
A Discord text channel has no separate member list. `type: "discord.channelAudience"` models membership as: the DM sender is a member of the configured guild and currently has effective `ViewChannel` permission on the configured channel after role and channel overwrites are applied.
|
||||
|
||||
Example: allow anyone who can see `#maintainers` to DM the bot, while keeping DMs closed to everyone else.
|
||||
|
||||
@@ -47,6 +47,32 @@ access; they do not add more owners.
|
||||
|
||||
Supported channels: `bluebubbles`, `discord`, `feishu`, `googlechat`, `imessage`, `irc`, `line`, `matrix`, `mattermost`, `msteams`, `nextcloud-talk`, `nostr`, `openclaw-weixin`, `signal`, `slack`, `synology-chat`, `telegram`, `twitch`, `whatsapp`, `zalo`, `zalouser`.
|
||||
|
||||
### Reusable sender groups
|
||||
|
||||
Use top-level `accessGroups` when the same trusted sender set should apply to multiple message channels or to both DM and group allowlists. Static sender groups use `type: "message.senders"` and list members in each channel's normal `allowFrom` syntax.
|
||||
|
||||
```json5
|
||||
{
|
||||
accessGroups: {
|
||||
operators: {
|
||||
type: "message.senders",
|
||||
members: {
|
||||
"*": ["global-owner-id"],
|
||||
discord: ["discord:123456789012345678"],
|
||||
telegram: ["987654321"],
|
||||
whatsapp: ["+15551234567"],
|
||||
},
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
telegram: { dmPolicy: "allowlist", allowFrom: ["accessGroup:operators"] },
|
||||
whatsapp: { groupPolicy: "allowlist", groupAllowFrom: ["accessGroup:operators"] },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
The `"*"` member list is shared by all message channels. Channel-specific lists are checked with that channel's own sender matching rules.
|
||||
|
||||
### Where the state lives
|
||||
|
||||
Stored under `~/.openclaw/credentials/`:
|
||||
|
||||
@@ -1,17 +1,42 @@
|
||||
import {
|
||||
ACCESS_GROUP_ALLOW_FROM_PREFIX,
|
||||
parseAccessGroupAllowFromEntry,
|
||||
resolveAccessGroupAllowFromMatches,
|
||||
type AccessGroupMembershipResolver,
|
||||
} from "openclaw/plugin-sdk/command-auth";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import type { RequestClient } from "../internal/discord.js";
|
||||
import { canViewDiscordGuildChannel } from "../send.permissions.js";
|
||||
|
||||
export const DISCORD_ACCESS_GROUP_PREFIX = "accessGroup:";
|
||||
export const DISCORD_ACCESS_GROUP_PREFIX = ACCESS_GROUP_ALLOW_FROM_PREFIX;
|
||||
|
||||
export function parseDiscordAccessGroupEntry(entry: string): string | null {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed.startsWith(DISCORD_ACCESS_GROUP_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
const name = trimmed.slice(DISCORD_ACCESS_GROUP_PREFIX.length).trim();
|
||||
return name.length > 0 ? name : null;
|
||||
return parseAccessGroupAllowFromEntry(entry);
|
||||
}
|
||||
|
||||
export function createDiscordAccessGroupMembershipResolver(params: {
|
||||
token?: string;
|
||||
rest?: RequestClient;
|
||||
}): AccessGroupMembershipResolver {
|
||||
return async ({ cfg, name, group, accountId, senderId }) => {
|
||||
if (group.type !== "discord.channelAudience") {
|
||||
return false;
|
||||
}
|
||||
const membership = group.membership ?? "canViewChannel";
|
||||
if (membership !== "canViewChannel") {
|
||||
return false;
|
||||
}
|
||||
return await canViewDiscordGuildChannel(group.guildId, group.channelId, senderId, {
|
||||
cfg,
|
||||
accountId,
|
||||
token: params.token,
|
||||
rest: params.rest,
|
||||
}).catch((err) => {
|
||||
logVerbose(`discord: accessGroup:${name} lookup failed for user ${senderId}: ${String(err)}`);
|
||||
return false;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveDiscordDmAccessGroupEntries(params: {
|
||||
@@ -21,50 +46,18 @@ export async function resolveDiscordDmAccessGroupEntries(params: {
|
||||
accountId: string;
|
||||
token?: string;
|
||||
rest?: RequestClient;
|
||||
isSenderAllowed?: (senderId: string, allowFrom: string[]) => boolean;
|
||||
}): Promise<string[]> {
|
||||
const names = Array.from(
|
||||
new Set(
|
||||
params.allowFrom
|
||||
.map((entry) => parseDiscordAccessGroupEntry(entry))
|
||||
.filter((entry): entry is string => entry != null),
|
||||
),
|
||||
);
|
||||
if (names.length === 0 || !params.cfg?.accessGroups) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const matched: string[] = [];
|
||||
for (const name of names) {
|
||||
const group = params.cfg.accessGroups[name];
|
||||
if (!group) {
|
||||
continue;
|
||||
}
|
||||
if (group.type !== "discord.channelAudience") {
|
||||
continue;
|
||||
}
|
||||
const membership = group.membership ?? "canViewChannel";
|
||||
if (membership !== "canViewChannel") {
|
||||
continue;
|
||||
}
|
||||
const allowed = await canViewDiscordGuildChannel(
|
||||
group.guildId,
|
||||
group.channelId,
|
||||
params.sender.id,
|
||||
{
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
token: params.token,
|
||||
rest: params.rest,
|
||||
},
|
||||
).catch((err) => {
|
||||
logVerbose(
|
||||
`discord: accessGroup:${name} lookup failed for user ${params.sender.id}: ${String(err)}`,
|
||||
);
|
||||
return false;
|
||||
});
|
||||
if (allowed) {
|
||||
matched.push(`${DISCORD_ACCESS_GROUP_PREFIX}${name}`);
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
return await resolveAccessGroupAllowFromMatches({
|
||||
cfg: params.cfg,
|
||||
allowFrom: params.allowFrom,
|
||||
channel: "discord",
|
||||
accountId: params.accountId,
|
||||
senderId: params.sender.id,
|
||||
isSenderAllowed: params.isSenderAllowed,
|
||||
resolveMembership: createDiscordAccessGroupMembershipResolver({
|
||||
token: params.token,
|
||||
rest: params.rest,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ async function ensureDmComponentAuthorized(params: {
|
||||
sender: { id: user.id },
|
||||
accountId: ctx.accountId,
|
||||
token: ctx.token,
|
||||
isSenderAllowed: (senderId, allowFrom) =>
|
||||
resolveAllowMatch(allowFrom).allowed || allowFrom.includes(senderId),
|
||||
});
|
||||
return matchedGroups.length > 0
|
||||
? resolveAllowMatch([...entries, `discord:${user.id}`])
|
||||
|
||||
@@ -127,6 +127,33 @@ describe("resolveDiscordDmCommandAccess", () => {
|
||||
expect(result.commandAuthorized).toBe(true);
|
||||
});
|
||||
|
||||
it("authorizes allowlist DMs from a generic message sender access group", async () => {
|
||||
const result = await resolveDiscordDmCommandAccess({
|
||||
accountId: "default",
|
||||
dmPolicy: "allowlist",
|
||||
configuredAllowFrom: ["accessGroup:owners"],
|
||||
sender,
|
||||
allowNameMatching: false,
|
||||
useAccessGroups: true,
|
||||
cfg: {
|
||||
accessGroups: {
|
||||
owners: {
|
||||
type: "message.senders",
|
||||
members: {
|
||||
discord: ["discord:123"],
|
||||
telegram: ["987"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
readStoreAllowFrom: async () => [],
|
||||
});
|
||||
|
||||
expect(canViewDiscordGuildChannelMock).not.toHaveBeenCalled();
|
||||
expect(result.decision).toBe("allow");
|
||||
expect(result.commandAuthorized).toBe(true);
|
||||
});
|
||||
|
||||
it("fails closed when a Discord channel audience access group lookup rejects", async () => {
|
||||
canViewDiscordGuildChannelMock.mockRejectedValueOnce(new Error("missing intent"));
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { expandAllowFromWithAccessGroups } from "openclaw/plugin-sdk/command-auth";
|
||||
import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/command-auth-native";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||
import {
|
||||
@@ -6,7 +7,7 @@ import {
|
||||
type DmGroupAccessDecision,
|
||||
} from "openclaw/plugin-sdk/security-runtime";
|
||||
import type { RequestClient } from "../internal/discord.js";
|
||||
import { resolveDiscordDmAccessGroupEntries } from "./access-groups.js";
|
||||
import { createDiscordAccessGroupMembershipResolver } from "./access-groups.js";
|
||||
import { normalizeDiscordAllowList, resolveDiscordAllowListMatch } from "./allow-list.js";
|
||||
|
||||
const DISCORD_ALLOW_LIST_PREFIXES = ["discord:", "user:", "pk:"];
|
||||
@@ -50,11 +51,24 @@ async function expandAllowFromWithDiscordAccessGroups(params: {
|
||||
token?: string;
|
||||
rest?: RequestClient;
|
||||
}) {
|
||||
const matchedGroups = await resolveDiscordDmAccessGroupEntries(params);
|
||||
if (matchedGroups.length === 0) {
|
||||
return params.allowFrom;
|
||||
}
|
||||
return [...params.allowFrom, `discord:${params.sender.id}`];
|
||||
return await expandAllowFromWithAccessGroups({
|
||||
cfg: params.cfg,
|
||||
allowFrom: params.allowFrom,
|
||||
channel: "discord",
|
||||
accountId: params.accountId,
|
||||
senderId: params.sender.id,
|
||||
senderAllowEntry: `discord:${params.sender.id}`,
|
||||
isSenderAllowed: (senderId, allowFrom) =>
|
||||
resolveSenderAllowMatch({
|
||||
allowEntries: allowFrom,
|
||||
sender: { id: senderId },
|
||||
allowNameMatching: false,
|
||||
}).allowed,
|
||||
resolveMembership: createDiscordAccessGroupMembershipResolver({
|
||||
token: params.token,
|
||||
rest: params.rest,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveDiscordDmCommandAccess(params: {
|
||||
|
||||
@@ -105,12 +105,16 @@ function resolveMemberChannelPermissionBits(params: {
|
||||
permissions = addPermissionBits(permissions, overwrite.allow ?? "0");
|
||||
}
|
||||
}
|
||||
let roleDeny = 0n;
|
||||
let roleAllow = 0n;
|
||||
for (const overwrite of overwrites) {
|
||||
if (params.member.roles?.includes(overwrite.id)) {
|
||||
permissions = removePermissionBits(permissions, overwrite.deny ?? "0");
|
||||
permissions = addPermissionBits(permissions, overwrite.allow ?? "0");
|
||||
roleDeny = addPermissionBits(roleDeny, overwrite.deny ?? "0");
|
||||
roleAllow = addPermissionBits(roleAllow, overwrite.allow ?? "0");
|
||||
}
|
||||
}
|
||||
permissions = permissions & ~roleDeny;
|
||||
permissions = permissions | roleAllow;
|
||||
for (const overwrite of overwrites) {
|
||||
if (overwrite.id === params.userId) {
|
||||
permissions = removePermissionBits(permissions, overwrite.deny ?? "0");
|
||||
|
||||
@@ -735,6 +735,44 @@ describe("fetchChannelPermissionsDiscord", () => {
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("aggregates conflicting role overwrites before applying allows", async () => {
|
||||
const { rest, getMock } = makeDiscordRest();
|
||||
getMock
|
||||
.mockResolvedValueOnce({
|
||||
id: "chan1",
|
||||
guild_id: "guild1",
|
||||
permission_overwrites: [
|
||||
{
|
||||
id: "role-allow",
|
||||
deny: "0",
|
||||
allow: PermissionFlagsBits.ViewChannel.toString(),
|
||||
},
|
||||
{
|
||||
id: "role-deny",
|
||||
deny: PermissionFlagsBits.ViewChannel.toString(),
|
||||
allow: "0",
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: "guild1",
|
||||
roles: [
|
||||
{ id: "guild1", permissions: "0" },
|
||||
{ id: "role-allow", permissions: "0" },
|
||||
{ id: "role-deny", permissions: "0" },
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({ roles: ["role-allow", "role-deny"] });
|
||||
|
||||
await expect(
|
||||
canViewDiscordGuildChannel("guild1", "chan1", "user1", {
|
||||
rest,
|
||||
token: "t",
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("fails closed when the channel belongs to a different guild", async () => {
|
||||
const { rest, getMock } = makeDiscordRest();
|
||||
getMock.mockResolvedValueOnce({
|
||||
|
||||
@@ -216,6 +216,89 @@ describe("googlechat inbound access policy", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("allows group traffic from generic message sender access groups", async () => {
|
||||
primeCommonDefaults();
|
||||
allowInboundGroupTraffic();
|
||||
|
||||
await expect(
|
||||
applyInboundAccessPolicy({
|
||||
config: {
|
||||
...baseAccessConfig,
|
||||
accessGroups: {
|
||||
operators: {
|
||||
type: "message.senders",
|
||||
members: {
|
||||
googlechat: ["users/alice"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
account: {
|
||||
accountId: "default",
|
||||
config: {
|
||||
groups: {
|
||||
"spaces/AAA": {
|
||||
users: ["accessGroup:operators"],
|
||||
requireMention: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("expands generic message sender access groups before DM access checks", async () => {
|
||||
primeCommonDefaults();
|
||||
const readAllowFromStore = vi.fn(async () => []);
|
||||
createChannelPairingController.mockReturnValue({
|
||||
readAllowFromStore,
|
||||
issueChallenge: vi.fn(),
|
||||
});
|
||||
resolveDmGroupAccessWithLists.mockReturnValue({
|
||||
decision: "allow",
|
||||
effectiveAllowFrom: ["accessGroup:operators", "users/alice"],
|
||||
effectiveGroupAllowFrom: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
applyInboundAccessPolicy({
|
||||
isGroup: false,
|
||||
config: {
|
||||
...baseAccessConfig,
|
||||
accessGroups: {
|
||||
operators: {
|
||||
type: "message.senders",
|
||||
members: {
|
||||
googlechat: ["users/alice"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
account: {
|
||||
accountId: "default",
|
||||
config: {
|
||||
dm: {
|
||||
policy: "allowlist",
|
||||
allowFrom: ["accessGroup:operators"],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
|
||||
expect(resolveDmGroupAccessWithLists).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
allowFrom: ["accessGroup:operators", "users/alice"],
|
||||
}),
|
||||
);
|
||||
expect(readAllowFromStore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves allowlist group policy when a routed space has no sender allowlist", async () => {
|
||||
primeCommonDefaults();
|
||||
allowInboundGroupTraffic({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { expandAllowFromWithAccessGroups } from "openclaw/plugin-sdk/security-runtime";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
@@ -204,6 +205,16 @@ export async function applyGoogleChatInboundAccessPolicy(params: {
|
||||
});
|
||||
const groupEntry = groupConfigResolved.entry;
|
||||
const groupUsers = groupEntry?.users ?? account.config.groupAllowFrom ?? [];
|
||||
const isGoogleChatSenderAllowed = (_senderId: string, allowFrom: string[]) =>
|
||||
isSenderAllowed(senderId, senderEmail, allowFrom, allowNameMatching);
|
||||
const expandedGroupUsers = await expandAllowFromWithAccessGroups({
|
||||
cfg: config,
|
||||
allowFrom: groupUsers,
|
||||
channel: "googlechat",
|
||||
accountId: account.accountId,
|
||||
senderId,
|
||||
isSenderAllowed: isGoogleChatSenderAllowed,
|
||||
});
|
||||
let effectiveWasMentioned: boolean | undefined;
|
||||
|
||||
if (isGroup) {
|
||||
@@ -231,10 +242,9 @@ export async function applyGoogleChatInboundAccessPolicy(params: {
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
if (groupUsers.length > 0) {
|
||||
const normalizedGroupUsers = groupUsers.map((v) => String(v));
|
||||
warnDeprecatedUsersEmailEntries(logVerbose, normalizedGroupUsers);
|
||||
const ok = isSenderAllowed(senderId, senderEmail, normalizedGroupUsers, allowNameMatching);
|
||||
if (expandedGroupUsers.length > 0) {
|
||||
warnDeprecatedUsersEmailEntries(logVerbose, expandedGroupUsers);
|
||||
const ok = isSenderAllowed(senderId, senderEmail, expandedGroupUsers, allowNameMatching);
|
||||
if (!ok) {
|
||||
logVerbose(`drop group message (sender not allowed, ${senderId})`);
|
||||
return { ok: false };
|
||||
@@ -243,8 +253,8 @@ export async function applyGoogleChatInboundAccessPolicy(params: {
|
||||
}
|
||||
|
||||
const dmPolicy = account.config.dm?.policy ?? "pairing";
|
||||
const configAllowFrom = (account.config.dm?.allowFrom ?? []).map((v) => String(v));
|
||||
const normalizedGroupUsers = groupUsers.map((v) => String(v));
|
||||
const rawConfigAllowFrom = (account.config.dm?.allowFrom ?? []).map((v) => String(v));
|
||||
const normalizedGroupUsers = expandedGroupUsers;
|
||||
const senderGroupPolicy =
|
||||
groupConfigResolved.allowlistConfigured && normalizedGroupUsers.length === 0
|
||||
? groupPolicy
|
||||
@@ -257,13 +267,31 @@ export async function applyGoogleChatInboundAccessPolicy(params: {
|
||||
!isGroup && dmPolicy !== "allowlist" && dmPolicy !== "open"
|
||||
? await pairing.readAllowFromStore().catch(() => [])
|
||||
: [];
|
||||
const [configAllowFrom, effectiveStoreAllowFrom] = await Promise.all([
|
||||
expandAllowFromWithAccessGroups({
|
||||
cfg: config,
|
||||
allowFrom: rawConfigAllowFrom,
|
||||
channel: "googlechat",
|
||||
accountId: account.accountId,
|
||||
senderId,
|
||||
isSenderAllowed: isGoogleChatSenderAllowed,
|
||||
}),
|
||||
expandAllowFromWithAccessGroups({
|
||||
cfg: config,
|
||||
allowFrom: storeAllowFrom,
|
||||
channel: "googlechat",
|
||||
accountId: account.accountId,
|
||||
senderId,
|
||||
isSenderAllowed: isGoogleChatSenderAllowed,
|
||||
}),
|
||||
]);
|
||||
const access = resolveDmGroupAccessWithLists({
|
||||
isGroup,
|
||||
dmPolicy,
|
||||
groupPolicy: senderGroupPolicy,
|
||||
allowFrom: configAllowFrom,
|
||||
groupAllowFrom: normalizedGroupUsers,
|
||||
storeAllowFrom,
|
||||
storeAllowFrom: effectiveStoreAllowFrom,
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
isSenderAllowed: (allowFrom) =>
|
||||
isSenderAllowed(senderId, senderEmail, allowFrom, allowNameMatching),
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
} from "openclaw/plugin-sdk/config-types";
|
||||
import { resolveDefaultGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy";
|
||||
import {
|
||||
expandAllowFromWithAccessGroups,
|
||||
readStoreAllowFromForDmPolicy,
|
||||
resolveEffectiveAllowFromLists,
|
||||
resolveDmGroupAccessWithCommandGate,
|
||||
@@ -177,13 +178,45 @@ export async function resolveWhatsAppCommandAuthorized(params: {
|
||||
dmPolicy: policy.dmPolicy,
|
||||
shouldRead: policy.shouldReadStorePairingApprovals,
|
||||
});
|
||||
const isSenderAllowed = (senderId: string, allowEntries: string[]) =>
|
||||
isGroup
|
||||
? policy.isGroupSenderAllowed(allowEntries, senderId)
|
||||
: policy.isDmSenderAllowed(allowEntries, senderId);
|
||||
const [allowFrom, groupAllowFrom] = await Promise.all([
|
||||
expandAllowFromWithAccessGroups({
|
||||
cfg: params.cfg,
|
||||
allowFrom: policy.dmAllowFrom,
|
||||
channel: "whatsapp",
|
||||
accountId: policy.account.accountId,
|
||||
senderId: normalizedSender,
|
||||
isSenderAllowed,
|
||||
}),
|
||||
expandAllowFromWithAccessGroups({
|
||||
cfg: params.cfg,
|
||||
allowFrom: policy.groupAllowFrom,
|
||||
channel: "whatsapp",
|
||||
accountId: policy.account.accountId,
|
||||
senderId: normalizedSender,
|
||||
isSenderAllowed,
|
||||
}),
|
||||
]);
|
||||
const dmStoreAllowFrom = isGroup
|
||||
? []
|
||||
: await expandAllowFromWithAccessGroups({
|
||||
cfg: params.cfg,
|
||||
allowFrom: storeAllowFrom,
|
||||
channel: "whatsapp",
|
||||
accountId: policy.account.accountId,
|
||||
senderId: normalizedSender,
|
||||
isSenderAllowed,
|
||||
});
|
||||
const access = resolveDmGroupAccessWithCommandGate({
|
||||
isGroup,
|
||||
dmPolicy: policy.dmPolicy,
|
||||
groupPolicy: policy.groupPolicy,
|
||||
allowFrom: policy.dmAllowFrom,
|
||||
groupAllowFrom: policy.groupAllowFrom,
|
||||
storeAllowFrom,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
storeAllowFrom: dmStoreAllowFrom,
|
||||
isSenderAllowed: (allowEntries) =>
|
||||
isGroup
|
||||
? policy.isGroupSenderAllowed(allowEntries, groupSender)
|
||||
|
||||
@@ -59,6 +59,29 @@ async function checkCommandAuthorizedForDm(params: {
|
||||
});
|
||||
}
|
||||
|
||||
async function checkCommandAuthorizedForGroup(params: {
|
||||
cfg: Record<string, unknown>;
|
||||
accountId?: string;
|
||||
from?: string;
|
||||
senderE164?: string;
|
||||
selfE164?: string;
|
||||
}) {
|
||||
return await resolveWhatsAppCommandAuthorized({
|
||||
cfg: params.cfg as never,
|
||||
msg: {
|
||||
accountId: params.accountId ?? "work",
|
||||
chatType: "group",
|
||||
from: params.from ?? "120363401234567890@g.us",
|
||||
conversationId: params.from ?? "120363401234567890@g.us",
|
||||
chatId: params.from ?? "120363401234567890@g.us",
|
||||
senderE164: params.senderE164 ?? "+15550001111",
|
||||
selfE164: params.selfE164 ?? "+15550009999",
|
||||
body: "/status",
|
||||
to: params.selfE164 ?? "+15550009999",
|
||||
} as never,
|
||||
});
|
||||
}
|
||||
|
||||
describe("checkInboundAccessControl pairing grace", () => {
|
||||
async function runPairingGraceCase(messageTimestampMs: number) {
|
||||
const connectedAtMs = 1_000_000;
|
||||
@@ -206,6 +229,94 @@ describe("WhatsApp dmPolicy precedence", () => {
|
||||
expect(sendMessageMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows DMs from generic message sender access groups", async () => {
|
||||
const cfg = {
|
||||
accessGroups: {
|
||||
owners: {
|
||||
type: "message.senders",
|
||||
members: {
|
||||
whatsapp: ["+15550001111"],
|
||||
},
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
whatsapp: {
|
||||
dmPolicy: "allowlist",
|
||||
accounts: {
|
||||
work: {
|
||||
allowFrom: ["accessGroup:owners"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
setAccessControlTestConfig(cfg);
|
||||
|
||||
const result = await checkInboundAccessControl({
|
||||
cfg: getAccessControlTestConfig() as never,
|
||||
accountId: "work",
|
||||
from: "+15550001111",
|
||||
selfE164: "+15550009999",
|
||||
senderE164: "+15550001111",
|
||||
group: false,
|
||||
pushName: "Sam",
|
||||
isFromMe: false,
|
||||
sock: { sendMessage: sendMessageMock },
|
||||
remoteJid: "15550001111@s.whatsapp.net",
|
||||
});
|
||||
const commandAuthorized = await checkCommandAuthorizedForDm({ cfg });
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(commandAuthorized).toBe(true);
|
||||
expect(upsertPairingRequestMock).not.toHaveBeenCalled();
|
||||
expect(sendMessageMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows group messages from generic message sender access groups", async () => {
|
||||
const cfg = {
|
||||
accessGroups: {
|
||||
operators: {
|
||||
type: "message.senders",
|
||||
members: {
|
||||
whatsapp: ["+15550001111"],
|
||||
},
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
whatsapp: {
|
||||
dmPolicy: "allowlist",
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["accessGroup:operators"],
|
||||
accounts: {
|
||||
work: {
|
||||
allowFrom: ["+15559999999"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
setAccessControlTestConfig(cfg);
|
||||
|
||||
const result = await checkInboundAccessControl({
|
||||
cfg: getAccessControlTestConfig() as never,
|
||||
accountId: "work",
|
||||
from: "120363401234567890@g.us",
|
||||
selfE164: "+15550009999",
|
||||
senderE164: "+15550001111",
|
||||
group: true,
|
||||
pushName: "Sam",
|
||||
isFromMe: false,
|
||||
sock: { sendMessage: sendMessageMock },
|
||||
remoteJid: "120363401234567890@g.us",
|
||||
});
|
||||
const commandAuthorized = await checkCommandAuthorizedForGroup({ cfg });
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(commandAuthorized).toBe(true);
|
||||
expect(upsertPairingRequestMock).not.toHaveBeenCalled();
|
||||
expect(sendMessageMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not broaden self-chat mode to every paired DM when allowFrom is empty", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-ru
|
||||
import { defaultRuntime } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { warnMissingProviderGroupPolicyFallbackOnce } from "openclaw/plugin-sdk/runtime-group-policy";
|
||||
import {
|
||||
expandAllowFromWithAccessGroups,
|
||||
readStoreAllowFromForDmPolicy,
|
||||
resolveDmGroupAccessWithLists,
|
||||
} from "openclaw/plugin-sdk/security-runtime";
|
||||
@@ -48,12 +49,14 @@ export async function checkInboundAccessControl(params: {
|
||||
accountId: params.accountId,
|
||||
selfE164: params.selfE164,
|
||||
});
|
||||
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
|
||||
provider: "whatsapp",
|
||||
accountId: policy.account.accountId,
|
||||
dmPolicy: policy.dmPolicy,
|
||||
shouldRead: policy.shouldReadStorePairingApprovals,
|
||||
});
|
||||
const storeAllowFrom = params.group
|
||||
? []
|
||||
: await readStoreAllowFromForDmPolicy({
|
||||
provider: "whatsapp",
|
||||
accountId: policy.account.accountId,
|
||||
dmPolicy: policy.dmPolicy,
|
||||
shouldRead: policy.shouldReadStorePairingApprovals,
|
||||
});
|
||||
const pairingGraceMs =
|
||||
typeof params.pairingGraceMs === "number" && params.pairingGraceMs > 0
|
||||
? params.pairingGraceMs
|
||||
@@ -73,13 +76,47 @@ export async function checkInboundAccessControl(params: {
|
||||
accountId: policy.account.accountId,
|
||||
log: (message) => logWhatsAppVerbose(params.verbose, message),
|
||||
});
|
||||
const accessGroupSenderId = params.group ? (params.senderE164 ?? params.from) : params.from;
|
||||
const isAccessGroupSenderAllowed = (senderId: string, allowEntries: string[]) => {
|
||||
return params.group
|
||||
? policy.isGroupSenderAllowed(allowEntries, senderId)
|
||||
: policy.isDmSenderAllowed(allowEntries, senderId);
|
||||
};
|
||||
const [allowFrom, groupAllowFrom] = await Promise.all([
|
||||
expandAllowFromWithAccessGroups({
|
||||
cfg: params.cfg,
|
||||
allowFrom: params.group ? policy.configuredAllowFrom : policy.dmAllowFrom,
|
||||
channel: "whatsapp",
|
||||
accountId: policy.account.accountId,
|
||||
senderId: accessGroupSenderId,
|
||||
isSenderAllowed: isAccessGroupSenderAllowed,
|
||||
}),
|
||||
expandAllowFromWithAccessGroups({
|
||||
cfg: params.cfg,
|
||||
allowFrom: policy.groupAllowFrom,
|
||||
channel: "whatsapp",
|
||||
accountId: policy.account.accountId,
|
||||
senderId: accessGroupSenderId,
|
||||
isSenderAllowed: isAccessGroupSenderAllowed,
|
||||
}),
|
||||
]);
|
||||
const dmStoreAllowFrom = params.group
|
||||
? []
|
||||
: await expandAllowFromWithAccessGroups({
|
||||
cfg: params.cfg,
|
||||
allowFrom: storeAllowFrom,
|
||||
channel: "whatsapp",
|
||||
accountId: policy.account.accountId,
|
||||
senderId: accessGroupSenderId,
|
||||
isSenderAllowed: isAccessGroupSenderAllowed,
|
||||
});
|
||||
const access = resolveDmGroupAccessWithLists({
|
||||
isGroup: params.group,
|
||||
dmPolicy: policy.dmPolicy,
|
||||
groupPolicy: policy.groupPolicy,
|
||||
allowFrom: params.group ? policy.configuredAllowFrom : policy.dmAllowFrom,
|
||||
groupAllowFrom: policy.groupAllowFrom,
|
||||
storeAllowFrom,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
storeAllowFrom: dmStoreAllowFrom,
|
||||
isSenderAllowed: (allowEntries) => {
|
||||
return params.group
|
||||
? policy.isGroupSenderAllowed(allowEntries, params.senderE164)
|
||||
|
||||
@@ -469,6 +469,8 @@ async function authorizeZaloMessage(
|
||||
configuredGroupAllowFrom: groupAllowFrom,
|
||||
senderId,
|
||||
isSenderAllowed: isZaloSenderAllowed,
|
||||
channel: "zalo",
|
||||
accountId: account.accountId,
|
||||
readAllowFromStore: pairing.readAllowFromStore,
|
||||
runtime: core.channel.commands,
|
||||
});
|
||||
|
||||
@@ -436,6 +436,8 @@ async function processMessage(
|
||||
configuredGroupAllowFrom: configGroupAllowFrom,
|
||||
senderId,
|
||||
isSenderAllowed,
|
||||
channel: "zalouser",
|
||||
accountId: account.accountId,
|
||||
readAllowFromStore: async () => storeAllowFrom,
|
||||
shouldComputeCommandAuthorized: (body, cfg) =>
|
||||
core.channel.commands.shouldComputeCommandAuthorized(body, cfg),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getRuntimeConfig } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||
import { createEmptyUninstallActions } from "../plugins/uninstall.js";
|
||||
import { createCliRuntimeCapture } from "./test-runtime-capture.js";
|
||||
import type { CliMockOutputRuntime } from "./test-runtime-capture.js";
|
||||
|
||||
type UnknownMock = Mock<(...args: unknown[]) => unknown>;
|
||||
type AsyncUnknownMock = Mock<(...args: unknown[]) => Promise<unknown>>;
|
||||
@@ -81,8 +81,40 @@ export const installHooksFromNpmSpec: AsyncUnknownMock = vi.fn();
|
||||
export const installHooksFromPath: AsyncUnknownMock = vi.fn();
|
||||
export const recordHookInstall: UnknownMock = vi.fn();
|
||||
|
||||
const { defaultRuntime, runtimeLogs, runtimeErrors, resetRuntimeCapture } =
|
||||
createCliRuntimeCapture();
|
||||
const { defaultRuntime, runtimeLogs, runtimeErrors, resetRuntimeCapture } = vi.hoisted(() => {
|
||||
const runtimeLogs: string[] = [];
|
||||
const runtimeErrors: string[] = [];
|
||||
const stringifyArgs = (args: unknown[]) => args.map((value) => String(value)).join(" ");
|
||||
const normalizeStdout = (value: string) => (value.endsWith("\n") ? value.slice(0, -1) : value);
|
||||
const stringifyJson = (value: unknown, space = 2) =>
|
||||
JSON.stringify(value, null, space > 0 ? space : undefined);
|
||||
const defaultRuntime = {
|
||||
log: vi.fn((...args: unknown[]) => {
|
||||
runtimeLogs.push(stringifyArgs(args));
|
||||
}),
|
||||
error: vi.fn((...args: unknown[]) => {
|
||||
runtimeErrors.push(stringifyArgs(args));
|
||||
}),
|
||||
writeStdout: vi.fn((value: string) => {
|
||||
defaultRuntime.log(normalizeStdout(value));
|
||||
}),
|
||||
writeJson: vi.fn((value: unknown, space = 2) => {
|
||||
defaultRuntime.log(stringifyJson(value, space));
|
||||
}),
|
||||
exit: vi.fn((code: number) => {
|
||||
throw new Error(`__exit__:${code}`);
|
||||
}),
|
||||
} as CliMockOutputRuntime;
|
||||
return {
|
||||
defaultRuntime,
|
||||
runtimeLogs,
|
||||
runtimeErrors,
|
||||
resetRuntimeCapture: () => {
|
||||
runtimeLogs.length = 0;
|
||||
runtimeErrors.length = 0;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export { runtimeErrors, runtimeLogs };
|
||||
|
||||
|
||||
@@ -88,6 +88,29 @@ describe("accessGroups config", () => {
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts message sender access groups for any channel", () => {
|
||||
const result = OpenClawSchema.safeParse({
|
||||
accessGroups: {
|
||||
owners: {
|
||||
type: "message.senders",
|
||||
members: {
|
||||
"*": ["global-owner"],
|
||||
telegram: ["12345"],
|
||||
discord: ["discord:67890"],
|
||||
},
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
telegram: {
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["accessGroup:owners"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugins.slots.contextEngine", () => {
|
||||
|
||||
@@ -1310,6 +1310,31 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
|
||||
required: ["type", "guildId", "channelId"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
type: {
|
||||
type: "string",
|
||||
const: "message.senders",
|
||||
},
|
||||
members: {
|
||||
type: "object",
|
||||
propertyNames: {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
},
|
||||
additionalProperties: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["type", "members"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -12,6 +12,16 @@ export type DiscordChannelAudienceAccessGroup = {
|
||||
membership?: "canViewChannel";
|
||||
};
|
||||
|
||||
export type AccessGroupConfig = DiscordChannelAudienceAccessGroup;
|
||||
export type MessageSendersAccessGroup = {
|
||||
/**
|
||||
* Static sender allowlists that can be referenced by any message channel via
|
||||
* accessGroup:<name>.
|
||||
*/
|
||||
type: "message.senders";
|
||||
/** Sender entries by channel id, plus optional "*" entries shared by all channels. */
|
||||
members: Record<string, string[]>;
|
||||
};
|
||||
|
||||
export type AccessGroupConfig = DiscordChannelAudienceAccessGroup | MessageSendersAccessGroup;
|
||||
|
||||
export type AccessGroupsConfig = Record<string, AccessGroupConfig>;
|
||||
|
||||
@@ -61,6 +61,12 @@ const AccessGroupsSchema = z
|
||||
membership: z.literal("canViewChannel").optional(),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("message.senders"),
|
||||
members: z.record(z.string().min(1), z.array(z.string().min(1))),
|
||||
})
|
||||
.strict(),
|
||||
]),
|
||||
)
|
||||
.optional();
|
||||
|
||||
126
src/plugin-sdk/access-groups.ts
Normal file
126
src/plugin-sdk/access-groups.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { ChannelId } from "../channels/plugins/types.public.js";
|
||||
import type { AccessGroupConfig } from "../config/types.access-groups.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
|
||||
export const ACCESS_GROUP_ALLOW_FROM_PREFIX = "accessGroup:";
|
||||
|
||||
export type AccessGroupMembershipResolver = (params: {
|
||||
cfg: OpenClawConfig;
|
||||
name: string;
|
||||
group: AccessGroupConfig;
|
||||
channel: ChannelId;
|
||||
accountId: string;
|
||||
senderId: string;
|
||||
}) => boolean | Promise<boolean>;
|
||||
|
||||
export function parseAccessGroupAllowFromEntry(entry: string): string | null {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed.startsWith(ACCESS_GROUP_ALLOW_FROM_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
const name = trimmed.slice(ACCESS_GROUP_ALLOW_FROM_PREFIX.length).trim();
|
||||
return name.length > 0 ? name : null;
|
||||
}
|
||||
|
||||
function resolveMessageSenderGroupEntries(params: {
|
||||
group: AccessGroupConfig;
|
||||
channel: ChannelId;
|
||||
}): string[] {
|
||||
if (params.group.type !== "message.senders") {
|
||||
return [];
|
||||
}
|
||||
return [...(params.group.members["*"] ?? []), ...(params.group.members[params.channel] ?? [])];
|
||||
}
|
||||
|
||||
export async function resolveAccessGroupAllowFromMatches(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
allowFrom: Array<string | number> | null | undefined;
|
||||
channel: ChannelId;
|
||||
accountId: string;
|
||||
senderId: string;
|
||||
isSenderAllowed?: (senderId: string, allowFrom: string[]) => boolean;
|
||||
resolveMembership?: AccessGroupMembershipResolver;
|
||||
}): Promise<string[]> {
|
||||
const cfg = params.cfg;
|
||||
const groups = cfg?.accessGroups;
|
||||
if (!groups) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const names = Array.from(
|
||||
new Set(
|
||||
(params.allowFrom ?? [])
|
||||
.map((entry) => parseAccessGroupAllowFromEntry(String(entry)))
|
||||
.filter((entry): entry is string => entry != null),
|
||||
),
|
||||
);
|
||||
if (names.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const matched: string[] = [];
|
||||
for (const name of names) {
|
||||
const group = groups[name];
|
||||
if (!group) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const senderEntries = resolveMessageSenderGroupEntries({
|
||||
group,
|
||||
channel: params.channel,
|
||||
});
|
||||
if (
|
||||
senderEntries.length > 0 &&
|
||||
params.isSenderAllowed?.(params.senderId, senderEntries) === true
|
||||
) {
|
||||
matched.push(`${ACCESS_GROUP_ALLOW_FROM_PREFIX}${name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let allowed = false;
|
||||
try {
|
||||
allowed =
|
||||
(await params.resolveMembership?.({
|
||||
cfg,
|
||||
name,
|
||||
group,
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
senderId: params.senderId,
|
||||
})) === true;
|
||||
} catch {
|
||||
allowed = false;
|
||||
}
|
||||
if (allowed) {
|
||||
matched.push(`${ACCESS_GROUP_ALLOW_FROM_PREFIX}${name}`);
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
export async function expandAllowFromWithAccessGroups(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
allowFrom: Array<string | number> | null | undefined;
|
||||
channel: ChannelId;
|
||||
accountId: string;
|
||||
senderId: string;
|
||||
senderAllowEntry?: string;
|
||||
isSenderAllowed?: (senderId: string, allowFrom: string[]) => boolean;
|
||||
resolveMembership?: AccessGroupMembershipResolver;
|
||||
}): Promise<string[]> {
|
||||
const allowFrom = (params.allowFrom ?? []).map(String);
|
||||
const matched = await resolveAccessGroupAllowFromMatches({
|
||||
cfg: params.cfg,
|
||||
allowFrom,
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
senderId: params.senderId,
|
||||
isSenderAllowed: params.isSenderAllowed,
|
||||
resolveMembership: params.resolveMembership,
|
||||
});
|
||||
if (matched.length === 0) {
|
||||
return allowFrom;
|
||||
}
|
||||
const senderEntry = params.senderAllowEntry ?? params.senderId;
|
||||
return Array.from(new Set([...allowFrom, senderEntry]));
|
||||
}
|
||||
@@ -15,9 +15,10 @@ async function resolveAuthorization(params: {
|
||||
senderId: string;
|
||||
configuredAllowFrom?: string[];
|
||||
configuredGroupAllowFrom?: string[];
|
||||
cfg?: OpenClawConfig;
|
||||
}) {
|
||||
return resolveSenderCommandAuthorization({
|
||||
cfg: baseCfg,
|
||||
cfg: params.cfg ?? baseCfg,
|
||||
rawBody: "/status",
|
||||
isGroup: true,
|
||||
dmPolicy: "pairing",
|
||||
@@ -25,6 +26,8 @@ async function resolveAuthorization(params: {
|
||||
configuredGroupAllowFrom: params.configuredGroupAllowFrom ?? ["group-owner"],
|
||||
senderId: params.senderId,
|
||||
isSenderAllowed: (senderId, allowFrom) => allowFrom.includes(senderId),
|
||||
channel: "zalouser",
|
||||
accountId: "default",
|
||||
readAllowFromStore: async () => ["paired-user"],
|
||||
shouldComputeCommandAuthorized: () => true,
|
||||
resolveCommandAuthorizedFromAuthorizers: ({ useAccessGroups, authorizers }) =>
|
||||
@@ -92,6 +95,30 @@ describe("plugin-sdk/command-auth", () => {
|
||||
expect(result.commandAuthorized).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves generic message sender access groups for group command authorization", async () => {
|
||||
const result = await resolveAuthorization({
|
||||
senderId: "group-admin",
|
||||
configuredAllowFrom: [],
|
||||
configuredGroupAllowFrom: ["accessGroup:admins"],
|
||||
cfg: {
|
||||
...baseCfg,
|
||||
accessGroups: {
|
||||
admins: {
|
||||
type: "message.senders",
|
||||
members: {
|
||||
zalouser: ["group-admin"],
|
||||
telegram: ["12345"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
});
|
||||
|
||||
expect(result.effectiveGroupAllowFrom).toEqual(["accessGroup:admins", "group-admin"]);
|
||||
expect(result.senderAllowedForCommands).toBe(true);
|
||||
expect(result.commandAuthorized).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat open DM policy as an allowlist bypass", async () => {
|
||||
const result = await resolveSenderCommandAuthorization({
|
||||
cfg: baseCfg,
|
||||
|
||||
@@ -3,8 +3,20 @@ import {
|
||||
buildCommandsMessagePaginated as buildCommandsMessagePaginatedCompat,
|
||||
buildHelpMessage as buildHelpMessageCompat,
|
||||
} from "../auto-reply/command-status-builders.js";
|
||||
import type { ChannelId } from "../channels/plugins/types.public.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { resolveDmGroupAccessWithLists } from "../security/dm-policy-shared.js";
|
||||
import {
|
||||
expandAllowFromWithAccessGroups,
|
||||
type AccessGroupMembershipResolver,
|
||||
} from "./access-groups.js";
|
||||
export {
|
||||
ACCESS_GROUP_ALLOW_FROM_PREFIX,
|
||||
expandAllowFromWithAccessGroups,
|
||||
parseAccessGroupAllowFromEntry,
|
||||
resolveAccessGroupAllowFromMatches,
|
||||
type AccessGroupMembershipResolver,
|
||||
} from "./access-groups.js";
|
||||
export { buildCommandsPaginationKeyboard } from "./telegram-command-ui.js";
|
||||
export {
|
||||
createPreCryptoDirectDmAuthorizer,
|
||||
@@ -97,6 +109,9 @@ export type ResolveSenderCommandAuthorizationParams = {
|
||||
configuredGroupAllowFrom?: string[];
|
||||
senderId: string;
|
||||
isSenderAllowed: (senderId: string, allowFrom: string[]) => boolean;
|
||||
channel?: ChannelId;
|
||||
accountId?: string;
|
||||
resolveAccessGroupMembership?: AccessGroupMembershipResolver;
|
||||
readAllowFromStore: () => Promise<string[]>;
|
||||
shouldComputeCommandAuthorized: (rawBody: string, cfg: OpenClawConfig) => boolean;
|
||||
resolveCommandAuthorizedFromAuthorizers: (params: {
|
||||
@@ -164,13 +179,51 @@ export async function resolveSenderCommandAuthorization(
|
||||
!params.isGroup && params.dmPolicy !== "allowlist" && params.dmPolicy !== "open"
|
||||
? await params.readAllowFromStore().catch(() => [])
|
||||
: [];
|
||||
const channel = params.channel;
|
||||
const accountId = params.accountId ?? "default";
|
||||
let configuredAllowFrom = params.configuredAllowFrom;
|
||||
let configuredGroupAllowFrom = params.configuredGroupAllowFrom ?? [];
|
||||
let dmStoreAllowFrom = storeAllowFrom;
|
||||
if (channel) {
|
||||
[configuredAllowFrom, configuredGroupAllowFrom] = await Promise.all([
|
||||
expandAllowFromWithAccessGroups({
|
||||
cfg: params.cfg,
|
||||
allowFrom: params.configuredAllowFrom,
|
||||
channel,
|
||||
accountId,
|
||||
senderId: params.senderId,
|
||||
isSenderAllowed: params.isSenderAllowed,
|
||||
resolveMembership: params.resolveAccessGroupMembership,
|
||||
}),
|
||||
expandAllowFromWithAccessGroups({
|
||||
cfg: params.cfg,
|
||||
allowFrom: params.configuredGroupAllowFrom ?? [],
|
||||
channel,
|
||||
accountId,
|
||||
senderId: params.senderId,
|
||||
isSenderAllowed: params.isSenderAllowed,
|
||||
resolveMembership: params.resolveAccessGroupMembership,
|
||||
}),
|
||||
]);
|
||||
if (!params.isGroup) {
|
||||
dmStoreAllowFrom = await expandAllowFromWithAccessGroups({
|
||||
cfg: params.cfg,
|
||||
allowFrom: storeAllowFrom,
|
||||
channel,
|
||||
accountId,
|
||||
senderId: params.senderId,
|
||||
isSenderAllowed: params.isSenderAllowed,
|
||||
resolveMembership: params.resolveAccessGroupMembership,
|
||||
});
|
||||
}
|
||||
}
|
||||
const access = resolveDmGroupAccessWithLists({
|
||||
isGroup: params.isGroup,
|
||||
dmPolicy: params.dmPolicy,
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: params.configuredAllowFrom,
|
||||
groupAllowFrom: params.configuredGroupAllowFrom ?? [],
|
||||
storeAllowFrom,
|
||||
allowFrom: configuredAllowFrom,
|
||||
groupAllowFrom: configuredGroupAllowFrom,
|
||||
storeAllowFrom: dmStoreAllowFrom,
|
||||
isSenderAllowed: (allowFrom) => params.isSenderAllowed(params.senderId, allowFrom),
|
||||
});
|
||||
const effectiveAllowFrom = access.effectiveAllowFrom;
|
||||
|
||||
@@ -5,6 +5,11 @@ import {
|
||||
resolveDmGroupAccessWithLists,
|
||||
type DmGroupAccessReasonCode,
|
||||
} from "../security/dm-policy-shared.js";
|
||||
import {
|
||||
expandAllowFromWithAccessGroups,
|
||||
type AccessGroupMembershipResolver,
|
||||
} from "./access-groups.js";
|
||||
export type { AccessGroupMembershipResolver } from "./access-groups.js";
|
||||
|
||||
export type DirectDmCommandAuthorizationRuntime = {
|
||||
shouldComputeCommandAuthorized: (rawBody: string, cfg: OpenClawConfig) => boolean;
|
||||
@@ -37,6 +42,7 @@ export async function resolveInboundDirectDmAccessWithRuntime(params: {
|
||||
senderId: string;
|
||||
rawBody: string;
|
||||
isSenderAllowed: (senderId: string, allowFrom: string[]) => boolean;
|
||||
resolveAccessGroupMembership?: AccessGroupMembershipResolver;
|
||||
runtime: DirectDmCommandAuthorizationRuntime;
|
||||
modeWhenAccessGroupsOff?: "allow" | "deny" | "configured";
|
||||
readStoreAllowFrom?: (provider: ChannelId, accountId: string) => Promise<string[]>;
|
||||
@@ -51,12 +57,32 @@ export async function resolveInboundDirectDmAccessWithRuntime(params: {
|
||||
readStore: params.readStoreAllowFrom,
|
||||
})
|
||||
: [];
|
||||
const [allowFrom, effectiveStoreAllowFrom] = await Promise.all([
|
||||
expandAllowFromWithAccessGroups({
|
||||
cfg: params.cfg,
|
||||
allowFrom: params.allowFrom,
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
senderId: params.senderId,
|
||||
isSenderAllowed: params.isSenderAllowed,
|
||||
resolveMembership: params.resolveAccessGroupMembership,
|
||||
}),
|
||||
expandAllowFromWithAccessGroups({
|
||||
cfg: params.cfg,
|
||||
allowFrom: storeAllowFrom,
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
senderId: params.senderId,
|
||||
isSenderAllowed: params.isSenderAllowed,
|
||||
resolveMembership: params.resolveAccessGroupMembership,
|
||||
}),
|
||||
]);
|
||||
|
||||
const access = resolveDmGroupAccessWithLists({
|
||||
isGroup: false,
|
||||
dmPolicy,
|
||||
allowFrom: params.allowFrom,
|
||||
storeAllowFrom,
|
||||
allowFrom,
|
||||
storeAllowFrom: effectiveStoreAllowFrom,
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
isSenderAllowed: (allowEntries) => params.isSenderAllowed(params.senderId, allowEntries),
|
||||
});
|
||||
|
||||
@@ -93,6 +93,39 @@ describe("plugin-sdk/direct-dm", () => {
|
||||
expect(result.commandAuthorized).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves generic message sender access groups for direct DMs", async () => {
|
||||
const result = await resolveInboundDirectDmAccessWithRuntime({
|
||||
cfg: {
|
||||
...baseCfg,
|
||||
accessGroups: {
|
||||
owners: {
|
||||
type: "message.senders",
|
||||
members: {
|
||||
nostr: ["owner-pubkey"],
|
||||
telegram: ["12345"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
channel: "nostr",
|
||||
accountId: "default",
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["accessGroup:owners"],
|
||||
senderId: "owner-pubkey",
|
||||
rawBody: "/status",
|
||||
isSenderAllowed: (senderId, allowFrom) => allowFrom.includes(senderId),
|
||||
runtime: {
|
||||
shouldComputeCommandAuthorized: () => true,
|
||||
resolveCommandAuthorizedFromAuthorizers: ({ authorizers }) =>
|
||||
authorizers.some((entry) => entry.configured && entry.allowed),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.access.decision).toBe("allow");
|
||||
expect(result.access.effectiveAllowFrom).toEqual(["accessGroup:owners", "owner-pubkey"]);
|
||||
expect(result.commandAuthorized).toBe(true);
|
||||
});
|
||||
|
||||
it("creates a pre-crypto authorizer that issues pairing and blocks unknown senders", async () => {
|
||||
const issuePairingChallenge = vi.fn(async () => {});
|
||||
const onBlocked = vi.fn();
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { OutboundReplyPayload } from "./reply-payload.js";
|
||||
export {
|
||||
createPreCryptoDirectDmAuthorizer,
|
||||
resolveInboundDirectDmAccessWithRuntime,
|
||||
type AccessGroupMembershipResolver,
|
||||
type DirectDmCommandAuthorizationRuntime,
|
||||
type ResolvedInboundDirectDmAccess,
|
||||
} from "./direct-dm-access.js";
|
||||
|
||||
@@ -7,6 +7,13 @@ export type * from "../secrets/target-registry-types.js";
|
||||
export * from "../security/channel-metadata.js";
|
||||
export * from "../security/context-visibility.js";
|
||||
export * from "../security/dm-policy-shared.js";
|
||||
export {
|
||||
ACCESS_GROUP_ALLOW_FROM_PREFIX,
|
||||
expandAllowFromWithAccessGroups,
|
||||
parseAccessGroupAllowFromEntry,
|
||||
resolveAccessGroupAllowFromMatches,
|
||||
type AccessGroupMembershipResolver,
|
||||
} from "./access-groups.js";
|
||||
export * from "../security/external-content.js";
|
||||
export * from "../security/safe-regex.js";
|
||||
export {
|
||||
|
||||
Reference in New Issue
Block a user