mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-11 11:43:17 +08:00
feat: add unified IM access policies
This commit is contained in:
parent
075d2713c1
commit
840e5aa857
89 changed files with 5573 additions and 1676 deletions
219
test/access-policy-host.test.mjs
Normal file
219
test/access-policy-host.test.mjs
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
accessPolicyProvider,
|
||||
initialAccessPolicyFor,
|
||||
privilegedSenderIdsFor,
|
||||
} from '../plugin-src/host/channels/shared/access-policy-production.mjs';
|
||||
import {
|
||||
SET_ACCESS_POLICY_ENDPOINT,
|
||||
validAccessPolicyPayload,
|
||||
} from '../plugin-src/host/channels/shared/access-policy-rpc.mjs';
|
||||
import { createWeixinRpcHandler, WEIXIN_ENDPOINTS } from '../plugin-src/host/channels/weixin/rpc.mjs';
|
||||
import { createFeishuRpcHandler, FEISHU_ENDPOINTS } from '../plugin-src/host/channels/feishu/rpc.mjs';
|
||||
import { createDingtalkRpcHandler, DINGTALK_ENDPOINTS } from '../plugin-src/host/channels/dingtalk/rpc.mjs';
|
||||
import { createWecomRpcHandler, WECOM_ENDPOINTS } from '../plugin-src/host/channels/wecom/rpc.mjs';
|
||||
import { createQqRpcHandler, QQ_ENDPOINTS } from '../plugin-src/host/channels/qq/rpc.mjs';
|
||||
import { createSlackRpcHandler, SLACK_ENDPOINTS } from '../plugin-src/host/channels/slack/rpc.mjs';
|
||||
import { createTelegramRpcHandler, TELEGRAM_ENDPOINTS } from '../plugin-src/host/channels/telegram/rpc.mjs';
|
||||
import { createDiscordRpcHandler, DISCORD_ENDPOINTS } from '../plugin-src/host/channels/discord/rpc.mjs';
|
||||
import { createWhatsappRpcHandler, WHATSAPP_ENDPOINTS } from '../plugin-src/host/channels/whatsapp/rpc.mjs';
|
||||
|
||||
const users = (values = []) => values.map((value) => (
|
||||
value && typeof value === 'object'
|
||||
? value
|
||||
: { id: value, canExecuteCommands: true }
|
||||
));
|
||||
const scope = ({
|
||||
mode,
|
||||
defaultCanExecuteCommands,
|
||||
commandPermissionOverrides = [],
|
||||
allowlistUsers = [],
|
||||
}) => ({
|
||||
mode,
|
||||
open: {
|
||||
defaultCanExecuteCommands,
|
||||
commandPermissionOverrides: users(commandPermissionOverrides),
|
||||
},
|
||||
allowlist: { users: users(allowlistUsers) },
|
||||
});
|
||||
const open = (allowlistUsers = [], defaultCanExecuteCommands = true) => scope({
|
||||
mode: 'open', defaultCanExecuteCommands, allowlistUsers,
|
||||
});
|
||||
const allowlist = (allowlistUsers = []) => scope({
|
||||
mode: 'allowlist', defaultCanExecuteCommands: false, allowlistUsers,
|
||||
});
|
||||
const policy = (direct = open(), group = open()) => ({ direct, group });
|
||||
|
||||
test('Host initialization preserves the nine channel access baselines and legacy migrations', () => {
|
||||
assert.deepEqual(initialAccessPolicyFor('weixin', { ownerUserId: 'wx-owner' }),
|
||||
policy(allowlist(), allowlist()));
|
||||
assert.deepEqual(initialAccessPolicyFor('feishu', { ownerOpenIds: ['ou_owner'] }),
|
||||
policy(allowlist(), allowlist()));
|
||||
assert.deepEqual(initialAccessPolicyFor('feishu', { ownerOpenIds: ['*'] }), policy());
|
||||
for (const channel of ['dingtalk', 'wecom', 'slack', 'discord']) {
|
||||
assert.deepEqual(initialAccessPolicyFor(channel), policy());
|
||||
}
|
||||
assert.deepEqual(initialAccessPolicyFor('qq', { ownerUserOpenid: 'qq-owner' }),
|
||||
policy(allowlist(), open()));
|
||||
assert.deepEqual(initialAccessPolicyFor('qq', { ownerUserOpenid: '*' }), policy());
|
||||
assert.deepEqual(initialAccessPolicyFor('telegram', {
|
||||
accessMode: 'compatible', allowedUsers: ['101'],
|
||||
}), policy(open(['101']), open()));
|
||||
assert.deepEqual(initialAccessPolicyFor('telegram', {
|
||||
accessMode: 'private-allowlist', allowedUsers: ['101'],
|
||||
}), policy(allowlist(['101']), allowlist()));
|
||||
assert.deepEqual(initialAccessPolicyFor('whatsapp', {
|
||||
accessMode: 'self-only', accountJid: '886900000000@s.whatsapp.net',
|
||||
}), policy(allowlist(), allowlist()));
|
||||
assert.deepEqual(initialAccessPolicyFor('whatsapp', {
|
||||
accessMode: 'private-allowlist',
|
||||
accountJid: '886900000000@lid',
|
||||
allowedNumbers: ['16505550999'],
|
||||
}), policy(allowlist(['16505550999@s.whatsapp.net']), allowlist()));
|
||||
assert.deepEqual(initialAccessPolicyFor('whatsapp', {
|
||||
accessMode: 'open', allowedNumbers: ['16505550999'],
|
||||
}), policy(open(['16505550999@s.whatsapp.net']), open()));
|
||||
});
|
||||
|
||||
test('Host-only owner identities are not copied into public access-policy rows', () => {
|
||||
const cases = [
|
||||
['weixin', { ownerUserId: 'wx-private-owner' }, 'wx-private-owner'],
|
||||
['feishu', { ownerOpenIds: ['ou_private_owner'] }, 'ou_private_owner'],
|
||||
['qq', { ownerUserOpenid: 'qq-private-owner' }, 'qq-private-owner'],
|
||||
['whatsapp', {
|
||||
accessMode: 'private-allowlist',
|
||||
accountJid: '886900000000@lid',
|
||||
allowedNumbers: ['16505550999'],
|
||||
}, '886900000000@lid'],
|
||||
];
|
||||
for (const [channel, config, ownerId] of cases) {
|
||||
assert.equal(JSON.stringify(initialAccessPolicyFor(channel, config)).includes(ownerId), false, channel);
|
||||
}
|
||||
});
|
||||
|
||||
test('Host access provider reads the latest committed workspace policy', () => {
|
||||
let current = policy(allowlist(), allowlist());
|
||||
const provider = accessPolicyProvider({ accessPolicyFor: () => current }, 'bot_one', {
|
||||
channel: 'feishu', config: { ownerOpenIds: ['ou_owner', '*'] },
|
||||
});
|
||||
assert.equal(provider.botId, 'bot_one');
|
||||
assert.equal(provider.getSettings(), current);
|
||||
current = policy();
|
||||
assert.equal(provider.getSettings(), current);
|
||||
assert.equal(provider.isPrivileged(['ou_owner'], 'direct'), true);
|
||||
assert.equal(provider.isPrivileged(['*'], 'group'), false);
|
||||
assert.equal(provider.isPrivileged(['ou_other'], 'direct'), false);
|
||||
assert.equal(provider.isPrivileged(['ou_owner'], 'unknown'), false);
|
||||
const whatsapp = accessPolicyProvider({ accessPolicyFor: () => current }, 'bot_wa', {
|
||||
channel: 'whatsapp',
|
||||
config: { accountJid: '16505550100@s.whatsapp.net' },
|
||||
equals: (left, right) => left.split('@')[0] === right.split('@')[0],
|
||||
});
|
||||
assert.equal(whatsapp.isPrivileged(['16505550100@lid'], 'group'), true);
|
||||
});
|
||||
|
||||
test('Host privileged identities come only from durable owner or legacy authorization fields', () => {
|
||||
assert.deepEqual(privilegedSenderIdsFor('weixin', { ownerUserId: 'wx-owner' }), ['wx-owner']);
|
||||
assert.deepEqual(privilegedSenderIdsFor('feishu', { ownerOpenIds: ['*', 'ou_owner'] }), ['ou_owner']);
|
||||
assert.deepEqual(privilegedSenderIdsFor('dingtalk', {
|
||||
approvedSenders: [{ staffId: 'ding-owner' }, { staffId: 'ding-owner' }],
|
||||
}), ['ding-owner']);
|
||||
assert.deepEqual(privilegedSenderIdsFor('qq', { ownerUserOpenid: '*' }), []);
|
||||
assert.deepEqual(privilegedSenderIdsFor('qq', { ownerUserOpenid: 'qq-owner' }), ['qq-owner']);
|
||||
assert.deepEqual(privilegedSenderIdsFor('whatsapp', {
|
||||
accountJid: '886900000000@s.whatsapp.net',
|
||||
}), ['886900000000@s.whatsapp.net']);
|
||||
for (const channel of ['wecom', 'slack', 'telegram', 'discord']) {
|
||||
assert.deepEqual(privilegedSenderIdsFor(channel, { allowedUsers: ['legacy'] }), []);
|
||||
}
|
||||
});
|
||||
|
||||
test('shared access-policy RPC payload is exact and validates the full atomic policy', () => {
|
||||
const value = policy(open([], false), allowlist());
|
||||
assert.equal(validAccessPolicyPayload({ botId: 'bot_one', policy: value }), true);
|
||||
assert.equal(validAccessPolicyPayload({ botId: 'bot_one', policy: value, extra: true }), false);
|
||||
assert.equal(validAccessPolicyPayload({ botId: '../bad', policy: value }), false);
|
||||
assert.equal(validAccessPolicyPayload({ botId: 'bot_one', accessMode: 'open' }), false);
|
||||
assert.equal(validAccessPolicyPayload({
|
||||
botId: 'bot_one',
|
||||
policy: {
|
||||
direct: { mode: 'open', defaultCanExecuteCommands: true, users: [] },
|
||||
group: { mode: 'open', defaultCanExecuteCommands: true, users: [] },
|
||||
},
|
||||
}), false, 'RPC accepts canonical scopes only');
|
||||
assert.equal(validAccessPolicyPayload({
|
||||
botId: 'bot_one',
|
||||
policy: { ...value, direct: { ...value.direct, mode: 'legacy' } },
|
||||
}), false);
|
||||
});
|
||||
|
||||
function controllerFixture() {
|
||||
const calls = [];
|
||||
const snapshot = (accessPolicy = policy()) => ({
|
||||
schemaVersion: 2,
|
||||
revision: 1,
|
||||
bots: [{
|
||||
botId: 'bot_one',
|
||||
configured: true,
|
||||
connected: true,
|
||||
state: 'connected',
|
||||
accessPolicy,
|
||||
}],
|
||||
});
|
||||
const controller = {
|
||||
status: async () => snapshot(),
|
||||
bindCredentials: async () => snapshot(),
|
||||
reconnectBot: async () => snapshot(),
|
||||
deleteBot: async () => snapshot(),
|
||||
startProvisioning: async () => ({}),
|
||||
registrationStatus: async () => ({}),
|
||||
cancelProvisioning: async () => ({}),
|
||||
submitVerification: async () => ({}),
|
||||
approveSender: async () => snapshot(),
|
||||
revokeSender: async () => snapshot(),
|
||||
startRegistration: async () => ({}),
|
||||
cancelRegistration: async () => ({}),
|
||||
disconnect: async () => snapshot(),
|
||||
async updateAccessPolicy(botId, accessPolicy, projectStatus) {
|
||||
calls.push({ botId, policy: accessPolicy });
|
||||
const value = snapshot(accessPolicy);
|
||||
return projectStatus ? projectStatus(value) : value;
|
||||
},
|
||||
};
|
||||
return { controller, calls };
|
||||
}
|
||||
|
||||
test('all nine Host RPCs accept only the unified bot.access-policy.set contract', async () => {
|
||||
const factories = [
|
||||
['weixin', createWeixinRpcHandler, WEIXIN_ENDPOINTS],
|
||||
['feishu', createFeishuRpcHandler, FEISHU_ENDPOINTS],
|
||||
['dingtalk', createDingtalkRpcHandler, DINGTALK_ENDPOINTS],
|
||||
['wecom', createWecomRpcHandler, WECOM_ENDPOINTS],
|
||||
['qq', createQqRpcHandler, QQ_ENDPOINTS],
|
||||
['slack', createSlackRpcHandler, SLACK_ENDPOINTS],
|
||||
['telegram', createTelegramRpcHandler, TELEGRAM_ENDPOINTS],
|
||||
['discord', createDiscordRpcHandler, DISCORD_ENDPOINTS],
|
||||
['whatsapp', createWhatsappRpcHandler, WHATSAPP_ENDPOINTS],
|
||||
];
|
||||
const next = policy(open([], false), allowlist(['operator']));
|
||||
for (const [channel, createHandler, endpoints] of factories) {
|
||||
const { controller, calls } = controllerFixture();
|
||||
const handler = createHandler(controller);
|
||||
assert.equal(endpoints.setAccessPolicy, SET_ACCESS_POLICY_ENDPOINT, channel);
|
||||
const result = await handler(endpoints.setAccessPolicy, { botId: 'bot_one', policy: next });
|
||||
assert.equal(result.ok, true, `${channel}: ${JSON.stringify(result)}`);
|
||||
assert.deepEqual(calls, [{ botId: 'bot_one', policy: next }], channel);
|
||||
assert.deepEqual(result.value?.bots?.[0]?.accessPolicy, next, `${channel} update projection`);
|
||||
const status = await handler(endpoints.status, {});
|
||||
assert.equal(status.ok, true, `${channel} status: ${JSON.stringify(status)}`);
|
||||
assert.deepEqual(status.value?.bots?.[0]?.accessPolicy, policy(), `${channel} status projection`);
|
||||
const legacy = await handler(endpoints.setAccessPolicy, {
|
||||
botId: 'bot_one', accessMode: 'open', allowedUsers: [],
|
||||
});
|
||||
assert.equal(legacy.ok, false, channel);
|
||||
assert.equal(legacy.error.code, 'bad-request', channel);
|
||||
assert.equal(calls.length, 1, channel);
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue