Files
nodeagent/tests/unit/hub-authz.test.mjs
T
jkooe cd774ae0a6 feat(E3c): Hub 侧按控制端授权(多令牌 + 按设备白/黑名单 + 注册准入)
## 背景
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 且错误信息含授权原因
2026-09-29 14:38:53 +08:00

102 lines
3.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});