// +---------------------------------------------------------------------- namespace addon\iot\command; use think\console\Command; use think\console\Input; use think\console\Output; use think\facade\Db; use ywxapp\service\AddonService; use addon\iot\service\Auth; /** * ACL 实网决策验证:用真实 DB 规则校验 Auth::checkAcl 的放行/拒绝逻辑。 * php think iot:acltest * 验证项:deny 发布拒绝、默认放行、用户定向拒绝、超管绕过、$SYS 只读。 * 不依赖运行中的 Broker,直接复用 Broker 实际调用的 Auth 逻辑。 */ class TestAcl extends Command { protected function configure() { $this->setName('iot:acltest') ->setDescription('验证 ACL 发布/订阅权限决策(含 deny 拒绝)'); } protected function execute(Input $input, Output $output) { // 配置:开启 ACL,默认放行 $saved = []; try { $saved = AddonService::config('iot') ?: []; } catch (\Throwable $e) {} $config = array_merge($saved, ['acl_enabled' => '1', 'acl_default' => 'allow']); $auth = new Auth($config); $auth->ensureTables(); // 插入临时规则(测试后清理) $rows = [ ['target_type' => 'user', 'target' => 'alice', 'topic' => 'alice/secret', 'access' => 2, 'allow' => 0, 'sort' => 5, 'remark' => 'tmp'], ['target_type' => 'all', 'target' => '', 'topic' => 'secret/#', 'access' => 2, 'allow' => 0, 'sort' => 10, 'remark' => 'tmp'], ['target_type' => 'all', 'target' => '', 'topic' => 'public/#', 'access' => 3, 'allow' => 1, 'sort' => 20, 'remark' => 'tmp'], ]; $ids = []; foreach ($rows as $r) { $ids[] = Db::name('iot_acl')->insertGetId(array_merge($r, ['create_at' => time()])); } $results = []; $chk = function (string $case, bool $got, bool $expect) use (&$results) { $results[$case] = ['got' => $got, 'expect' => $expect, 'ok' => $got === $expect]; }; // 1) deny 发布 secret/# 被拒 $chk('deny publish secret/#', $auth->checkAcl(null, 'c1', 'secret/data', Auth::ACT_PUB), false); // 2) 同一主题仅 deny 发布,订阅走默认放行 $chk('subscribe secret/# default-allow', $auth->checkAcl(null, 'c1', 'secret/data', Auth::ACT_SUB), true); // 3) allow 规则 public/# 发布放行 $chk('allow publish public/info', $auth->checkAcl(null, 'c1', 'public/info', Auth::ACT_PUB), true); // 4) 未命中规则走默认放行 $chk('default allow other/topic', $auth->checkAcl(null, 'c1', 'other/topic', Auth::ACT_PUB), true); // 5) 用户定向 deny:alice 发布 alice/secret 被拒 $chk('user alice deny publish', $auth->checkAcl('alice', 'cA', 'alice/secret', Auth::ACT_PUB), false); // 6) alice 订阅 alice/secret(user 规则仅限发布)走默认放行 $chk('user alice subscribe (rule is pub-only)', $auth->checkAcl('alice', 'cA', 'alice/secret', Auth::ACT_SUB), true); // 7) 超管绕过 deny $chk('superuser bypass deny', $auth->checkAcl('root', 'cR', 'secret/data', Auth::ACT_PUB, true), true); // 8) $SYS 只读:订阅放行、发布拒绝 $chk('$SYS subscribe allowed', $auth->checkAcl(null, 'c2', '$SYS/broker/uptime', Auth::ACT_SUB), true); $chk('$SYS publish denied', $auth->checkAcl(null, 'c2', '$SYS/broker/uptime', Auth::ACT_PUB), false); // 9) 设备注册主题:发布 register/{sn} 与订阅 .../resp 始终放行(入网必经,不受 ACL 默认策略影响) $chk('register publish allowed', $auth->checkAcl(null, 'c3', 'system/device/register/SN001', Auth::ACT_PUB), true); $chk('register resp subscribe allowed', $auth->checkAcl(null, 'c3', 'system/device/register/SN001/resp', Auth::ACT_SUB), true); // 10) 私有主题硬归属:即使 acl_default=allow,匿名/冒名身份也不得触碰他人设备与告警 $chk('anonymous sub other device state', $auth->checkAcl(null, 'c4', '/devices/dev_x/state', Auth::ACT_SUB), false); $chk('named-anon sub other device state', $auth->checkAcl('someone', 'c4', '/devices/dev_x/state', Auth::ACT_SUB, false, 'anonymous-named'), false); $chk('named-anon pub other device cmd', $auth->checkAcl('someone', 'c4', '/devices/dev_x/cmd', Auth::ACT_PUB, false, 'anonymous-named'), false); $chk('anonymous sub notify of others', $auth->checkAcl(null, 'c4', '/notify/alice/#', Auth::ACT_SUB), false); $chk('self notify subscribe allowed', $auth->checkAcl('alice', 'c4', '/notify/alice/#', Auth::ACT_SUB), true); $chk('superuser bypass private topic', $auth->checkAcl(null, 'c4', '/devices/dev_x/state', Auth::ACT_SUB, true), true); // 11) 通配符订阅封堵:`#` / `+/...` 不得绕过私有主题归属(acl_default=allow 下也必须拒绝) // 背景:顶层通配符不以 /devices/ 开头 → 硬归属返回 null → 落到 acl_default 兜底, // 若兜底为「允许」,订阅 `#` 就能收到全站设备消息与注册回执(含 secret)。 $chk('wildcard # subscribe denied', $auth->checkAcl(null, 'c5', '#', Auth::ACT_SUB), false); $chk('wildcard system/# denied', $auth->checkAcl(null, 'c5', 'system/#', Auth::ACT_SUB), false); $chk('wildcard +/state denied', $auth->checkAcl(null, 'c5', '+/state', Auth::ACT_SUB), false); $chk('register wildcard sub denied', $auth->checkAcl(null, 'c5', 'system/device/register/#', Auth::ACT_SUB), false); $chk('register precise sub still allowed', $auth->checkAcl(null, 'c5', 'system/device/register/SN001/resp', Auth::ACT_SUB), true); $chk('$SYS wildcard allowed (readonly)', $auth->checkAcl(null, 'c5', '$SYS/#', Auth::ACT_SUB), true); $chk('superuser wildcard # allowed', $auth->checkAcl(null, 'c5', '#', Auth::ACT_SUB, true), true); // 清理临时规则 if ($ids) { Db::name('iot_acl')->whereIn('id', $ids)->delete(); } $pass = 0; $fail = 0; foreach ($results as $case => $r) { $flag = $r['ok'] ? 'PASS' : 'FAIL'; if ($r['ok']) { $pass++; } else { $fail++; } $output->writeln("[{$flag}] {$case} (got=" . var_export($r['got'], true) . " expect=" . var_export($r['expect'], true) . ")"); } $output->writeln(""); $output->writeln("ACL 验证结果:PASS={$pass} FAIL={$fail}"); return $fail === 0 ? 0 : 1; } }