Files
openclaw/extensions/slack/src/monitor/auth.ts
2026-03-16 21:16:32 -07:00

287 lines
8.4 KiB
TypeScript

import { readStoreAllowFromForDmPolicy } from "openclaw/plugin-sdk/security-runtime";
import {
allowListMatches,
normalizeAllowList,
normalizeAllowListLower,
resolveSlackUserAllowed,
} from "./allow-list.js";
import { resolveSlackChannelConfig } from "./channel-config.js";
import { normalizeSlackChannelType, type SlackMonitorContext } from "./context.js";
type ResolvedAllowFromLists = {
allowFrom: string[];
allowFromLower: string[];
};
type SlackAllowFromCacheState = {
baseSignature?: string;
base?: ResolvedAllowFromLists;
pairingKey?: string;
pairing?: ResolvedAllowFromLists;
pairingExpiresAtMs?: number;
pairingPending?: Promise<ResolvedAllowFromLists>;
};
let slackAllowFromCache = new WeakMap<SlackMonitorContext, SlackAllowFromCacheState>();
const DEFAULT_PAIRING_ALLOW_FROM_CACHE_TTL_MS = 5000;
function getPairingAllowFromCacheTtlMs(): number {
const raw = process.env.OPENCLAW_SLACK_PAIRING_ALLOWFROM_CACHE_TTL_MS?.trim();
if (!raw) {
return DEFAULT_PAIRING_ALLOW_FROM_CACHE_TTL_MS;
}
const parsed = Number(raw);
if (!Number.isFinite(parsed)) {
return DEFAULT_PAIRING_ALLOW_FROM_CACHE_TTL_MS;
}
return Math.max(0, Math.floor(parsed));
}
function getAllowFromCacheState(ctx: SlackMonitorContext): SlackAllowFromCacheState {
const existing = slackAllowFromCache.get(ctx);
if (existing) {
return existing;
}
const next: SlackAllowFromCacheState = {};
slackAllowFromCache.set(ctx, next);
return next;
}
function buildBaseAllowFrom(ctx: SlackMonitorContext): ResolvedAllowFromLists {
const allowFrom = normalizeAllowList(ctx.allowFrom);
return {
allowFrom,
allowFromLower: normalizeAllowListLower(allowFrom),
};
}
export async function resolveSlackEffectiveAllowFrom(
ctx: SlackMonitorContext,
options?: { includePairingStore?: boolean },
) {
const includePairingStore = options?.includePairingStore === true;
const cache = getAllowFromCacheState(ctx);
const baseSignature = JSON.stringify(ctx.allowFrom);
if (cache.baseSignature !== baseSignature || !cache.base) {
cache.baseSignature = baseSignature;
cache.base = buildBaseAllowFrom(ctx);
cache.pairing = undefined;
cache.pairingKey = undefined;
cache.pairingExpiresAtMs = undefined;
cache.pairingPending = undefined;
}
if (!includePairingStore) {
return cache.base;
}
const ttlMs = getPairingAllowFromCacheTtlMs();
const nowMs = Date.now();
const pairingKey = `${ctx.accountId}:${ctx.dmPolicy}`;
if (
ttlMs > 0 &&
cache.pairing &&
cache.pairingKey === pairingKey &&
(cache.pairingExpiresAtMs ?? 0) >= nowMs
) {
return cache.pairing;
}
if (cache.pairingPending && cache.pairingKey === pairingKey) {
return await cache.pairingPending;
}
const pairingPending = (async (): Promise<ResolvedAllowFromLists> => {
let storeAllowFrom: string[] = [];
try {
const resolved = await readStoreAllowFromForDmPolicy({
provider: "slack",
accountId: ctx.accountId,
dmPolicy: ctx.dmPolicy,
});
storeAllowFrom = Array.isArray(resolved) ? resolved : [];
} catch {
storeAllowFrom = [];
}
const allowFrom = normalizeAllowList([...(cache.base?.allowFrom ?? []), ...storeAllowFrom]);
return {
allowFrom,
allowFromLower: normalizeAllowListLower(allowFrom),
};
})();
cache.pairingKey = pairingKey;
cache.pairingPending = pairingPending;
try {
const resolved = await pairingPending;
if (ttlMs > 0) {
cache.pairing = resolved;
cache.pairingExpiresAtMs = nowMs + ttlMs;
} else {
cache.pairing = undefined;
cache.pairingExpiresAtMs = undefined;
}
return resolved;
} finally {
if (cache.pairingPending === pairingPending) {
cache.pairingPending = undefined;
}
}
}
export function clearSlackAllowFromCacheForTest(): void {
slackAllowFromCache = new WeakMap<SlackMonitorContext, SlackAllowFromCacheState>();
}
export function isSlackSenderAllowListed(params: {
allowListLower: string[];
senderId: string;
senderName?: string;
allowNameMatching?: boolean;
}) {
const { allowListLower, senderId, senderName, allowNameMatching } = params;
return (
allowListLower.length === 0 ||
allowListMatches({
allowList: allowListLower,
id: senderId,
name: senderName,
allowNameMatching,
})
);
}
export type SlackSystemEventAuthResult = {
allowed: boolean;
reason?:
| "missing-sender"
| "sender-mismatch"
| "channel-not-allowed"
| "dm-disabled"
| "sender-not-allowlisted"
| "sender-not-channel-allowed";
channelType?: "im" | "mpim" | "channel" | "group";
channelName?: string;
};
export async function authorizeSlackSystemEventSender(params: {
ctx: SlackMonitorContext;
senderId?: string;
channelId?: string;
channelType?: string | null;
expectedSenderId?: string;
}): Promise<SlackSystemEventAuthResult> {
const senderId = params.senderId?.trim();
if (!senderId) {
return { allowed: false, reason: "missing-sender" };
}
const expectedSenderId = params.expectedSenderId?.trim();
if (expectedSenderId && expectedSenderId !== senderId) {
return { allowed: false, reason: "sender-mismatch" };
}
const channelId = params.channelId?.trim();
let channelType = normalizeSlackChannelType(params.channelType, channelId);
let channelName: string | undefined;
if (channelId) {
const info: {
name?: string;
type?: "im" | "mpim" | "channel" | "group";
} = await params.ctx.resolveChannelName(channelId).catch(() => ({}));
channelName = info.name;
channelType = normalizeSlackChannelType(params.channelType ?? info.type, channelId);
if (
!params.ctx.isChannelAllowed({
channelId,
channelName,
channelType,
})
) {
return {
allowed: false,
reason: "channel-not-allowed",
channelType,
channelName,
};
}
}
const senderInfo: { name?: string } = await params.ctx
.resolveUserName(senderId)
.catch(() => ({}));
const senderName = senderInfo.name;
const resolveAllowFromLower = async (includePairingStore = false) =>
(await resolveSlackEffectiveAllowFrom(params.ctx, { includePairingStore })).allowFromLower;
if (channelType === "im") {
if (!params.ctx.dmEnabled || params.ctx.dmPolicy === "disabled") {
return { allowed: false, reason: "dm-disabled", channelType, channelName };
}
if (params.ctx.dmPolicy !== "open") {
const allowFromLower = await resolveAllowFromLower(true);
const senderAllowListed = isSlackSenderAllowListed({
allowListLower: allowFromLower,
senderId,
senderName,
allowNameMatching: params.ctx.allowNameMatching,
});
if (!senderAllowListed) {
return {
allowed: false,
reason: "sender-not-allowlisted",
channelType,
channelName,
};
}
}
} else if (!channelId) {
// No channel context. Apply allowFrom if configured so we fail closed
// for privileged interactive events when owner allowlist is present.
const allowFromLower = await resolveAllowFromLower(false);
if (allowFromLower.length > 0) {
const senderAllowListed = isSlackSenderAllowListed({
allowListLower: allowFromLower,
senderId,
senderName,
allowNameMatching: params.ctx.allowNameMatching,
});
if (!senderAllowListed) {
return { allowed: false, reason: "sender-not-allowlisted" };
}
}
} else {
const channelConfig = resolveSlackChannelConfig({
channelId,
channelName,
channels: params.ctx.channelsConfig,
channelKeys: params.ctx.channelsConfigKeys,
defaultRequireMention: params.ctx.defaultRequireMention,
allowNameMatching: params.ctx.allowNameMatching,
});
const channelUsersAllowlistConfigured =
Array.isArray(channelConfig?.users) && channelConfig.users.length > 0;
if (channelUsersAllowlistConfigured) {
const channelUserAllowed = resolveSlackUserAllowed({
allowList: channelConfig?.users,
userId: senderId,
userName: senderName,
allowNameMatching: params.ctx.allowNameMatching,
});
if (!channelUserAllowed) {
return {
allowed: false,
reason: "sender-not-channel-allowed",
channelType,
channelName,
};
}
}
}
return {
allowed: true,
channelType,
channelName,
};
}