## 背景 Hub 原先只有**一个**令牌:任何持有者都能接入任意设备,无法区分「谁在控制哪台机器」。 E3 的权限隔离因此只做到能力级(被控端 ACL),Hub 侧仍是全通。 ## 实现(apps/hub/src/authz.ts,纯函数便于单测) - 多令牌:每个令牌 = 一个控制端身份,可独立指定 allow_nodes / deny_nodes(glob) - 判定顺序 deny -> allow -> 默认拒绝;未配置 allow_nodes 时默认放行(兼容) - 顶层 token 视为主令牌(放行全部),旧配置零改动 - 前缀式滚动令牌:value 以 * 结尾时按前缀匹配(轮换期间可灰度) - 注册准入:node_allowlist 非空时只有匹配的 node_id 能注册,防止他人借令牌挂靠节点 - 列表按身份过滤:控制端只看得到自己被授权的设备 - 越权接入返回新错误码 E_NODE_FORBIDDEN ## 职责边界(重要) Hub 只做「能否连到某设备」的接入过滤;连上之后的**能力级**授权仍由被控端 ACL 按 client_id 裁决。两层各自独立,Hub 不参与能力决策 —— 保持 Hub 不可信假设。 ## 测试 - 单测 79 -> 89(新增 Hub 授权 10 例:主令牌兼容 / 未知令牌 / 前缀轮换 / deny 优先 / glob 白名单 / 默认放行 / 注册准入 / 旧配置兼容 / 列表过滤 / glob 转义) - e2e:Hub 用例新增受限令牌访问未授权设备 -> 断言 E_NODE_FORBIDDEN 且错误信息含授权原因
102 lines
3.5 KiB
JavaScript
102 lines
3.5 KiB
JavaScript
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import {
|
||
nodeAllowed,
|
||
nodeMatches,
|
||
nodeMayRegister,
|
||
resolveToken,
|
||
visibleNodes,
|
||
} from '../../apps/hub/dist/authz.js';
|
||
|
||
const cfg = {
|
||
node_id: 'hub_01',
|
||
host: '0.0.0.0',
|
||
port: 9443,
|
||
token: 'master-token',
|
||
log_level: 'warn',
|
||
tokens: [
|
||
{ value: 'owner-token', name: 'owner', allow_nodes: ['*'] },
|
||
{ value: 'ci-token', name: 'ci', allow_nodes: ['win_build_*'] },
|
||
{ value: 'guest-token', name: 'guest', allow_nodes: ['*'], deny_nodes: ['win_prod'] },
|
||
{ value: 'roll-*', name: 'rolling', allow_nodes: ['win_a'] },
|
||
],
|
||
node_allowlist: ['win_*', 'mac_a'],
|
||
};
|
||
|
||
test('令牌:顶层 token 视为主令牌(放行全部,向后兼容)', () => {
|
||
const t = resolveToken(cfg, 'master-token');
|
||
assert.ok(t);
|
||
assert.equal(t.isMaster, true);
|
||
assert.equal(nodeAllowed(t, '任意设备').allowed, true);
|
||
});
|
||
|
||
test('令牌:未知令牌解析失败', () => {
|
||
assert.equal(resolveToken(cfg, 'nope'), null);
|
||
assert.equal(resolveToken(cfg, undefined), null);
|
||
assert.equal(resolveToken(cfg, ''), null);
|
||
});
|
||
|
||
test('令牌:前缀式滚动令牌按前缀匹配', () => {
|
||
const t = resolveToken(cfg, 'roll-2026-09');
|
||
assert.ok(t);
|
||
assert.equal(t.name, 'rolling');
|
||
assert.equal(nodeAllowed(t, 'win_a').allowed, true);
|
||
assert.equal(nodeAllowed(t, 'win_b').allowed, false, '白名单外应拒绝');
|
||
});
|
||
|
||
test('设备授权:deny 优先于 allow', () => {
|
||
const guest = resolveToken(cfg, 'guest-token');
|
||
assert.equal(nodeAllowed(guest, 'win_prod').allowed, false, '黑名单优先');
|
||
assert.equal(nodeAllowed(guest, 'win_other').allowed, true);
|
||
});
|
||
|
||
test('设备授权:glob 白名单 + 默认拒绝', () => {
|
||
const ci = resolveToken(cfg, 'ci-token');
|
||
assert.equal(nodeAllowed(ci, 'win_build_01').allowed, true);
|
||
assert.equal(nodeAllowed(ci, 'win_build_99').allowed, true);
|
||
assert.equal(nodeAllowed(ci, 'win_prod').allowed, false);
|
||
const r = nodeAllowed(ci, 'win_prod');
|
||
assert.match(r.reason, /未被授权/);
|
||
});
|
||
|
||
test('设备授权:未配置 allow_nodes 时默认放行全部', () => {
|
||
const c2 = { ...cfg, tokens: [{ value: 'plain', name: 'plain' }] };
|
||
const t = resolveToken(c2, 'plain');
|
||
assert.equal(nodeAllowed(t, 'any_node').allowed, true);
|
||
});
|
||
|
||
test('注册准入:node_allowlist 非空时按 glob 过滤', () => {
|
||
assert.equal(nodeMayRegister(cfg, 'win_x').allowed, true);
|
||
assert.equal(nodeMayRegister(cfg, 'mac_a').allowed, true);
|
||
assert.equal(nodeMayRegister(cfg, 'mac_b').allowed, false);
|
||
assert.equal(nodeMayRegister(cfg, 'attacker_node').allowed, false);
|
||
});
|
||
|
||
test('注册准入:未配置白名单时放行(兼容旧配置)', () => {
|
||
const legacy = { ...cfg, node_allowlist: undefined };
|
||
assert.equal(nodeMayRegister(legacy, 'anything').allowed, true);
|
||
});
|
||
|
||
test('列表可见性:只展示被授权的设备', () => {
|
||
const ci = resolveToken(cfg, 'ci-token');
|
||
const all = [
|
||
{ node_id: 'win_build_01' },
|
||
{ node_id: 'win_prod' },
|
||
{ node_id: 'win_build_02' },
|
||
];
|
||
assert.deepEqual(
|
||
visibleNodes(ci, all).map((x) => x.node_id),
|
||
['win_build_01', 'win_build_02'],
|
||
);
|
||
});
|
||
|
||
test('nodeMatches:glob 语义与转义(点号不被当通配)', () => {
|
||
assert.equal(nodeMatches('*', 'x'), true);
|
||
assert.equal(nodeMatches('win*', 'win_a'), true);
|
||
assert.equal(nodeMatches('win_a', 'win_a'), true);
|
||
assert.equal(nodeMatches('win_a', 'win_b'), false);
|
||
// '.' 应被转义为字面量
|
||
assert.equal(nodeMatches('a.b', 'axb'), false);
|
||
assert.equal(nodeMatches('a.b', 'a.b'), true);
|
||
});
|