From 7b3d49acda1a7c4cc4a26824527917e5da7d42d6 Mon Sep 17 00:00:00 2001 From: whyour Date: Tue, 25 Aug 2026 02:09:06 +0800 Subject: [PATCH] feat(ql3): add short-lived security administration command --- deploy/kubernetes/ql3-cluster/README.md | 31 +- docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 8 +- ...cluster-security-administration-command.md | 87 ++++ docs/adr/README.md | 1 + .../ql3-cluster-security-administration.md | 130 +++++ packages/ql3-cluster-admin/package.json | 6 + .../pluginPackageIdentityAssertion.ts | 6 + .../pluginPackageIdentityKeyset.ts | 11 + .../src/product-cli/productCommand.ts | 6 + .../clusterAdministrationCli.ts | 87 ++++ .../clusterAdministrationCommand.ts | 468 ++++++++++++++++++ .../clusterAdministrationCommandRuntime.ts | 467 +++++++++++++++++ .../clusterAdministrationCommand.test.cjs | 361 ++++++++++++++ .../test/pluginPackageIdentityKeyset.test.cjs | 87 +++- .../test/productCli.test.cjs | 9 +- ...l3-cluster-admin-product-live-contract.cjs | 4 + test/back/ql3PackageBoundaryAudit.test.cjs | 4 +- 17 files changed, 1756 insertions(+), 17 deletions(-) create mode 100644 docs/adr/ADR-0500-short-lived-cluster-security-administration-command.md create mode 100644 docs/operations/ql3-cluster-security-administration.md create mode 100644 packages/ql3-cluster-admin/src/security-administration/clusterAdministrationCli.ts create mode 100644 packages/ql3-cluster-admin/src/security-administration/clusterAdministrationCommand.ts create mode 100644 packages/ql3-cluster-admin/src/security-administration/clusterAdministrationCommandRuntime.ts create mode 100644 packages/ql3-cluster-admin/test/clusterAdministrationCommand.test.cjs diff --git a/deploy/kubernetes/ql3-cluster/README.md b/deploy/kubernetes/ql3-cluster/README.md index d330a68e..51764439 100644 --- a/deploy/kubernetes/ql3-cluster/README.md +++ b/deploy/kubernetes/ql3-cluster/README.md @@ -43,7 +43,8 @@ client and stdio transport. Its default entrypoint is the bounded `ql3-cluster-admin` product facade. The facade exposes eight operator-facing remote clients: `copilot`, `package`, `package-kubernetes`, `worker-credential`, `approval`, `run`, `automation` and -`model-credential`, plus the bounded `copilot-mcp` stdio process. It delegates +`model-credential`, the short-lived local `security` administration command, +plus the bounded `copilot-mcp` stdio process. It delegates with the current Node executable, an exact same-image target and `shell=false`; arguments and MCP stdio remain opaque. Server `*-manage` processes, migration, recovery, executors, Prompt output key custody @@ -51,6 +52,30 @@ and garbage collection remain separate explicit binaries and Kubernetes operations. Existing Jobs continue to name their exact binary and do not inherit facade authority. +Run Identity, API credential and bounded Security Audit administration only +from a short-lived operator workstation or one-shot Job. The command opens at +most one admin PostgreSQL connection and never starts a listener: + +```sh +ql3-cluster-admin security \ + --command=/secure/qinglong3/security-command.json \ + --assertion=/secure/qinglong3/security-assertion.jwt \ + --keyset=/secure/qinglong3/security-keyset.json \ + --pepper=/secure/qinglong3/api-credential-pepper \ + --delivery=/secure/qinglong3/delivery/new-api-credential.json +``` + +Use `--delivery` only for `credential.issue` and `credential.rotate`. Its +parent directory must already be private and the target must not exist; the +token is published as a `0600`, no-replace file while stdout contains only its +basename and SHA-256. The four input files must be explicit, bounded and +private. Inject `QL3_POSTGRES_ADMIN_*` separately from runtime and migration +credentials; production defaults to TLS `verify-full`. Exact keyset/JWT rules, +command schemas and operator procedure are documented in +[`ql3-cluster-security-administration`](../../../docs/operations/ql3-cluster-security-administration.md); +response-loss behavior and remaining gates are frozen in +[`ADR-0500`](../../../docs/adr/ADR-0500-short-lived-cluster-security-administration-command.md). + Workstation operators may pass an explicit owner-private context to reuse only stable client paths: @@ -88,8 +113,8 @@ ql3-cluster-admin context validate \ This command reuses the production client parsers to validate every selected endpoint shape, CA, matching client certificate/private key and the bounded -Kubernetes config. It also verifies that all nine facade targets exist in the -same installation. It does not read a command or assertion, open a network +Kubernetes config. It also verifies the context-aware facade targets exist in +the same installation. It does not read a command or assertion, open a network connection, query Kubernetes or mutate the cluster. Its JSON summary contains only command names and reviewed transport/authentication classes; paths, endpoints, namespaces, context names and credentials are never emitted. diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 0f36a45d..c6f2f310 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -6,10 +6,12 @@ - 目标版本:QingLong 3.x - 作者:QingLong Maintainers - 创建日期:2026-07-17 -- 最后更新:2026-08-24 +- 最后更新:2026-08-25 - 讨论范围:架构与演进路线,不包含最终 UI 视觉方案 -最新增量证据(2026-08-24): +最新增量证据(2026-08-25): + +- D-405/ADR-0500(已验收):Cluster Identity/API Credential/Security Audit 现在有首个受审产品入口。`ql3-security-admin` 由既有 `ql3-cluster-admin security` facade 到达同一安装内的固定 target,不新建单文件 package,也不进入常驻 `cluster-control`;每次只执行一个 exact-shape 的 identity register/enable/disable、credential issue/rotate/revoke 或最多 200 条的 keyset audit query,随后关闭单连接 admin PostgreSQL authority。命令使用独立 `ql3-security-administration+jwt` type、`security-administration` purpose、audience 与 generation/revocation ledger,拒绝其他管理面的 assertion;command、assertion、keyset 与 pepper 均来自显式有界文件,不发现 home、ambient Kubernetes context 或默认 credential。issue/rotate token 只经私有目录内 `0600`、`fsync`、hard-link no-replace 文件交付,stdout 仅含 basename 与 SHA-256;精确重放的 `token=null` 不重新生成或发布 secret。默认 PostgreSQL `verify-full`,Pool 最大一个连接,无 listener、daemon、timer、watcher 或后台 retention。`cluster-admin` 完整回归为 `448 total / 445 pass / 3 conditional skip / 0 fail`,新增聚焦测试 19/19、产品 facade 4/4、backend product contract 4/4;18-package clean build/test 退出 0,backend 为 `1575 total / 1573 pass / 2 conditional skip / 0 fail`。真实 arm64 Admin 镜像在 non-root、read-only、无网络/能力、128 MiB/0.25 CPU 下通过 12-command live contract;PostgreSQL 18.6 arm64 physical HA timeline 1→2 的 147 gates 全部通过,报告 SHA-256 为 `8fbb606773080dae15de5e31db5726abb8700862b51e4616b5f3f50e0b8374f3`。package、Cluster dependency、122-module Edge import、部署、镜像与 service bridge 审计全部 compatible,Edge/Standalone package、依赖和常驻资源不变。本切片关闭 ADR-0050“只有 application service、没有受审产品入口”的缺口,但不冒充远程 HTTP/API/UI、双人复核/break-glass、pepper rotation、audit retention/export/alert 或完整 Kubernetes Job ceremony。 - D-404/ADR-0499(已验收):Cluster Worker 现在有可选的直接外部 Secret custody adapter,而不再只能依赖 Kubernetes Secret value projection。`vault-kv-v2` 位于既有 `@qinglong/cluster-control` Remote Execution 子域,只有显式选择 provider 的 Cluster 进程才动态加载;基础部署继续使用 `mounted-files`,Edge/Standalone 不新增 package、依赖、daemon、timer、watcher、连接池或常驻内存。adapter 只在 durable Run/Attempt/Lease/Worker Session/execution digest/SecretRef authority 通过后,用 `SHA-256(canonical SecretRef)` 路径读取 KV v2;只接受显式私有 CA 的 TLS 1.3、每次重新打开的短期 orphan/non-renewable service token 和唯一精确 policy,不跟随 redirect、不使用系统 CA 回退、不缓存值或 token。Kubernetes overlay 删除 value Secret projection,只挂载 CA 与 token;普通 Secret、opaque environment bundle 和总响应仍受原 16/96/256 KiB 边界约束,空 Secret 保持合法,异常 envelope、metadata、digest、token、TLS 或 Vault availability 均失败关闭且不回退。真实 arm64 Vault 1.21.4 gate 已完成 3-share/2-threshold init、两个普通 Secret 与一个 bundle、value/token 原子轮换、旧 accessor revoke、缺失 material、不可信 CA、seal/unseal 与同持久存储容器替换;capability-free `0600` content-free 报告 SHA-256 为 `281fe542e1bf6078132216b9701a28e76f36dcbccc95367997d8457773a9c210`,audit 为 `compatible=true/findings=[]`。live fixture 以非 root、read-only rootfs、`cap-drop=ALL`、零新增 capability、`no-new-privileges` 与 `memory-swappiness=0` 运行,并为原生 hosted runner 的 capability/lock 差异显式关闭 fixture mlock;生产 Vault host 的 swap/mlock 硬化不由该门冒充。Cluster Control 为 `279 total / 277 pass / 2 conditional skip / 0 fail`,backend 为 `1574 total / 1572 pass / 2 conditional skip / 0 fail`,18-package clean build/test 退出 0;package、Cluster dependency、122-module Edge import、部署和 14 档 Local artifact 审计全部 compatible,基础 Edge/Standalone 仍为 `2,669,390 / 2,669,468 bytes`、325 files、58 modules。共享 CI 新增原生 x64/arm64 live matrix。该 fixture 关闭 QingLong 直接 custody adapter/data-boundary 门,不冒充生产 Vault HA、KMS/HSM seal、审计设备或灾备证明;ADR-0491 现在只剩固定低性能物理 Edge 的真实空间、RSS/I/O、写放大、ENOSPC 与断电恢复门。 @@ -10074,7 +10076,7 @@ PR-8 的本机最新增量由 ADR-0075/0076/0077/0078/0079/0080/0081/0082/0083/0 ADR-0087 Owner package 更新同样适用于上段 PR-8 累计描述:现行产品 CLI 只能经 console facade 到达其内部 bootstrap/credential-recovery,三个历史 ceremony package 名都只表示旧切片;`ql3-owner-gc` 由 maintenance 直接提供,不再拥有独立 importer。 -PR-8 的 cluster ADR-0049 未完成项由 ADR-0050/0051 部分收敛:Identity register/enable/disable、credential issue/rotate/revoke、mutation ledger、强 actor、同事务 audit、有界 audit query,以及常驻 `/api/v3` 的认证前 overload shield 已孵化完成,但仍默认没有远程管理入口。当前 Gate 仍要求受审 CLI/API/UI、部署 ceremony、管理入口独立 rate limit、双人复核或 break-glass、pepper rotation、audit retention/export/alert;不得把 application service 或 process-local HTTP shield 的存在解释为 cluster-control 已获得管理 authority 或全局 quota。 +PR-8 的 cluster ADR-0049 未完成项由 ADR-0050/0051/0500 继续收敛:Identity register/enable/disable、credential issue/rotate/revoke、mutation ledger、强 actor、同事务 audit、有界 audit query,以及常驻 `/api/v3` 的认证前 overload shield 已孵化完成;D-405 新增默认无 listener、一次只执行一个命令并关闭单连接 admin authority 的 `ql3-security-admin` 产品 CLI,credential token 只向私有 no-replace 文件交付。当前 Gate 仍要求远程 API/UI 或完整 Kubernetes Job ceremony、管理入口独立 rate limit、双人复核或 break-glass、pepper rotation、audit retention/export/alert;不得把短生命周期 CLI、application service 或 process-local HTTP shield 的存在解释为 cluster-control 已获得管理 authority 或全局 quota。 未进入当前孵化切片的代码在通过对应 Gate 前必须保持不可达:不得仅因 schema、service 或 Primary 编排器已存在,就让旧 Controller、Scheduler、gRPC callback 或 Shell 脚本直接写入新状态表或调用新 Executor。已接入的 Shadow 观察只能通过默认关闭的 Feature Flag 和 origin owner 决策到达,不得调用 Executor、再次 spawn 或改变 Legacy 返回结果。manual `runSingle` 只增加 owner selection seam;默认没有 router。HTTP bootstrap 每次启动只读取一次 manifest,缺失、禁用、拒绝或非 primary 时保持 Legacy 且不加载重组件;显式 accepted manual primary 会在恢复门禁通过后安装唯一 owner,选中后禁止回退双跑。ADR-0445 已让 `ScheduleService.runTask` 的 subscription/system/script 在显式 origin flag 下只观察同一个 Legacy ChildProcess;system crond 的 `scheduled_system`、once/boot/grpc 与这些来源的 Primary owner 切换仍须独立门禁。 diff --git a/docs/adr/ADR-0500-short-lived-cluster-security-administration-command.md b/docs/adr/ADR-0500-short-lived-cluster-security-administration-command.md new file mode 100644 index 00000000..0d1432b3 --- /dev/null +++ b/docs/adr/ADR-0500-short-lived-cluster-security-administration-command.md @@ -0,0 +1,87 @@ +# ADR-0500:短生命周期 Cluster Security Administration 产品命令 + +- 状态:Accepted +- 日期:2026-08-25 +- 决策:D-405 +- 关联:ADR-0049、ADR-0050、ADR-0051、ADR-0276、ADR-0301 + +## 背景 + +ADR-0050 已把 Identity register/enable/disable、API credential issue/rotate/revoke、不可变 mutation ledger、强 actor、同事务 audit 与有界 audit query 收口到独立的 `@qinglong/cluster-admin` authority,并明确禁止常驻 `cluster-control` 获得 admin 数据库角色。但原切片只有 application service,没有受审产品入口。部署用户只能自行编写 composition root,容易重新引入常驻高权限进程、宽泛数据库连接池、secret stdout 或未经隔离的认证用途。 + +QingLong 3.0 同时面向低性能路由设备和 Cluster 节点。该入口只属于 Cluster Admin 镜像;不能进入 Edge/Standalone 制品,也不能为了操作便利新增 listener、daemon、timer、watcher 或常驻连接池。 + +## 决策 + +### 1. 在既有 Cluster Admin package 内增加短生命周期命令 + +`@qinglong/cluster-admin` 增加 `ql3-security-admin`,并由 `ql3-cluster-admin security` facade 到达同一安装内的固定目标。它继续位于 ADR-0301 已确定的 `security-administration/` 领域目录,不为一个共享相同镜像、依赖、升级和故障生命周期的 composition root 新建 workspace package。纯命令协议/runner 与 POSIX/PostgreSQL production runtime 分成两个领域内文件,避免再形成根目录平铺或单个超大 composition 文件。 + +命令不监听端口,也不读取 home、ambient Kubernetes context 或默认凭据。每次调用只执行一个 exact-shape JSON operation,然后关闭 PostgreSQL authority: + +- `identity.register`、`identity.enable`、`identity.disable`; +- `credential.issue`、`credential.rotate`、`credential.revoke`; +- `audit.list`。 + +未知字段、相对路径、非规范路径、无界 audit query 和 operation/request shape 混淆都在获得数据库 authority 前失败关闭。 + +### 2. 独立强身份用途 + +管理命令只接受 dedicated JWT assertion profile: + +- `typ=ql3-security-administration+jwt`; +- `purpose=security-administration`; +- 独立 audience 与 keyset generation/revocation ledger; +- 复用既有强认证规则,只接受当前、未撤销且满足 assurance 的 principal。 + +Plugin Package、Worker Credential、Automation、Approval、Run、Provider Credential 或其他管理面的 assertion 不能跨用途复用。assertion、keyset、pepper 与 command 都必须是显式绝对文件;私有材料拒绝 group/world 权限,读取时拒绝 symlink、大小越界和读中变更。 + +### 3. Admin 数据库 authority 保持一次性和最小化 + +命令使用独立的 `QL3_POSTGRES_ADMIN_*` 配置,只打开 admin role,Pool 上限为一个连接,并设置短 connection/idle/lifetime 边界。默认要求 PostgreSQL `verify-full` 与显式 DNS server name;只有同时指定 `TLS_MODE=disable` 和明确的不安全 opt-in 才允许测试环境禁用 TLS。 + +启动先验证 ADR-0050 admin schema/readiness,随后复用既有 Repository 与 `ClusterAdministrationService`。成功、拒绝、异常和 readiness 失败都关闭数据库 authority。runtime、migration 与 admin credential 仍不得混用。 + +### 4. Credential 只交付到私有 no-replace 文件 + +issue/rotate 的一次性 bearer token 不写 stdout、stderr、日志、audit 或命令结果。调用者必须提供一个尚不存在的 delivery path,其父目录必须是真实私有目录。publisher 在同目录创建 `0600` 临时文件,完整写入并 `fsync`,再以 hard-link no-replace 发布并同步父目录;已有目标永不覆盖。 + +stdout 只返回 delivery basename 与内容 SHA-256。语义重放仍遵循 ADR-0050:数据库返回 `token=null` 时不得重新生成或重新发布材料。若首次成功后的交付响应丢失,操作者只能以新的 mutation ID 执行 rotate,不能恢复旧 secret。 + +### 5. 本切片不是远程管理平面 + +该命令关闭“已有 administration service 但没有受审产品入口”的缺口,但不增加 HTTP/API/UI、远程 listener、全局 quota 或后台 retention/export。生产使用仍应由一次性 Job、受控运维工作站或等价的短生命周期执行环境注入 assertion、keyset、pepper 与 admin database credential,并在完成后销毁运行环境。 + +双人复核、break-glass、pepper rotation、audit retention/export/alert 和完整 Kubernetes Job ceremony 仍是后续门禁,不能由本命令的存在推断为已经完成。 + +## 被拒绝的替代方案 + +### 把命令加入 Cluster Control HTTP API + +拒绝。它会让常驻业务进程获得 admin 数据库权限,并把 credential 签发和 audit 读取暴露到远程攻击面。 + +### 新建一个 workspace package 或常驻 Admin Deployment + +拒绝。该 composition root 与 `cluster-admin` 共享镜像、依赖、权限和升级生命周期;拆包只会制造单文件 package,常驻 Deployment 则扩大高权限驻留时间和资源占用。 + +### 把 token 返回 stdout 或保存进 PostgreSQL + +拒绝。shell history、日志采集、CI 输出和数据库备份都会成为 bearer custody 面,且破坏 ADR-0050 的不可恢复语义。 + +### 自动发现默认 keyset、pepper 或数据库凭据 + +拒绝。ambient authority 会让同一命令在工作站、Pod 和路由设备上取得不同且不可审计的权限来源。 + +## 验证 + +- 新命令测试覆盖强身份注入、authority 必关闭、敏感 Buffer 清零、credential 私有交付、精确重放不再发布、有界 audit query、widened shape 拒绝,以及真实 `0600` no-replace 文件语义。 +- keyset 测试证明 Security Administration 的 type、purpose 与 audience 不能和其他管理面混用。 +- product facade 与 admin-image live contract 冻结固定 target、无 shell delegation、`--help` 和无网络/只读容器运行边界。 +- `@qinglong/cluster-admin` 完整回归为 448 total / 445 pass / 3 conditional skip / 0 fail。 +- 18-package clean build/test 退出 0;backend 为 1575 total / 1573 pass / 2 conditional skip / 0 fail。 +- 真实 arm64 Cluster Admin 镜像在 non-root、read-only rootfs、`network=none`、`cap-drop=ALL`、`no-new-privileges`、32 PID、128 MiB 和 0.25 CPU 下通过 12 个产品命令 live contract。PostgreSQL 18.6 arm64 physical HA 在 timeline 1→2 promotion 后通过 147 gates,content-free report SHA-256 为 `8fbb606773080dae15de5e31db5726abb8700862b51e4616b5f3f50e0b8374f3`。 +- package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`;Cluster dependency、122-module Edge import、Cluster deployment、image release 与 service-manager bridge 审计全部 compatible。 + +## 影响与剩余门禁 + +D-405 提供了第一个可直接使用、默认无 listener 的 Cluster Identity/API Credential/Audit 管理入口,同时不改变 Edge/Standalone package、依赖或常驻资源。它只关闭 ADR-0050 的产品入口缺口;远程管理 API/UI、双人复核或 break-glass、pepper 生命周期、audit retention/export/alert 和生产部署 ceremony 仍需后续 ADR 独立证明。 diff --git a/docs/adr/README.md b/docs/adr/README.md index edc0bc18..c49bed13 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -503,6 +503,7 @@ | [ADR-0497](./ADR-0497-atomic-cluster-legacy-env-migration-application.md) | Cluster Legacy Env 的原子 Task/Trigger 迁移与只追加回执 | Accepted | | [ADR-0498](./ADR-0498-cluster-legacy-env-application-ha-replay.md) | Cluster Legacy Env Application 的 HA Promotion 后精确重放 | Accepted | | [ADR-0499](./ADR-0499-direct-vault-kv-worker-secret-custody.md) | 直接 Vault KV Worker Secret 外部托管 | Accepted | +| [ADR-0500](./ADR-0500-short-lived-cluster-security-administration-command.md) | 短生命周期 Cluster Security Administration 产品命令 | Accepted | ## 规则 diff --git a/docs/operations/ql3-cluster-security-administration.md b/docs/operations/ql3-cluster-security-administration.md new file mode 100644 index 00000000..b132fac3 --- /dev/null +++ b/docs/operations/ql3-cluster-security-administration.md @@ -0,0 +1,130 @@ +# QingLong 3.0 Cluster Security Administration + +该命令是 Cluster Identity、API Credential 和 Security Audit 的短生命周期管理入口,不适用于 Edge/Standalone,也不会启动 HTTP listener。每次进程只执行一个命令,最多打开一个 admin PostgreSQL 连接,然后退出。 + +## 私有输入 + +准备一个仅当前操作者可访问的目录;command、assertion、keyset 和 pepper 必须使用规范绝对路径,私有文件不能向 group/world 开放。credential issue/rotate 还需要一个已存在的私有 delivery 目录,但目标文件必须不存在。 + +keyset 使用既有 generation/revocation 协议,例如: + +```json +{ + "schemaVersion": 1, + "generation": 1, + "issuer": "https://identity.example.test/", + "audience": "qinglong3-security-administration", + "keys": [ + { + "alg": "EdDSA", + "crv": "Ed25519", + "kid": "REPLACE_WITH_KEY_ID", + "kty": "OKP", + "use": "sig", + "x": "REPLACE_WITH_ED25519_PUBLIC_JWK_X" + } + ], + "revokedKids": [], + "assuranceMappings": [ + { + "acr": "urn:example:mfa", + "assurance": "multi_factor", + "requiredAmr": ["pwd", "otp"] + }, + { + "acr": "urn:example:hardware", + "assurance": "hardware", + "requiredAmr": ["hwk"] + } + ], + "constraints": { + "maxAssertionBytes": 8192, + "maxLifetimeMs": 300000, + "maxAuthenticationAgeMs": 300000, + "clockSkewMs": 5000 + } +} +``` + +JWT header 必须使用 `typ=ql3-security-administration+jwt`;payload 的 issuer/audience 必须匹配 keyset,并包含 `ql3_purpose=security-administration`。只接受当前、未撤销的强认证 principal。其他管理面即使使用同一签名 key,也会因 type/purpose/audience 不同而被拒绝。 + +pepper 是现有 API credential digest authority 要求的 32-byte canonical base64url 值。它必须与已存 credential 的 pepper authority 一致,不要为了单次命令临时生成新值,也不要写入 command JSON。 + +## 精确命令 + +注册 Identity: + +```json +{ + "schemaVersion": 1, + "operation": "identity.register", + "request": { + "mutationId": "123e4567-e89b-42d3-a456-426614174301", + "requestId": "security-identity-register-20260825-1", + "expectedCurrentVersion": 0, + "subject": { "type": "api_app", "id": "automation-client" } + } +} +``` + +`identity.enable` 和 `identity.disable` 使用相同 request shape,只需修改 operation、mutationId、requestId 和 expectedCurrentVersion。 + +签发 API Credential: + +```json +{ + "schemaVersion": 1, + "operation": "credential.issue", + "request": { + "mutationId": "123e4567-e89b-42d3-a456-426614174302", + "requestId": "security-credential-issue-20260825-1", + "expectedCurrentVersion": 0, + "credentialId": "automation-primary", + "subject": { "type": "api_app", "id": "automation-client" }, + "notBeforeAtMs": 1787596800000, + "expiresAtMs": 1787683200000 + } +} +``` + +`credential.rotate` 使用同一完整 shape 和新的 mutationId;`credential.revoke` 必须删除 `notBeforeAtMs`、`expiresAtMs`,且不提供 `--delivery`。所有 mutation 都必须携带当前版本 fence。 + +有界查询 Security Audit: + +```json +{ + "schemaVersion": 1, + "operation": "audit.list", + "request": { + "limit": 25, + "filter": { "outcome": "allowed" } + } +} +``` + +limit 范围为 1–200。可选 filter 只有 projectId、subject 和 outcome;翻页使用上一页的 exact `{occurredAtMs,eventId}` 作为 `before`,不支持 offset、自由文本或无界导出。 + +## 执行 + +生产默认要求 TLS hostname verification: + +```sh +export QL3_POSTGRES_ADMIN_URL='postgresql://ql3_admin:REDACTED@postgres.example.test:5432/qinglong' +export QL3_POSTGRES_ADMIN_TLS_SERVERNAME='postgres.example.test' +export QL3_POSTGRES_ADMIN_TLS_CA_FILE='/secure/qinglong3/postgres-ca.pem' + +ql3-cluster-admin security \ + --command=/secure/qinglong3/security-command.json \ + --assertion=/secure/qinglong3/security-assertion.jwt \ + --keyset=/secure/qinglong3/security-keyset.json \ + --pepper=/secure/qinglong3/api-credential-pepper \ + --delivery=/secure/qinglong3/delivery/new-api-credential.json +``` + +也可以直接调用同镜像内的 `ql3-security-admin`。测试环境只有同时设置 `QL3_POSTGRES_ADMIN_TLS_MODE=disable` 与 `QL3_POSTGRES_ADMIN_ALLOW_INSECURE=true` 才能关闭 TLS;生产禁止这样部署。 + +成功签发或轮换时,stdout 只包含 delivery 文件名和 SHA-256。token 只存在于新建的 `0600` delivery 文件。目标已存在时命令失败且绝不覆盖。精确重放返回 `status=existing` 且不重新发布 token;如果首次响应丢失,先检查原 delivery 文件,确实丢失时使用新的 mutationId 执行 rotate,不能尝试恢复旧 token。 + +## 当前边界 + +本入口没有远程 API/UI、双人复核或 break-glass、pepper rotation、audit retention/export/alert,也没有默认安装的 Kubernetes Job。生产部署应把它放在受控工作站或自行审查的一次性 Job 中,并确保 admin database credential 不进入常驻 Cluster Control。完整安全决策见 [ADR-0500](../adr/ADR-0500-short-lived-cluster-security-administration-command.md)。 diff --git a/packages/ql3-cluster-admin/package.json b/packages/ql3-cluster-admin/package.json index d4291c1c..d33b26f5 100644 --- a/packages/ql3-cluster-admin/package.json +++ b/packages/ql3-cluster-admin/package.json @@ -75,6 +75,11 @@ "require": "./dist/security-administration/clusterAdministration.js", "default": "./dist/security-administration/clusterAdministration.js" }, + "./administration-command": { + "types": "./dist/security-administration/clusterAdministrationCommand.d.ts", + "require": "./dist/security-administration/clusterAdministrationCommand.js", + "default": "./dist/security-administration/clusterAdministrationCommand.js" + }, "./automation-management": { "types": "./dist/automation-management/automationManagement.d.ts", "require": "./dist/automation-management/automationManagement.js", @@ -414,6 +419,7 @@ }, "bin": { "ql3-cluster-admin": "dist/product-cli/cli.js", + "ql3-security-admin": "dist/security-administration/clusterAdministrationCli.js", "ql3-copilot-client": "dist/copilot-client/cli.js", "ql3-copilot-mcp": "dist/copilot-mcp/cli.js", "ql3-copilot-console": "dist/copilot-console/cli.js", diff --git a/packages/ql3-cluster-admin/src/management-support/pluginPackageIdentityAssertion.ts b/packages/ql3-cluster-admin/src/management-support/pluginPackageIdentityAssertion.ts index e1990372..0c8c43e6 100644 --- a/packages/ql3-cluster-admin/src/management-support/pluginPackageIdentityAssertion.ts +++ b/packages/ql3-cluster-admin/src/management-support/pluginPackageIdentityAssertion.ts @@ -85,6 +85,12 @@ export const CLUSTER_RUN_MANAGEMENT_IDENTITY_ASSERTION_PROFILE = Object.freeze({ purpose: 'run-management', }); +export const CLUSTER_SECURITY_ADMINISTRATION_IDENTITY_ASSERTION_PROFILE = + Object.freeze({ + type: 'ql3-security-administration+jwt', + purpose: 'security-administration', + }); + export interface ClusterPluginPackageIdentityAssertionVerifierOptions { readonly issuer: string; readonly audience: string; diff --git a/packages/ql3-cluster-admin/src/management-support/pluginPackageIdentityKeyset.ts b/packages/ql3-cluster-admin/src/management-support/pluginPackageIdentityKeyset.ts index d4c788e2..008aefb7 100644 --- a/packages/ql3-cluster-admin/src/management-support/pluginPackageIdentityKeyset.ts +++ b/packages/ql3-cluster-admin/src/management-support/pluginPackageIdentityKeyset.ts @@ -10,6 +10,7 @@ import { CLUSTER_APPROVAL_MANAGEMENT_IDENTITY_ASSERTION_PROFILE, CLUSTER_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_IDENTITY_ASSERTION_PROFILE, CLUSTER_RUN_MANAGEMENT_IDENTITY_ASSERTION_PROFILE, + CLUSTER_SECURITY_ADMINISTRATION_IDENTITY_ASSERTION_PROFILE, createClusterPluginPackageIdentityAssertionVerifier, type ClusterManagementIdentityAssertionProfile, type ClusterPluginPackageIdentityAssertionAuthentication, @@ -517,3 +518,13 @@ export function createClusterRunIdentityKeysetFile( assertionProfile: CLUSTER_RUN_MANAGEMENT_IDENTITY_ASSERTION_PROFILE, }); } + +export function createClusterSecurityAdministrationIdentityKeysetFile( + options: ClusterWorkerCredentialIdentityKeysetFileOptions, +): Readonly { + return createClusterPluginPackageIdentityKeysetFile({ + ...options, + assertionProfile: + CLUSTER_SECURITY_ADMINISTRATION_IDENTITY_ASSERTION_PROFILE, + }); +} diff --git a/packages/ql3-cluster-admin/src/product-cli/productCommand.ts b/packages/ql3-cluster-admin/src/product-cli/productCommand.ts index 22e82894..82785bfe 100644 --- a/packages/ql3-cluster-admin/src/product-cli/productCommand.ts +++ b/packages/ql3-cluster-admin/src/product-cli/productCommand.ts @@ -83,6 +83,12 @@ export const QINGLONG3_CLUSTER_PRODUCT_COMMANDS: readonly QingLong3ClusterProduc target: 'worker-credential/workerCredentialManagementClientCli.js', description: 'manage Worker credentials through the authenticated API', }), + Object.freeze({ + name: 'security', + binary: 'ql3-security-admin', + target: 'security-administration/clusterAdministrationCli.js', + description: 'administer identities, API credentials and audit records', + }), Object.freeze({ name: 'approval', binary: 'ql3-approval-client', diff --git a/packages/ql3-cluster-admin/src/security-administration/clusterAdministrationCli.ts b/packages/ql3-cluster-admin/src/security-administration/clusterAdministrationCli.ts new file mode 100644 index 00000000..c0447005 --- /dev/null +++ b/packages/ql3-cluster-admin/src/security-administration/clusterAdministrationCli.ts @@ -0,0 +1,87 @@ +#!/usr/bin/env node + +import { + ClusterAdministrationCommandError, + createClusterAdministrationCommandRunner, +} from './clusterAdministrationCommand'; + +const USAGE = + 'Usage: ql3-security-admin --command=/absolute/command.json --assertion=/absolute/assertion.jwt --keyset=/absolute/keyset.json --pepper=/absolute/pepper [--delivery=/absolute/token.json]'; + +function argumentsFrom(argv: readonly string[]) { + if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) { + return Object.freeze({ kind: 'help' as const }); + } + const values = new Map(); + for (const argument of argv) { + const match = /^--(command|assertion|keyset|pepper|delivery)=(\/.+)$/.exec( + argument, + ); + if (!match || values.has(match[1]!)) { + throw new ClusterAdministrationCommandError('CLI arguments are invalid'); + } + values.set(match[1]!, match[2]!); + } + if ( + !values.has('command') || + !values.has('assertion') || + !values.has('keyset') || + !values.has('pepper') + ) { + throw new ClusterAdministrationCommandError('CLI arguments are invalid'); + } + return Object.freeze({ + kind: 'run' as const, + paths: Object.freeze({ + commandFile: values.get('command')!, + assertionFile: values.get('assertion')!, + keysetFile: values.get('keyset')!, + pepperFile: values.get('pepper')!, + ...(values.has('delivery') + ? { deliveryFile: values.get('delivery')! } + : {}), + }), + }); +} + +function failure(error: unknown): Readonly> { + const candidate = error as { + readonly name?: unknown; + readonly code?: unknown; + }; + return Object.freeze({ + schemaVersion: 1, + component: 'qinglong3-security-administration', + event: 'command_failed', + name: + typeof candidate?.name === 'string' && candidate.name.length <= 128 + ? candidate.name + : 'Error', + ...(typeof candidate?.code === 'string' && candidate.code.length <= 128 + ? { code: candidate.code } + : {}), + }); +} + +async function main(argv: readonly string[]): Promise { + try { + const parsed = argumentsFrom(argv); + if (parsed.kind === 'help') { + process.stdout.write(`${USAGE}\n`); + return; + } + const result = await createClusterAdministrationCommandRunner().run( + parsed.paths, + process.env, + ); + process.stdout.write(`${JSON.stringify(result)}\n`); + } catch (error) { + process.stderr.write(`${JSON.stringify(failure(error))}\n`); + process.exitCode = + error instanceof ClusterAdministrationCommandError ? 64 : 1; + } +} + +if (require.main === module) { + void main(process.argv.slice(2)); +} diff --git a/packages/ql3-cluster-admin/src/security-administration/clusterAdministrationCommand.ts b/packages/ql3-cluster-admin/src/security-administration/clusterAdministrationCommand.ts new file mode 100644 index 00000000..44056d7a --- /dev/null +++ b/packages/ql3-cluster-admin/src/security-administration/clusterAdministrationCommand.ts @@ -0,0 +1,468 @@ +import { createHash } from 'node:crypto'; +import { basename } from 'node:path'; + +import { assertApiCredentialPepper } from '@qinglong/runtime-core/api-credential-token'; +import { normalizeIdentityAdministrationSubject } from '@qinglong/runtime-core/identity-administration'; +import { + normalizeSecurityAuditQuery, + type SecurityAuditQuery, + type SecurityAuditQueryPage, + type SecurityAuditQueryRepository, +} from '@qinglong/runtime-core/security-audit-query'; +import type { + SecurityPrincipal, + SecuritySubject, +} from '@qinglong/runtime-core/security'; +import { type ClusterAdministrationService } from './clusterAdministration'; +import { + CLUSTER_ADMINISTRATION_COMMAND_RUNTIME_DEPENDENCIES, + ClusterAdministrationCommandError, + clusterAdministrationCommandFileBeforeAdmission, + normalizeClusterAdministrationCommandPaths, + publishClusterAdministrationCredentialDelivery, +} from './clusterAdministrationCommandRuntime'; + +export { + ClusterAdministrationCommandError, + publishClusterAdministrationCredentialDelivery, +}; + +const UUID_V4_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const MAX_VERSION = 2_147_483_646; +const MAX_COMMAND_BYTES = 64 * 1024; +const MAX_ASSERTION_BYTES = 16 * 1024; +const MAX_PEPPER_BYTES = 256; + +export type ClusterAdministrationCommandOperation = + | 'identity.register' + | 'identity.enable' + | 'identity.disable' + | 'credential.issue' + | 'credential.rotate' + | 'credential.revoke' + | 'audit.list'; + +interface BaseMutationRequest { + readonly mutationId: string; + readonly requestId: string; + readonly expectedCurrentVersion: number; + readonly subject: SecuritySubject; +} + +interface IdentityCommand { + readonly schemaVersion: 1; + readonly operation: + | 'identity.register' + | 'identity.enable' + | 'identity.disable'; + readonly request: BaseMutationRequest; +} + +interface CredentialCommand { + readonly schemaVersion: 1; + readonly operation: + | 'credential.issue' + | 'credential.rotate' + | 'credential.revoke'; + readonly request: BaseMutationRequest & { + readonly credentialId: string; + readonly notBeforeAtMs?: number; + readonly expiresAtMs?: number; + }; +} + +interface AuditCommand { + readonly schemaVersion: 1; + readonly operation: 'audit.list'; + readonly request: SecurityAuditQuery; +} + +export type ClusterAdministrationCommand = + | IdentityCommand + | CredentialCommand + | AuditCommand; + +export interface ClusterAdministrationCommandPaths { + readonly commandFile: string; + readonly assertionFile: string; + readonly keysetFile: string; + readonly pepperFile: string; + readonly deliveryFile?: string; +} + +export type ClusterAdministrationCommandResult = + | Readonly<{ + schemaVersion: 1; + operation: IdentityCommand['operation']; + status: 'inserted' | 'existing'; + subject: Readonly; + version: number; + identityStatus: 'active' | 'disabled'; + }> + | Readonly<{ + schemaVersion: 1; + operation: CredentialCommand['operation']; + status: 'inserted' | 'existing'; + subject: Readonly; + credentialId: string; + version: number; + state: 'active' | 'revoked'; + delivery?: Readonly<{ fileName: string; digest: string }>; + }> + | Readonly<{ + schemaVersion: 1; + operation: 'audit.list'; + page: Readonly; + }>; + +export interface ClusterAdministrationCommandAuthority { + readonly administration: ClusterAdministrationService; + readonly audit: SecurityAuditQueryRepository; + close(): Promise; +} + +export interface ClusterAdministrationCommandDependencies { + readonly openAuthority: ( + environment: Readonly>, + pepper: string, + ) => Promise>; + readonly authenticate: ( + keysetFile: string, + assertion: string, + ) => Promise>; + readonly readFile: ( + filePath: string, + maximumBytes: number, + privateMaterial: boolean, + ) => Buffer; + readonly publishDelivery: (filePath: string, bytes: Buffer) => void; +} + +function exactObject( + value: unknown, + expectedKeys: readonly string[], + label: string, +): asserts value is Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new ClusterAdministrationCommandError(`${label} must be an object`); + } + const actual = Object.keys(value).sort(); + const expected = [...expectedKeys].sort(); + if ( + actual.length !== expected.length || + actual.some((key, index) => key !== expected[index]) + ) { + throw new ClusterAdministrationCommandError(`${label} shape is invalid`); + } +} + +function strictUtf8(bytes: Buffer, label: string): string { + try { + return new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch (error) { + throw new ClusterAdministrationCommandError( + `${label} must be strict UTF-8`, + error, + ); + } +} + +function normalizeMutationRequest( + value: unknown, + activeCredential: boolean, + credential: boolean, +): BaseMutationRequest & { + readonly credentialId?: string; + readonly notBeforeAtMs?: number; + readonly expiresAtMs?: number; +} { + exactObject( + value, + [ + 'expectedCurrentVersion', + 'mutationId', + 'requestId', + 'subject', + ...(credential ? ['credentialId'] : []), + ...(activeCredential ? ['notBeforeAtMs', 'expiresAtMs'] : []), + ], + 'request', + ); + let subject: Readonly; + try { + subject = normalizeIdentityAdministrationSubject( + value.subject as SecuritySubject, + ); + } catch (error) { + throw new ClusterAdministrationCommandError('subject is invalid', error); + } + if ( + typeof value.mutationId !== 'string' || + !UUID_V4_PATTERN.test(value.mutationId) || + typeof value.requestId !== 'string' || + !REQUEST_ID_PATTERN.test(value.requestId) || + !Number.isSafeInteger(value.expectedCurrentVersion) || + (value.expectedCurrentVersion as number) < 0 || + (value.expectedCurrentVersion as number) > MAX_VERSION || + (credential && + (typeof value.credentialId !== 'string' || + !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value.credentialId))) || + (activeCredential && + (!Number.isSafeInteger(value.notBeforeAtMs) || + (value.notBeforeAtMs as number) < 0 || + !Number.isSafeInteger(value.expiresAtMs) || + (value.expiresAtMs as number) <= (value.notBeforeAtMs as number))) + ) { + throw new ClusterAdministrationCommandError('mutation request is invalid'); + } + return Object.freeze({ + mutationId: value.mutationId, + requestId: value.requestId, + expectedCurrentVersion: value.expectedCurrentVersion as number, + subject, + ...(credential ? { credentialId: value.credentialId as string } : {}), + ...(activeCredential + ? { + notBeforeAtMs: value.notBeforeAtMs as number, + expiresAtMs: value.expiresAtMs as number, + } + : {}), + }); +} + +export function normalizeClusterAdministrationCommand( + value: unknown, +): Readonly { + exactObject(value, ['operation', 'request', 'schemaVersion'], 'command'); + const operations: readonly ClusterAdministrationCommandOperation[] = [ + 'identity.register', + 'identity.enable', + 'identity.disable', + 'credential.issue', + 'credential.rotate', + 'credential.revoke', + 'audit.list', + ]; + if ( + value.schemaVersion !== 1 || + typeof value.operation !== 'string' || + !operations.includes( + value.operation as ClusterAdministrationCommandOperation, + ) + ) { + throw new ClusterAdministrationCommandError( + 'command version or operation is invalid', + ); + } + const operation = value.operation as ClusterAdministrationCommandOperation; + if (operation === 'audit.list') { + let request: Readonly; + try { + request = normalizeSecurityAuditQuery( + value.request as SecurityAuditQuery, + ); + } catch (error) { + throw new ClusterAdministrationCommandError( + 'audit query is invalid', + error, + ); + } + return Object.freeze({ schemaVersion: 1, operation, request }); + } + const credential = operation.startsWith('credential.'); + const activeCredential = + operation === 'credential.issue' || operation === 'credential.rotate'; + return Object.freeze({ + schemaVersion: 1, + operation, + request: normalizeMutationRequest( + value.request, + activeCredential, + credential, + ), + } as ClusterAdministrationCommand); +} + +function parseCommand(bytes: Buffer): Readonly { + let value: unknown; + try { + value = JSON.parse(strictUtf8(bytes, 'command file')); + } catch (error) { + if (error instanceof ClusterAdministrationCommandError) throw error; + throw new ClusterAdministrationCommandError( + 'command file must contain JSON', + error, + ); + } + return normalizeClusterAdministrationCommand(value); +} + +function credentialDelivery( + command: CredentialCommand, + token: string, + result: Awaited>, +): Buffer { + return Buffer.from( + `${JSON.stringify({ + schemaVersion: 1, + kind: 'qinglong3-cluster-api-credential-delivery', + operation: command.operation, + mutationId: command.request.mutationId, + requestId: command.request.requestId, + credentialId: result.credential.credentialId, + subject: result.credential.subject, + version: result.credential.version, + token, + notBeforeAtMs: result.credential.notBeforeAtMs, + expiresAtMs: result.credential.expiresAtMs, + })}\n`, + 'utf8', + ); +} + +export function createClusterAdministrationCommandRunner( + dependencies: ClusterAdministrationCommandDependencies = CLUSTER_ADMINISTRATION_COMMAND_RUNTIME_DEPENDENCIES, +): Readonly<{ + run( + paths: ClusterAdministrationCommandPaths, + environment: Readonly>, + ): Promise>; +}> { + exactObject( + dependencies, + ['authenticate', 'openAuthority', 'publishDelivery', 'readFile'], + 'dependencies', + ); + if ( + typeof dependencies.openAuthority !== 'function' || + typeof dependencies.authenticate !== 'function' || + typeof dependencies.readFile !== 'function' || + typeof dependencies.publishDelivery !== 'function' + ) { + throw new ClusterAdministrationCommandError('dependencies are invalid'); + } + return Object.freeze({ + async run(pathsValue, environment) { + const commandFile = + clusterAdministrationCommandFileBeforeAdmission(pathsValue); + const commandBytes = dependencies.readFile( + commandFile, + MAX_COMMAND_BYTES, + true, + ); + let command: Readonly; + try { + command = parseCommand(commandBytes); + } finally { + commandBytes.fill(0); + } + const requiresDelivery = + command.operation === 'credential.issue' || + command.operation === 'credential.rotate'; + const paths = normalizeClusterAdministrationCommandPaths( + pathsValue, + requiresDelivery, + ); + const assertionBytes = dependencies.readFile( + paths.assertionFile, + MAX_ASSERTION_BYTES, + true, + ); + const pepperBytes = dependencies.readFile( + paths.pepperFile, + MAX_PEPPER_BYTES, + true, + ); + let authority: + | Readonly + | undefined; + try { + const assertion = strictUtf8(assertionBytes, 'assertion file').trim(); + const pepper = strictUtf8(pepperBytes, 'pepper file').trim(); + assertApiCredentialPepper(pepper); + const principal = await dependencies.authenticate( + paths.keysetFile, + assertion, + ); + authority = await dependencies.openAuthority(environment, pepper); + if (command.operation === 'audit.list') { + // Successful verification is the short-lived admin admission. Audit + // queries remain read-only and use the repository's bounded contract. + void principal; + return Object.freeze({ + schemaVersion: 1 as const, + operation: command.operation, + page: await authority.audit.list(command.request), + }); + } + if (command.operation.startsWith('identity.')) { + const identityCommand = command as Readonly; + const operation = identityCommand.operation.slice( + 'identity.'.length, + ) as 'register' | 'enable' | 'disable'; + const result = await authority.administration[ + operation === 'register' + ? 'registerIdentity' + : operation === 'enable' + ? 'enableIdentity' + : 'disableIdentity' + ]({ ...identityCommand.request, principal }); + return Object.freeze({ + schemaVersion: 1 as const, + operation: identityCommand.operation, + status: result.status, + subject: result.identity.subject, + version: result.identity.version, + identityStatus: result.identity.status, + }); + } + const credentialCommand = command as Readonly; + const method = + credentialCommand.operation === 'credential.issue' + ? 'issueCredential' + : credentialCommand.operation === 'credential.rotate' + ? 'rotateCredential' + : 'revokeCredential'; + const result = await authority.administration[method]({ + ...credentialCommand.request, + principal, + } as never); + let delivery: + | Readonly<{ fileName: string; digest: string }> + | undefined; + if (typeof result.token === 'string') { + const bytes = credentialDelivery( + credentialCommand, + result.token, + result, + ); + try { + dependencies.publishDelivery(paths.deliveryFile!, bytes); + delivery = Object.freeze({ + fileName: basename(paths.deliveryFile!), + digest: createHash('sha256').update(bytes).digest('hex'), + }); + } finally { + bytes.fill(0); + } + } + return Object.freeze({ + schemaVersion: 1 as const, + operation: credentialCommand.operation, + status: result.status, + subject: result.credential.subject, + credentialId: result.credential.credentialId, + version: result.credential.version, + state: result.credential.state, + ...(delivery === undefined ? {} : { delivery }), + }); + } finally { + assertionBytes.fill(0); + pepperBytes.fill(0); + await authority?.close(); + } + }, + }); +} diff --git a/packages/ql3-cluster-admin/src/security-administration/clusterAdministrationCommandRuntime.ts b/packages/ql3-cluster-admin/src/security-administration/clusterAdministrationCommandRuntime.ts new file mode 100644 index 00000000..c7f080fd --- /dev/null +++ b/packages/ql3-cluster-admin/src/security-administration/clusterAdministrationCommandRuntime.ts @@ -0,0 +1,467 @@ +import { randomBytes as nodeRandomBytes } from 'node:crypto'; +import { + closeSync, + constants, + fstatSync, + fsyncSync, + linkSync, + lstatSync, + openSync, + readSync, + unlinkSync, + writeSync, +} from 'node:fs'; +import { + basename, + dirname, + isAbsolute, + normalize, + parse, + resolve, +} from 'node:path'; + +import { assertApiCredentialPepper } from '@qinglong/runtime-core/api-credential-token'; +import { + PostgresApiCredentialAdministrationRepository, + PostgresIdentityAdministrationRepository, + PostgresSecurityAuditQueryRepository, + assertPostgresAdminSchemaReady, + createPostgresDatabaseOpener, + isPostgresTlsDnsServername, + loadPostgresCertificateAuthorityFile, + loadPostgresConnectionEnvironment, + type QingLongPostgresDatabaseResource, +} from '@qinglong/cluster-postgres/admin'; + +import { createClusterSecurityAdministrationIdentityKeysetFile } from '../management-support/pluginPackageIdentityKeyset'; +import { createClusterAdministrationService } from './clusterAdministration'; +import type { + ClusterAdministrationCommandAuthority, + ClusterAdministrationCommandDependencies, + ClusterAdministrationCommandPaths, +} from './clusterAdministrationCommand'; + +const MAX_DELIVERY_BYTES = 32 * 1024; + +export class ClusterAdministrationCommandError extends TypeError { + readonly code = 'QL3_CLUSTER_ADMINISTRATION_COMMAND_INVALID'; + + constructor(message: string, readonly cause?: unknown) { + super(`Cluster administration command is invalid: ${message}`); + this.name = 'ClusterAdministrationCommandError'; + } +} + +export function boundedClusterAdministrationFile( + value: unknown, + label: string, +): string { + if ( + typeof value !== 'string' || + !isAbsolute(value) || + normalize(value) !== value || + parse(value).root === value || + value.includes('\0') || + Buffer.byteLength(value, 'utf8') > 4_096 + ) { + throw new ClusterAdministrationCommandError( + `${label} must be a normalized absolute non-root path`, + ); + } + return value; +} + +function sameFileState( + left: Readonly<{ + dev: number; + ino: number; + size: number; + mtimeMs: number; + ctimeMs: number; + }>, + right: Readonly<{ + dev: number; + ino: number; + size: number; + mtimeMs: number; + ctimeMs: number; + }>, +): boolean { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.size === right.size && + left.mtimeMs === right.mtimeMs && + left.ctimeMs === right.ctimeMs + ); +} + +function readStableFile( + candidatePath: string, + maximumBytes: number, + privateMaterial: boolean, +): Buffer { + const filePath = boundedClusterAdministrationFile( + candidatePath, + 'input file', + ); + let descriptor: number | undefined; + let bytes: Buffer | undefined; + try { + descriptor = openSync( + filePath, + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0), + ); + const before = fstatSync(descriptor); + if ( + !before.isFile() || + before.size < 1 || + before.size > maximumBytes || + (before.mode & (privateMaterial ? 0o077 : 0o022)) !== 0 + ) { + throw new ClusterAdministrationCommandError( + 'input file authority is invalid', + ); + } + bytes = Buffer.alloc(before.size + 1); + let offset = 0; + while (offset < bytes.length) { + const count = readSync( + descriptor, + bytes, + offset, + bytes.length - offset, + offset, + ); + if (count === 0) break; + offset += count; + } + const after = fstatSync(descriptor); + if (offset !== before.size || !sameFileState(before, after)) { + throw new ClusterAdministrationCommandError( + 'input file changed while being read', + ); + } + return bytes.subarray(0, offset); + } catch (error) { + bytes?.fill(0); + if (error instanceof ClusterAdministrationCommandError) throw error; + throw new ClusterAdministrationCommandError( + 'input file cannot be read', + error, + ); + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } +} + +function booleanEnvironment( + environment: Readonly>, + name: string, +): boolean { + const value = environment[name]; + if (value === 'true') return true; + if (value === undefined || value === '' || value === 'false') return false; + throw new ClusterAdministrationCommandError(`${name} must be true or false`); +} + +function defaultDatabaseOpener( + environment: Readonly>, +): () => Promise { + let connection; + try { + connection = loadPostgresConnectionEnvironment(environment, { + connectionString: 'QL3_POSTGRES_ADMIN_URL', + host: 'QL3_POSTGRES_ADMIN_HOST', + port: 'QL3_POSTGRES_ADMIN_PORT', + database: 'QL3_POSTGRES_ADMIN_DATABASE', + user: 'QL3_POSTGRES_ADMIN_USER', + password: 'QL3_POSTGRES_ADMIN_PASSWORD', + }); + } catch (error) { + throw new ClusterAdministrationCommandError( + 'PostgreSQL admin connection is invalid', + error, + ); + } + const mode = environment.QL3_POSTGRES_ADMIN_TLS_MODE ?? 'verify-full'; + if (mode !== 'verify-full' && mode !== 'disable') { + throw new ClusterAdministrationCommandError( + 'QL3_POSTGRES_ADMIN_TLS_MODE must be verify-full or disable', + ); + } + if ( + mode === 'disable' && + !booleanEnvironment(environment, 'QL3_POSTGRES_ADMIN_ALLOW_INSECURE') + ) { + throw new ClusterAdministrationCommandError( + 'disabling PostgreSQL admin TLS requires explicit opt-in', + ); + } + const servername = environment.QL3_POSTGRES_ADMIN_TLS_SERVERNAME; + if (mode === 'verify-full' && !isPostgresTlsDnsServername(servername)) { + throw new ClusterAdministrationCommandError( + 'QL3_POSTGRES_ADMIN_TLS_SERVERNAME must be an explicit DNS name', + ); + } + const caFile = environment.QL3_POSTGRES_ADMIN_TLS_CA_FILE; + if (mode === 'disable' && caFile) { + throw new ClusterAdministrationCommandError( + 'PostgreSQL admin CA cannot be used when TLS is disabled', + ); + } + let ca: string | undefined; + if (caFile) { + try { + ca = loadPostgresCertificateAuthorityFile( + boundedClusterAdministrationFile(caFile, 'PostgreSQL admin CA file'), + ); + } catch (error) { + throw new ClusterAdministrationCommandError( + 'PostgreSQL admin CA file is invalid', + error, + ); + } + } + return createPostgresDatabaseOpener({ + role: 'admin', + connection: Object.freeze({ + ...connection, + tls: + mode === 'disable' + ? Object.freeze({ mode: 'disable' as const }) + : Object.freeze({ + mode: 'verify-full' as const, + servername: servername!, + ...(ca === undefined ? {} : { ca }), + }), + }), + pool: Object.freeze({ + applicationName: 'qinglong3-security-admin', + maxConnections: 1, + connectionTimeoutMs: 5_000, + idleTimeoutMs: 1_000, + maxLifetimeSeconds: 60, + }), + onPoolError() {}, + }); +} + +async function openDefaultAuthority( + environment: Readonly>, + pepper: string, +): Promise> { + assertApiCredentialPepper(pepper); + const database = await defaultDatabaseOpener(environment)(); + let closePromise: Promise | undefined; + const close = (): Promise => { + closePromise ??= database.close(); + return closePromise; + }; + try { + await assertPostgresAdminSchemaReady(database.pool); + return Object.freeze({ + administration: createClusterAdministrationService( + new PostgresIdentityAdministrationRepository(database.pool), + new PostgresApiCredentialAdministrationRepository(database.pool), + pepper, + ), + audit: new PostgresSecurityAuditQueryRepository(database.pool), + close, + }); + } catch (error) { + await close().catch(() => undefined); + throw error; + } +} + +async function authenticateDefault(keysetFile: string, assertion: string) { + const identities = createClusterSecurityAdministrationIdentityKeysetFile({ + filePath: boundedClusterAdministrationFile( + keysetFile, + 'identity keyset file', + ), + }); + return identities.bind(assertion).authenticate(); +} + +export function publishClusterAdministrationCredentialDelivery( + filePathValue: string, + bytes: Buffer, +): void { + const filePath = boundedClusterAdministrationFile( + filePathValue, + 'delivery file', + ); + if (bytes.length < 1 || bytes.length > MAX_DELIVERY_BYTES) { + throw new ClusterAdministrationCommandError( + 'credential delivery is oversized', + ); + } + const parent = dirname(filePath); + const parentStatus = lstatSync(parent, { throwIfNoEntry: false }); + if ( + parentStatus === undefined || + !parentStatus.isDirectory() || + parentStatus.isSymbolicLink() || + (parentStatus.mode & 0o077) !== 0 + ) { + throw new ClusterAdministrationCommandError( + 'credential delivery directory authority is invalid', + ); + } + const temporary = resolve( + parent, + `.${basename(filePath)}.${process.pid}.${nodeRandomBytes(12).toString( + 'hex', + )}.tmp`, + ); + let descriptor: number | undefined; + let linked = false; + try { + descriptor = openSync( + temporary, + constants.O_WRONLY | + constants.O_CREAT | + constants.O_EXCL | + (constants.O_NOFOLLOW ?? 0), + 0o600, + ); + let offset = 0; + while (offset < bytes.length) { + offset += writeSync( + descriptor, + bytes, + offset, + bytes.length - offset, + offset, + ); + } + fsyncSync(descriptor); + const status = fstatSync(descriptor); + if ( + !status.isFile() || + status.size !== bytes.length || + (status.mode & 0o077) !== 0 + ) { + throw new ClusterAdministrationCommandError( + 'credential delivery file authority is invalid', + ); + } + closeSync(descriptor); + descriptor = undefined; + linkSync(temporary, filePath); + linked = true; + unlinkSync(temporary); + const parentDescriptor = openSync( + parent, + constants.O_RDONLY | (constants.O_DIRECTORY ?? 0), + ); + try { + fsyncSync(parentDescriptor); + } finally { + closeSync(parentDescriptor); + } + } catch (error) { + if (descriptor !== undefined) closeSync(descriptor); + try { + unlinkSync(temporary); + } catch { + // Preserve the original delivery failure. + } + if (error instanceof ClusterAdministrationCommandError) throw error; + throw new ClusterAdministrationCommandError( + linked + ? 'credential delivery directory could not be synchronized' + : 'credential delivery could not be published', + error, + ); + } +} + +export function normalizeClusterAdministrationCommandPaths( + value: ClusterAdministrationCommandPaths, + requiresDelivery: boolean, +): Readonly { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new ClusterAdministrationCommandError( + 'command paths must be an object', + ); + } + const expected = [ + 'assertionFile', + 'commandFile', + 'keysetFile', + 'pepperFile', + ...(requiresDelivery ? ['deliveryFile'] : []), + ].sort(); + const actual = Object.keys(value).sort(); + if ( + actual.length !== expected.length || + actual.some((key, index) => key !== expected[index]) + ) { + throw new ClusterAdministrationCommandError( + 'command paths shape is invalid', + ); + } + return Object.freeze({ + commandFile: boundedClusterAdministrationFile( + value.commandFile, + 'command file', + ), + assertionFile: boundedClusterAdministrationFile( + value.assertionFile, + 'assertion file', + ), + keysetFile: boundedClusterAdministrationFile( + value.keysetFile, + 'identity keyset file', + ), + pepperFile: boundedClusterAdministrationFile( + value.pepperFile, + 'pepper file', + ), + ...(requiresDelivery + ? { + deliveryFile: boundedClusterAdministrationFile( + value.deliveryFile, + 'delivery file', + ), + } + : {}), + }); +} + +export function clusterAdministrationCommandFileBeforeAdmission( + value: unknown, +): string { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new ClusterAdministrationCommandError( + 'command paths must be an object', + ); + } + const candidate = value as Record; + const required = ['assertionFile', 'commandFile', 'keysetFile', 'pepperFile']; + if ( + required.some((key) => !Object.hasOwn(candidate, key)) || + Object.keys(candidate).some( + (key) => !required.includes(key) && key !== 'deliveryFile', + ) + ) { + throw new ClusterAdministrationCommandError( + 'command paths shape is invalid', + ); + } + return boundedClusterAdministrationFile( + candidate.commandFile, + 'command file', + ); +} + +export const CLUSTER_ADMINISTRATION_COMMAND_RUNTIME_DEPENDENCIES: ClusterAdministrationCommandDependencies = + Object.freeze({ + openAuthority: openDefaultAuthority, + authenticate: authenticateDefault, + readFile: readStableFile, + publishDelivery: publishClusterAdministrationCredentialDelivery, + }); diff --git a/packages/ql3-cluster-admin/test/clusterAdministrationCommand.test.cjs b/packages/ql3-cluster-admin/test/clusterAdministrationCommand.test.cjs new file mode 100644 index 00000000..48b0dee2 --- /dev/null +++ b/packages/ql3-cluster-admin/test/clusterAdministrationCommand.test.cjs @@ -0,0 +1,361 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { test } = require('node:test'); + +const { + createClusterAdministrationCommandRunner, + normalizeClusterAdministrationCommand, + publishClusterAdministrationCredentialDelivery, +} = require('@qinglong/cluster-admin/administration-command'); + +const PRINCIPAL = Object.freeze({ + subject: { type: 'user', id: 'security-owner' }, + authenticationId: 'assertion:security-command-1', + authenticatedAtMs: 900, + expiresAtMs: 2_000, + assurance: 'multi_factor', +}); +const SUBJECT = Object.freeze({ type: 'api_app', id: 'automation-client' }); +const PATHS = Object.freeze({ + commandFile: '/private/command.json', + assertionFile: '/private/assertion.jwt', + keysetFile: '/private/keyset.json', + pepperFile: '/private/pepper', +}); + +function identityCommand(overrides = {}) { + return { + schemaVersion: 1, + operation: 'identity.register', + request: { + mutationId: '123e4567-e89b-42d3-a456-426614174301', + requestId: 'security-identity-register-1', + expectedCurrentVersion: 0, + subject: SUBJECT, + }, + ...overrides, + }; +} + +function credentialCommand(overrides = {}) { + return { + schemaVersion: 1, + operation: 'credential.issue', + request: { + mutationId: '123e4567-e89b-42d3-a456-426614174302', + requestId: 'security-credential-issue-1', + expectedCurrentVersion: 0, + credentialId: 'automation-primary', + subject: SUBJECT, + notBeforeAtMs: 1_000, + expiresAtMs: 2_000, + }, + ...overrides, + }; +} + +function authority(overrides = {}) { + const calls = []; + let closes = 0; + const credential = { + credentialId: 'automation-primary', + subject: SUBJECT, + secretDigest: 'digest', + pepperKeyId: 'legacy-v1', + state: 'active', + version: 1, + createdAtMs: 1_000, + notBeforeAtMs: 1_000, + expiresAtMs: 2_000, + }; + const value = { + administration: { + async registerIdentity(request) { + calls.push(['identity.register', request]); + return { + status: 'inserted', + identity: { + subject: request.subject, + status: 'active', + version: 1, + createdAtMs: 1_000, + updatedAtMs: 1_000, + }, + mutation: {}, + }; + }, + async enableIdentity() { + throw new Error('unexpected enable'); + }, + async disableIdentity() { + throw new Error('unexpected disable'); + }, + async issueCredential(request) { + calls.push(['credential.issue', request]); + return { + status: 'inserted', + credential, + mutation: {}, + token: 'ql3c_automation-primary_private-secret', + }; + }, + async rotateCredential() { + throw new Error('unexpected rotate'); + }, + async revokeCredential() { + throw new Error('unexpected revoke'); + }, + }, + audit: { + async list(query) { + calls.push(['audit.list', query]); + return { records: [], nextCursor: null }; + }, + }, + async close() { + closes += 1; + }, + ...overrides, + }; + return { value, calls, closes: () => closes }; +} + +function runner(command, authorityValue, published = []) { + const buffers = []; + const pepper = 'A'.repeat(43); + const files = new Map([ + [PATHS.commandFile, `${JSON.stringify(command)}\n`], + [PATHS.assertionFile, 'signed.assertion.value'], + [PATHS.pepperFile, pepper], + ]); + const authentications = []; + const opens = []; + const instance = createClusterAdministrationCommandRunner({ + async openAuthority(environment, candidatePepper) { + opens.push({ environment, pepper: candidatePepper }); + return authorityValue; + }, + async authenticate(keysetFile, assertion) { + authentications.push({ keysetFile, assertion }); + return PRINCIPAL; + }, + readFile(filePath) { + const value = files.get(filePath); + if (value === undefined) throw new Error(`unexpected file: ${filePath}`); + const buffer = Buffer.from(value); + buffers.push(buffer); + return buffer; + }, + publishDelivery(filePath, bytes) { + published.push({ filePath, bytes: Buffer.from(bytes) }); + }, + }); + return { instance, buffers, authentications, opens }; +} + +test('executes one strongly authenticated identity mutation and closes authority', async () => { + const target = authority(); + const execution = runner(identityCommand(), target.value); + + const result = await execution.instance.run(PATHS, { deployment: 'test' }); + + assert.deepEqual(result, { + schemaVersion: 1, + operation: 'identity.register', + status: 'inserted', + subject: SUBJECT, + version: 1, + identityStatus: 'active', + }); + assert.deepEqual(execution.authentications, [ + { + keysetFile: PATHS.keysetFile, + assertion: 'signed.assertion.value', + }, + ]); + assert.equal(execution.opens[0].pepper, 'A'.repeat(43)); + assert.equal(target.calls[0][1].principal, PRINCIPAL); + assert.equal(target.closes(), 1); + assert.equal( + execution.buffers.every((value) => value.every((byte) => byte === 0)), + true, + ); +}); + +test('publishes a credential token only to the private delivery boundary', async () => { + const target = authority(); + const published = []; + const execution = runner(credentialCommand(), target.value, published); + const paths = { ...PATHS, deliveryFile: '/private/delivery.json' }; + + const result = await execution.instance.run(paths, {}); + + assert.equal(result.operation, 'credential.issue'); + assert.equal(result.status, 'inserted'); + assert.equal('token' in result, false); + assert.deepEqual(result.delivery.fileName, 'delivery.json'); + assert.match(result.delivery.digest, /^[0-9a-f]{64}$/); + assert.equal(published.length, 1); + const delivery = JSON.parse(published[0].bytes.toString('utf8')); + assert.equal(delivery.token, 'ql3c_automation-primary_private-secret'); + assert.equal(delivery.mutationId, credentialCommand().request.mutationId); + assert.equal(target.closes(), 1); +}); + +test('does not recreate lost token material during exact credential replay', async () => { + const base = authority(); + base.value.administration.issueCredential = async () => ({ + status: 'existing', + credential: { + credentialId: 'automation-primary', + subject: SUBJECT, + state: 'active', + version: 1, + createdAtMs: 1_000, + notBeforeAtMs: 1_000, + expiresAtMs: 2_000, + }, + mutation: {}, + token: null, + }); + const published = []; + const execution = runner(credentialCommand(), base.value, published); + + const result = await execution.instance.run( + { ...PATHS, deliveryFile: '/private/delivery.json' }, + {}, + ); + + assert.equal(result.status, 'existing'); + assert.equal('delivery' in result, false); + assert.deepEqual(published, []); + assert.equal(base.closes(), 1); +}); + +test('revokes a credential without requiring or publishing a delivery file', async () => { + const target = authority(); + target.value.administration.revokeCredential = async (request) => ({ + status: 'inserted', + credential: { + credentialId: request.credentialId, + subject: request.subject, + state: 'revoked', + version: 2, + createdAtMs: 1_000, + notBeforeAtMs: 1_000, + expiresAtMs: 2_000, + }, + mutation: {}, + }); + const published = []; + const execution = runner( + { + schemaVersion: 1, + operation: 'credential.revoke', + request: { + mutationId: '123e4567-e89b-42d3-a456-426614174303', + requestId: 'security-credential-revoke-1', + expectedCurrentVersion: 1, + credentialId: 'automation-primary', + subject: SUBJECT, + }, + }, + target.value, + published, + ); + + const result = await execution.instance.run(PATHS, {}); + + assert.deepEqual(result, { + schemaVersion: 1, + operation: 'credential.revoke', + status: 'inserted', + subject: SUBJECT, + credentialId: 'automation-primary', + version: 2, + state: 'revoked', + }); + assert.deepEqual(published, []); + assert.equal(target.closes(), 1); +}); + +test('keeps audit query bounded and rejects widened command shapes before admission', async () => { + const query = { + schemaVersion: 1, + operation: 'audit.list', + request: { limit: 25, filter: { outcome: 'allowed' } }, + }; + const target = authority(); + const execution = runner(query, target.value); + const result = await execution.instance.run(PATHS, {}); + assert.deepEqual(result, { + schemaVersion: 1, + operation: 'audit.list', + page: { records: [], nextCursor: null }, + }); + assert.deepEqual(target.calls, [ + ['audit.list', { limit: 25, filter: { outcome: 'allowed' } }], + ]); + assert.throws( + () => normalizeClusterAdministrationCommand({ ...query, debug: true }), + /command shape is invalid/, + ); + assert.throws( + () => + normalizeClusterAdministrationCommand({ + ...query, + request: { limit: 201, filter: {} }, + }), + /audit query is invalid/, + ); +}); + +test('rejects widened path authority before reading a command file', async () => { + let reads = 0; + const instance = createClusterAdministrationCommandRunner({ + async openAuthority() { + throw new Error('must not open authority'); + }, + async authenticate() { + throw new Error('must not authenticate'); + }, + readFile() { + reads += 1; + throw new Error('must not read'); + }, + publishDelivery() { + throw new Error('must not publish'); + }, + }); + + await assert.rejects( + instance.run({ ...PATHS, ambientCredential: true }, {}), + /command paths shape is invalid/, + ); + assert.equal(reads, 0); +}); + +test('publishes a 0600 no-replace delivery and leaves an existing target intact', (t) => { + const directory = fs.mkdtempSync( + path.join(os.tmpdir(), 'ql3-security-delivery-'), + ); + fs.chmodSync(directory, 0o700); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const filePath = path.join(directory, 'credential.json'); + const bytes = Buffer.from('{"token":"secret"}\n'); + + publishClusterAdministrationCredentialDelivery(filePath, bytes); + assert.equal(fs.statSync(filePath).mode & 0o777, 0o600); + assert.deepEqual(fs.readFileSync(filePath), bytes); + assert.throws( + () => + publishClusterAdministrationCredentialDelivery( + filePath, + Buffer.from('{"token":"replacement"}\n'), + ), + /could not be published/, + ); + assert.deepEqual(fs.readFileSync(filePath), bytes); +}); diff --git a/packages/ql3-cluster-admin/test/pluginPackageIdentityKeyset.test.cjs b/packages/ql3-cluster-admin/test/pluginPackageIdentityKeyset.test.cjs index e5e3f300..e9d22c77 100644 --- a/packages/ql3-cluster-admin/test/pluginPackageIdentityKeyset.test.cjs +++ b/packages/ql3-cluster-admin/test/pluginPackageIdentityKeyset.test.cjs @@ -13,6 +13,7 @@ const { createClusterApprovalIdentityKeysetFile, createClusterModelProviderCredentialIdentityKeysetFile, createClusterRunIdentityKeysetFile, + createClusterSecurityAdministrationIdentityKeysetFile, } = require('@qinglong/cluster-admin/plugin-package-identity-keyset'); const NOW_MS = 1_700_000_000_000; @@ -249,6 +250,38 @@ function runAssertion(key, overrides = {}) { ).toString('base64url')}`; } +function securityAdministrationAssertion(key, overrides = {}) { + const header = Buffer.from( + JSON.stringify({ + alg: 'EdDSA', + kid: key.kid, + typ: 'ql3-security-administration+jwt', + }), + ).toString('base64url'); + const now = Math.floor(NOW_MS / 1000); + const payload = Buffer.from( + JSON.stringify({ + acr: 'urn:ql3:mfa', + amr: ['pwd', 'otp'], + aud: 'qinglong3-security-administration', + auth_time: now - 10, + exp: now + 120, + iat: now, + iss: ISSUER, + jti: `security-administration-assertion-${key.kid}`, + ql3_purpose: 'security-administration', + sub: 'security-owner-1', + ...overrides, + }), + ).toString('base64url'); + const signed = `${header}.${payload}`; + return `${signed}.${sign( + null, + Buffer.from(signed, 'ascii'), + key.privateKey, + ).toString('base64url')}`; +} + async function atomicWrite(filePath, document) { const nextPath = `${filePath}.next`; await writeFile(nextPath, `${JSON.stringify(document)}\n`, { mode: 0o644 }); @@ -337,10 +370,9 @@ test('loads an automation keyset with a purpose isolated from other management p type: 'user', id: 'automation-operator-1', }); - await assert.rejects( - provider.bind(workerAssertion(key)).authenticate(), - { code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID' }, - ); + await assert.rejects(provider.bind(workerAssertion(key)).authenticate(), { + code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID', + }); await assert.rejects(provider.bind(assertion(key)).authenticate(), { code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID', }); @@ -358,14 +390,19 @@ test('loads an Approval keyset isolated by type, purpose and audience', async () filePath, now: () => NOW_MS, }); - const principal = await provider.bind(approvalAssertion(key)).authenticate(); + const principal = await provider + .bind(approvalAssertion(key)) + .authenticate(); assert.deepEqual(principal.subject, { type: 'user', id: 'approval-owner-1', }); - await assert.rejects(provider.bind(automationAssertion(key)).authenticate(), { - code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID', - }); + await assert.rejects( + provider.bind(automationAssertion(key)).authenticate(), + { + code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID', + }, + ); await assert.rejects(provider.bind(assertion(key)).authenticate(), { code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID', }); @@ -435,6 +472,40 @@ test('loads a Run keyset isolated from every other management purpose', async () }); }); +test('loads a Security Administration keyset isolated from other management purposes', async () => { + await fixture(async ({ filePath }) => { + const key = reviewedKey('security-administration-key-1'); + await atomicWrite(filePath, { + ...keyset(1, [key]), + audience: 'qinglong3-security-administration', + }); + const provider = createClusterSecurityAdministrationIdentityKeysetFile({ + filePath, + now: () => NOW_MS, + }); + const principal = await provider + .bind(securityAdministrationAssertion(key)) + .authenticate(); + assert.deepEqual(principal.subject, { + type: 'user', + id: 'security-owner-1', + }); + await assert.rejects(provider.bind(runAssertion(key)).authenticate(), { + code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID', + }); + await assert.rejects( + provider + .bind( + securityAdministrationAssertion(key, { + ql3_purpose: 'run-management', + }), + ) + .authenticate(), + { code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID' }, + ); + }); +}); + test('supports overlap rotation then immediately revokes the previous key', async () => { await fixture(async ({ filePath }) => { const first = reviewedKey('issuer-key-1'); diff --git a/packages/ql3-cluster-admin/test/productCli.test.cjs b/packages/ql3-cluster-admin/test/productCli.test.cjs index 9b86f11b..661d0b2a 100644 --- a/packages/ql3-cluster-admin/test/productCli.test.cjs +++ b/packages/ql3-cluster-admin/test/productCli.test.cjs @@ -336,7 +336,7 @@ function validContextFixture(t) { test('catalog exposes only reviewed product entrypoints from the same package', () => { assert.equal(manifest.bin['ql3-cluster-admin'], 'dist/product-cli/cli.js'); - assert.equal(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length, 12); + assert.equal(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length, 13); assert.equal( new Set(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.map(({ name }) => name)).size, QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length, @@ -354,6 +354,7 @@ test('catalog exposes only reviewed product entrypoints from the same package', ); assert.equal( command.binary.includes('-client') || + command.binary === 'ql3-security-admin' || command.binary === 'ql3-copilot-mcp' || command.binary === 'ql3-copilot-console' || command.binary === 'ql3-copilot-evidence-verify', @@ -391,6 +392,7 @@ test('help and version are bounded installation-derived product facts', () => { /\n evidence-verify\s+verify one redacted Console evidence/, ); assert.match(help, /Server, migration, recovery, executor and key-custody/); + assert.match(help, /\n security\s+administer identities, API credentials/); assert.equal(help.includes('plugin-package-manage'), false); assert.equal( loadQingLong3ClusterProductVersion(moduleDirectory), @@ -923,6 +925,11 @@ test('binary exposes help/version and delegates without a shell', () => { assert.match(delegatedHelp.stdout, /^Usage: ql3-run-client /); assert.equal(delegatedHelp.stderr, ''); + const securityHelp = runCli(['security', '--help']); + assert.equal(securityHelp.status, 0); + assert.match(securityHelp.stdout, /^Usage: ql3-security-admin /); + assert.equal(securityHelp.stderr, ''); + const rejected = runCli(['../../tmp/not-a-command']); assert.equal(rejected.status, 64); assert.equal(rejected.stdout, ''); diff --git a/scripts/ql3-cluster-admin-product-live-contract.cjs b/scripts/ql3-cluster-admin-product-live-contract.cjs index 7d9c06f2..a76ff60b 100644 --- a/scripts/ql3-cluster-admin-product-live-contract.cjs +++ b/scripts/ql3-cluster-admin-product-live-contract.cjs @@ -40,6 +40,10 @@ const COMMANDS = Object.freeze([ name: 'worker-credential', usage: 'Usage: ql3-worker-credential-client ', }), + Object.freeze({ + name: 'security', + usage: 'Usage: ql3-security-admin ', + }), Object.freeze({ name: 'approval', usage: 'Usage: ql3-approval-client ' }), Object.freeze({ name: 'run', usage: 'Usage: ql3-run-client ' }), Object.freeze({ name: 'automation', usage: 'Usage: ql3-automation-client ' }), diff --git a/test/back/ql3PackageBoundaryAudit.test.cjs b/test/back/ql3PackageBoundaryAudit.test.cjs index f120d4b9..a135820a 100644 --- a/test/back/ql3PackageBoundaryAudit.test.cjs +++ b/test/back/ql3PackageBoundaryAudit.test.cjs @@ -340,10 +340,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: clusterAdmin.rootSourceFileRoles, }, { - sourceFiles: 129, + sourceFiles: 132, rootSourceFiles: 1, rootSourceLines: 61, - nestedSourceFiles: 128, + nestedSourceFiles: 131, rootSourceFileRoles: { 'modelInvocationMigrationCli.ts': 'binary_entry', },