refactor: share matched group policy evaluation

This commit is contained in:
Peter Steinberger
2026-03-07 23:39:04 +00:00
parent f319ec2dac
commit b0d9246768
8 changed files with 293 additions and 28 deletions

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { resolveNextcloudTalkAllowlistMatch } from "./policy.js"; import { resolveNextcloudTalkAllowlistMatch, resolveNextcloudTalkGroupAllow } from "./policy.js";
describe("nextcloud-talk policy", () => { describe("nextcloud-talk policy", () => {
describe("resolveNextcloudTalkAllowlistMatch", () => { describe("resolveNextcloudTalkAllowlistMatch", () => {
@@ -30,4 +30,109 @@ describe("nextcloud-talk policy", () => {
).toBe(false); ).toBe(false);
}); });
}); });
describe("resolveNextcloudTalkGroupAllow", () => {
it("blocks disabled policy", () => {
expect(
resolveNextcloudTalkGroupAllow({
groupPolicy: "disabled",
outerAllowFrom: ["owner"],
innerAllowFrom: ["room-user"],
senderId: "owner",
}),
).toEqual({
allowed: false,
outerMatch: { allowed: false },
innerMatch: { allowed: false },
});
});
it("allows open policy", () => {
expect(
resolveNextcloudTalkGroupAllow({
groupPolicy: "open",
outerAllowFrom: [],
innerAllowFrom: [],
senderId: "owner",
}),
).toEqual({
allowed: true,
outerMatch: { allowed: true },
innerMatch: { allowed: true },
});
});
it("blocks allowlist mode when both outer and inner allowlists are empty", () => {
expect(
resolveNextcloudTalkGroupAllow({
groupPolicy: "allowlist",
outerAllowFrom: [],
innerAllowFrom: [],
senderId: "owner",
}),
).toEqual({
allowed: false,
outerMatch: { allowed: false },
innerMatch: { allowed: false },
});
});
it("requires inner match when only room-specific allowlist is configured", () => {
expect(
resolveNextcloudTalkGroupAllow({
groupPolicy: "allowlist",
outerAllowFrom: [],
innerAllowFrom: ["room-user"],
senderId: "room-user",
}),
).toEqual({
allowed: true,
outerMatch: { allowed: false },
innerMatch: { allowed: true, matchKey: "room-user", matchSource: "id" },
});
});
it("blocks when outer allowlist misses even if inner allowlist matches", () => {
expect(
resolveNextcloudTalkGroupAllow({
groupPolicy: "allowlist",
outerAllowFrom: ["team-owner"],
innerAllowFrom: ["room-user"],
senderId: "room-user",
}),
).toEqual({
allowed: false,
outerMatch: { allowed: false },
innerMatch: { allowed: true, matchKey: "room-user", matchSource: "id" },
});
});
it("allows when both outer and inner allowlists match", () => {
expect(
resolveNextcloudTalkGroupAllow({
groupPolicy: "allowlist",
outerAllowFrom: ["team-owner"],
innerAllowFrom: ["room-user"],
senderId: "team-owner",
}),
).toEqual({
allowed: false,
outerMatch: { allowed: true, matchKey: "team-owner", matchSource: "id" },
innerMatch: { allowed: false },
});
expect(
resolveNextcloudTalkGroupAllow({
groupPolicy: "allowlist",
outerAllowFrom: ["shared-user"],
innerAllowFrom: ["shared-user"],
senderId: "shared-user",
}),
).toEqual({
allowed: true,
outerMatch: { allowed: true, matchKey: "shared-user", matchSource: "id" },
innerMatch: { allowed: true, matchKey: "shared-user", matchSource: "id" },
});
});
});
}); });

View File

@@ -6,6 +6,7 @@ import type {
} from "openclaw/plugin-sdk/nextcloud-talk"; } from "openclaw/plugin-sdk/nextcloud-talk";
import { import {
buildChannelKeyCandidates, buildChannelKeyCandidates,
evaluateMatchedGroupAccessForPolicy,
normalizeChannelSlug, normalizeChannelSlug,
resolveChannelEntryMatchWithFallback, resolveChannelEntryMatchWithFallback,
resolveMentionGatingWithBypass, resolveMentionGatingWithBypass,
@@ -128,19 +129,8 @@ export function resolveNextcloudTalkGroupAllow(params: {
innerAllowFrom: Array<string | number> | undefined; innerAllowFrom: Array<string | number> | undefined;
senderId: string; senderId: string;
}): { allowed: boolean; outerMatch: AllowlistMatch; innerMatch: AllowlistMatch } { }): { allowed: boolean; outerMatch: AllowlistMatch; innerMatch: AllowlistMatch } {
if (params.groupPolicy === "disabled") {
return { allowed: false, outerMatch: { allowed: false }, innerMatch: { allowed: false } };
}
if (params.groupPolicy === "open") {
return { allowed: true, outerMatch: { allowed: true }, innerMatch: { allowed: true } };
}
const outerAllow = normalizeNextcloudTalkAllowlist(params.outerAllowFrom); const outerAllow = normalizeNextcloudTalkAllowlist(params.outerAllowFrom);
const innerAllow = normalizeNextcloudTalkAllowlist(params.innerAllowFrom); const innerAllow = normalizeNextcloudTalkAllowlist(params.innerAllowFrom);
if (outerAllow.length === 0 && innerAllow.length === 0) {
return { allowed: false, outerMatch: { allowed: false }, innerMatch: { allowed: false } };
}
const outerMatch = resolveNextcloudTalkAllowlistMatch({ const outerMatch = resolveNextcloudTalkAllowlistMatch({
allowFrom: params.outerAllowFrom, allowFrom: params.outerAllowFrom,
senderId: params.senderId, senderId: params.senderId,
@@ -149,14 +139,32 @@ export function resolveNextcloudTalkGroupAllow(params: {
allowFrom: params.innerAllowFrom, allowFrom: params.innerAllowFrom,
senderId: params.senderId, senderId: params.senderId,
}); });
const allowed = resolveNestedAllowlistDecision({ const access = evaluateMatchedGroupAccessForPolicy({
outerConfigured: outerAllow.length > 0 || innerAllow.length > 0, groupPolicy: params.groupPolicy,
outerMatched: outerAllow.length > 0 ? outerMatch.allowed : true, allowlistConfigured: outerAllow.length > 0 || innerAllow.length > 0,
innerConfigured: innerAllow.length > 0, allowlistMatched: resolveNestedAllowlistDecision({
innerMatched: innerMatch.allowed, outerConfigured: outerAllow.length > 0 || innerAllow.length > 0,
outerMatched: outerAllow.length > 0 ? outerMatch.allowed : true,
innerConfigured: innerAllow.length > 0,
innerMatched: innerMatch.allowed,
}),
}); });
return { allowed, outerMatch, innerMatch }; return {
allowed: access.allowed,
outerMatch:
params.groupPolicy === "open"
? { allowed: true }
: params.groupPolicy === "disabled"
? { allowed: false }
: outerMatch,
innerMatch:
params.groupPolicy === "open"
? { allowed: true }
: params.groupPolicy === "disabled"
? { allowed: false }
: innerMatch,
};
} }
export function resolveNextcloudTalkMentionGate(params: { export function resolveNextcloudTalkMentionGate(params: {

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
evaluateGroupRouteAccessForPolicy, evaluateGroupRouteAccessForPolicy,
evaluateMatchedGroupAccessForPolicy,
evaluateSenderGroupAccess, evaluateSenderGroupAccess,
evaluateSenderGroupAccessForPolicy, evaluateSenderGroupAccessForPolicy,
resolveSenderScopedGroupPolicy, resolveSenderScopedGroupPolicy,
@@ -120,6 +121,64 @@ describe("evaluateGroupRouteAccessForPolicy", () => {
}); });
}); });
describe("evaluateMatchedGroupAccessForPolicy", () => {
it("blocks disabled policy", () => {
expect(
evaluateMatchedGroupAccessForPolicy({
groupPolicy: "disabled",
allowlistConfigured: true,
allowlistMatched: true,
}),
).toEqual({
allowed: false,
groupPolicy: "disabled",
reason: "disabled",
});
});
it("blocks allowlist without configured entries", () => {
expect(
evaluateMatchedGroupAccessForPolicy({
groupPolicy: "allowlist",
allowlistConfigured: false,
allowlistMatched: false,
}),
).toEqual({
allowed: false,
groupPolicy: "allowlist",
reason: "empty_allowlist",
});
});
it("blocks unmatched allowlist sender", () => {
expect(
evaluateMatchedGroupAccessForPolicy({
groupPolicy: "allowlist",
allowlistConfigured: true,
allowlistMatched: false,
}),
).toEqual({
allowed: false,
groupPolicy: "allowlist",
reason: "not_allowlisted",
});
});
it("allows open policy", () => {
expect(
evaluateMatchedGroupAccessForPolicy({
groupPolicy: "open",
allowlistConfigured: false,
allowlistMatched: false,
}),
).toEqual({
allowed: true,
groupPolicy: "open",
reason: "allowed",
});
});
});
describe("evaluateSenderGroupAccess", () => { describe("evaluateSenderGroupAccess", () => {
it("defaults missing provider config to allowlist", () => { it("defaults missing provider config to allowlist", () => {
const decision = evaluateSenderGroupAccess({ const decision = evaluateSenderGroupAccess({

View File

@@ -27,6 +27,18 @@ export type GroupRouteAccessDecision = {
reason: GroupRouteAccessReason; reason: GroupRouteAccessReason;
}; };
export type MatchedGroupAccessReason =
| "allowed"
| "disabled"
| "empty_allowlist"
| "not_allowlisted";
export type MatchedGroupAccessDecision = {
allowed: boolean;
groupPolicy: GroupPolicy;
reason: MatchedGroupAccessReason;
};
export function resolveSenderScopedGroupPolicy(params: { export function resolveSenderScopedGroupPolicy(params: {
groupPolicy: GroupPolicy; groupPolicy: GroupPolicy;
groupAllowFrom: string[]; groupAllowFrom: string[];
@@ -83,6 +95,43 @@ export function evaluateGroupRouteAccessForPolicy(params: {
}; };
} }
export function evaluateMatchedGroupAccessForPolicy(params: {
groupPolicy: GroupPolicy;
allowlistConfigured: boolean;
allowlistMatched: boolean;
}): MatchedGroupAccessDecision {
if (params.groupPolicy === "disabled") {
return {
allowed: false,
groupPolicy: params.groupPolicy,
reason: "disabled",
};
}
if (params.groupPolicy === "allowlist") {
if (!params.allowlistConfigured) {
return {
allowed: false,
groupPolicy: params.groupPolicy,
reason: "empty_allowlist",
};
}
if (!params.allowlistMatched) {
return {
allowed: false,
groupPolicy: params.groupPolicy,
reason: "not_allowlisted",
};
}
}
return {
allowed: true,
groupPolicy: params.groupPolicy,
reason: "allowed",
};
}
export function evaluateSenderGroupAccessForPolicy(params: { export function evaluateSenderGroupAccessForPolicy(params: {
groupPolicy: GroupPolicy; groupPolicy: GroupPolicy;
providerMissingFallbackApplied?: boolean; providerMissingFallbackApplied?: boolean;

View File

@@ -280,11 +280,14 @@ export {
} from "./allow-from.js"; } from "./allow-from.js";
export { export {
evaluateGroupRouteAccessForPolicy, evaluateGroupRouteAccessForPolicy,
evaluateMatchedGroupAccessForPolicy,
evaluateSenderGroupAccess, evaluateSenderGroupAccess,
evaluateSenderGroupAccessForPolicy, evaluateSenderGroupAccessForPolicy,
resolveSenderScopedGroupPolicy, resolveSenderScopedGroupPolicy,
type GroupRouteAccessDecision, type GroupRouteAccessDecision,
type GroupRouteAccessReason, type GroupRouteAccessReason,
type MatchedGroupAccessDecision,
type MatchedGroupAccessReason,
type SenderGroupAccessDecision, type SenderGroupAccessDecision,
type SenderGroupAccessReason, type SenderGroupAccessReason,
} from "./group-access.js"; } from "./group-access.js";

View File

@@ -37,6 +37,7 @@ export type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
export { createReplyPrefixOptions } from "../channels/reply-prefix.js"; export { createReplyPrefixOptions } from "../channels/reply-prefix.js";
export type { OpenClawConfig } from "../config/config.js"; export type { OpenClawConfig } from "../config/config.js";
export { mapAllowFromEntries } from "./channel-config-helpers.js"; export { mapAllowFromEntries } from "./channel-config-helpers.js";
export { evaluateMatchedGroupAccessForPolicy } from "./group-access.js";
export { export {
GROUP_POLICY_BLOCKED_LABEL, GROUP_POLICY_BLOCKED_LABEL,
resolveAllowlistProviderRuntimeGroupPolicy, resolveAllowlistProviderRuntimeGroupPolicy,

View File

@@ -388,6 +388,38 @@ describe("security/dm-policy-shared", () => {
}); });
for (const channel of channels) { for (const channel of channels) {
it(`[${channel}] blocks groups when group allowlist is empty`, () => {
const decision = resolveDmGroupAccessDecision({
isGroup: true,
dmPolicy: "pairing",
groupPolicy: "allowlist",
effectiveAllowFrom: ["owner"],
effectiveGroupAllowFrom: [],
isSenderAllowed: () => false,
});
expect(decision).toEqual({
decision: "block",
reasonCode: DM_GROUP_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST,
reason: "groupPolicy=allowlist (empty allowlist)",
});
});
it(`[${channel}] allows groups when group policy is open`, () => {
const decision = resolveDmGroupAccessDecision({
isGroup: true,
dmPolicy: "pairing",
groupPolicy: "open",
effectiveAllowFrom: ["owner"],
effectiveGroupAllowFrom: [],
isSenderAllowed: () => false,
});
expect(decision).toEqual({
decision: "allow",
reasonCode: DM_GROUP_ACCESS_REASON.GROUP_POLICY_ALLOWED,
reason: "groupPolicy=open",
});
});
it(`[${channel}] blocks DM allowlist mode when allowlist is empty`, () => { it(`[${channel}] blocks DM allowlist mode when allowlist is empty`, () => {
const decision = resolveDmGroupAccessDecision({ const decision = resolveDmGroupAccessDecision({
isGroup: false, isGroup: false,

View File

@@ -2,6 +2,7 @@ import { mergeDmAllowFromSources, resolveGroupAllowFromSources } from "../channe
import { resolveControlCommandGate } from "../channels/command-gating.js"; import { resolveControlCommandGate } from "../channels/command-gating.js";
import type { ChannelId } from "../channels/plugins/types.js"; import type { ChannelId } from "../channels/plugins/types.js";
import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
import { evaluateMatchedGroupAccessForPolicy } from "../plugin-sdk/group-access.js";
import { normalizeStringEntries } from "../shared/string-normalization.js"; import { normalizeStringEntries } from "../shared/string-normalization.js";
export function resolvePinnedMainDmOwnerFromAllowlist(params: { export function resolvePinnedMainDmOwnerFromAllowlist(params: {
@@ -118,22 +119,28 @@ export function resolveDmGroupAccessDecision(params: {
const effectiveGroupAllowFrom = normalizeStringEntries(params.effectiveGroupAllowFrom); const effectiveGroupAllowFrom = normalizeStringEntries(params.effectiveGroupAllowFrom);
if (params.isGroup) { if (params.isGroup) {
if (groupPolicy === "disabled") { const groupAccess = evaluateMatchedGroupAccessForPolicy({
return { groupPolicy,
decision: "block", allowlistConfigured: effectiveGroupAllowFrom.length > 0,
reasonCode: DM_GROUP_ACCESS_REASON.GROUP_POLICY_DISABLED, allowlistMatched: params.isSenderAllowed(effectiveGroupAllowFrom),
reason: "groupPolicy=disabled", });
};
} if (!groupAccess.allowed) {
if (groupPolicy === "allowlist") { if (groupAccess.reason === "disabled") {
if (effectiveGroupAllowFrom.length === 0) { return {
decision: "block",
reasonCode: DM_GROUP_ACCESS_REASON.GROUP_POLICY_DISABLED,
reason: "groupPolicy=disabled",
};
}
if (groupAccess.reason === "empty_allowlist") {
return { return {
decision: "block", decision: "block",
reasonCode: DM_GROUP_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST, reasonCode: DM_GROUP_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST,
reason: "groupPolicy=allowlist (empty allowlist)", reason: "groupPolicy=allowlist (empty allowlist)",
}; };
} }
if (!params.isSenderAllowed(effectiveGroupAllowFrom)) { if (groupAccess.reason === "not_allowlisted") {
return { return {
decision: "block", decision: "block",
reasonCode: DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED, reasonCode: DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED,
@@ -141,6 +148,7 @@ export function resolveDmGroupAccessDecision(params: {
}; };
} }
} }
return { return {
decision: "allow", decision: "allow",
reasonCode: DM_GROUP_ACCESS_REASON.GROUP_POLICY_ALLOWED, reasonCode: DM_GROUP_ACCESS_REASON.GROUP_POLICY_ALLOWED,