mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): add short-lived security administration command
This commit is contained in:
@@ -43,7 +43,8 @@ client and stdio transport.
|
|||||||
Its default entrypoint is the bounded `ql3-cluster-admin` product facade. The
|
Its default entrypoint is the bounded `ql3-cluster-admin` product facade. The
|
||||||
facade exposes eight operator-facing remote clients: `copilot`, `package`,
|
facade exposes eight operator-facing remote clients: `copilot`, `package`,
|
||||||
`package-kubernetes`, `worker-credential`, `approval`, `run`, `automation` and
|
`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`;
|
with the current Node executable, an exact same-image target and `shell=false`;
|
||||||
arguments and MCP stdio remain opaque. Server
|
arguments and MCP stdio remain opaque. Server
|
||||||
`*-manage` processes, migration, recovery, executors, Prompt output key custody
|
`*-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
|
operations. Existing Jobs continue to name their exact binary and do not
|
||||||
inherit facade authority.
|
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
|
Workstation operators may pass an explicit owner-private context to reuse only
|
||||||
stable client paths:
|
stable client paths:
|
||||||
|
|
||||||
@@ -88,8 +113,8 @@ ql3-cluster-admin context validate \
|
|||||||
|
|
||||||
This command reuses the production client parsers to validate every selected
|
This command reuses the production client parsers to validate every selected
|
||||||
endpoint shape, CA, matching client certificate/private key and the bounded
|
endpoint shape, CA, matching client certificate/private key and the bounded
|
||||||
Kubernetes config. It also verifies that all nine facade targets exist in the
|
Kubernetes config. It also verifies the context-aware facade targets exist in
|
||||||
same installation. It does not read a command or assertion, open a network
|
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
|
connection, query Kubernetes or mutate the cluster. Its JSON summary contains
|
||||||
only command names and reviewed transport/authentication classes; paths,
|
only command names and reviewed transport/authentication classes; paths,
|
||||||
endpoints, namespaces, context names and credentials are never emitted.
|
endpoints, namespaces, context names and credentials are never emitted.
|
||||||
|
|||||||
@@ -6,10 +6,12 @@
|
|||||||
- 目标版本:QingLong 3.x
|
- 目标版本:QingLong 3.x
|
||||||
- 作者:QingLong Maintainers
|
- 作者:QingLong Maintainers
|
||||||
- 创建日期:2026-07-17
|
- 创建日期:2026-07-17
|
||||||
- 最后更新:2026-08-24
|
- 最后更新:2026-08-25
|
||||||
- 讨论范围:架构与演进路线,不包含最终 UI 视觉方案
|
- 讨论范围:架构与演进路线,不包含最终 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 与断电恢复门。
|
- 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。
|
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 切换仍须独立门禁。
|
未进入当前孵化切片的代码在通过对应 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 切换仍须独立门禁。
|
||||||
|
|
||||||
|
|||||||
@@ -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 独立证明。
|
||||||
@@ -503,6 +503,7 @@
|
|||||||
| [ADR-0497](./ADR-0497-atomic-cluster-legacy-env-migration-application.md) | Cluster Legacy Env 的原子 Task/Trigger 迁移与只追加回执 | Accepted |
|
| [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-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-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 |
|
||||||
|
|
||||||
## 规则
|
## 规则
|
||||||
|
|
||||||
|
|||||||
@@ -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)。
|
||||||
@@ -75,6 +75,11 @@
|
|||||||
"require": "./dist/security-administration/clusterAdministration.js",
|
"require": "./dist/security-administration/clusterAdministration.js",
|
||||||
"default": "./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": {
|
"./automation-management": {
|
||||||
"types": "./dist/automation-management/automationManagement.d.ts",
|
"types": "./dist/automation-management/automationManagement.d.ts",
|
||||||
"require": "./dist/automation-management/automationManagement.js",
|
"require": "./dist/automation-management/automationManagement.js",
|
||||||
@@ -414,6 +419,7 @@
|
|||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"ql3-cluster-admin": "dist/product-cli/cli.js",
|
"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-client": "dist/copilot-client/cli.js",
|
||||||
"ql3-copilot-mcp": "dist/copilot-mcp/cli.js",
|
"ql3-copilot-mcp": "dist/copilot-mcp/cli.js",
|
||||||
"ql3-copilot-console": "dist/copilot-console/cli.js",
|
"ql3-copilot-console": "dist/copilot-console/cli.js",
|
||||||
|
|||||||
@@ -85,6 +85,12 @@ export const CLUSTER_RUN_MANAGEMENT_IDENTITY_ASSERTION_PROFILE = Object.freeze({
|
|||||||
purpose: 'run-management',
|
purpose: 'run-management',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const CLUSTER_SECURITY_ADMINISTRATION_IDENTITY_ASSERTION_PROFILE =
|
||||||
|
Object.freeze({
|
||||||
|
type: 'ql3-security-administration+jwt',
|
||||||
|
purpose: 'security-administration',
|
||||||
|
});
|
||||||
|
|
||||||
export interface ClusterPluginPackageIdentityAssertionVerifierOptions {
|
export interface ClusterPluginPackageIdentityAssertionVerifierOptions {
|
||||||
readonly issuer: string;
|
readonly issuer: string;
|
||||||
readonly audience: string;
|
readonly audience: string;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
CLUSTER_APPROVAL_MANAGEMENT_IDENTITY_ASSERTION_PROFILE,
|
CLUSTER_APPROVAL_MANAGEMENT_IDENTITY_ASSERTION_PROFILE,
|
||||||
CLUSTER_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_IDENTITY_ASSERTION_PROFILE,
|
CLUSTER_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_IDENTITY_ASSERTION_PROFILE,
|
||||||
CLUSTER_RUN_MANAGEMENT_IDENTITY_ASSERTION_PROFILE,
|
CLUSTER_RUN_MANAGEMENT_IDENTITY_ASSERTION_PROFILE,
|
||||||
|
CLUSTER_SECURITY_ADMINISTRATION_IDENTITY_ASSERTION_PROFILE,
|
||||||
createClusterPluginPackageIdentityAssertionVerifier,
|
createClusterPluginPackageIdentityAssertionVerifier,
|
||||||
type ClusterManagementIdentityAssertionProfile,
|
type ClusterManagementIdentityAssertionProfile,
|
||||||
type ClusterPluginPackageIdentityAssertionAuthentication,
|
type ClusterPluginPackageIdentityAssertionAuthentication,
|
||||||
@@ -517,3 +518,13 @@ export function createClusterRunIdentityKeysetFile(
|
|||||||
assertionProfile: CLUSTER_RUN_MANAGEMENT_IDENTITY_ASSERTION_PROFILE,
|
assertionProfile: CLUSTER_RUN_MANAGEMENT_IDENTITY_ASSERTION_PROFILE,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createClusterSecurityAdministrationIdentityKeysetFile(
|
||||||
|
options: ClusterWorkerCredentialIdentityKeysetFileOptions,
|
||||||
|
): Readonly<ClusterPluginPackageIdentityKeysetFile> {
|
||||||
|
return createClusterPluginPackageIdentityKeysetFile({
|
||||||
|
...options,
|
||||||
|
assertionProfile:
|
||||||
|
CLUSTER_SECURITY_ADMINISTRATION_IDENTITY_ASSERTION_PROFILE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -83,6 +83,12 @@ export const QINGLONG3_CLUSTER_PRODUCT_COMMANDS: readonly QingLong3ClusterProduc
|
|||||||
target: 'worker-credential/workerCredentialManagementClientCli.js',
|
target: 'worker-credential/workerCredentialManagementClientCli.js',
|
||||||
description: 'manage Worker credentials through the authenticated API',
|
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({
|
Object.freeze({
|
||||||
name: 'approval',
|
name: 'approval',
|
||||||
binary: 'ql3-approval-client',
|
binary: 'ql3-approval-client',
|
||||||
|
|||||||
@@ -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<string, string>();
|
||||||
|
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<Record<string, unknown>> {
|
||||||
|
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<void> {
|
||||||
|
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));
|
||||||
|
}
|
||||||
+468
@@ -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<SecuritySubject>;
|
||||||
|
version: number;
|
||||||
|
identityStatus: 'active' | 'disabled';
|
||||||
|
}>
|
||||||
|
| Readonly<{
|
||||||
|
schemaVersion: 1;
|
||||||
|
operation: CredentialCommand['operation'];
|
||||||
|
status: 'inserted' | 'existing';
|
||||||
|
subject: Readonly<SecuritySubject>;
|
||||||
|
credentialId: string;
|
||||||
|
version: number;
|
||||||
|
state: 'active' | 'revoked';
|
||||||
|
delivery?: Readonly<{ fileName: string; digest: string }>;
|
||||||
|
}>
|
||||||
|
| Readonly<{
|
||||||
|
schemaVersion: 1;
|
||||||
|
operation: 'audit.list';
|
||||||
|
page: Readonly<SecurityAuditQueryPage>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export interface ClusterAdministrationCommandAuthority {
|
||||||
|
readonly administration: ClusterAdministrationService;
|
||||||
|
readonly audit: SecurityAuditQueryRepository;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClusterAdministrationCommandDependencies {
|
||||||
|
readonly openAuthority: (
|
||||||
|
environment: Readonly<Record<string, string | undefined>>,
|
||||||
|
pepper: string,
|
||||||
|
) => Promise<Readonly<ClusterAdministrationCommandAuthority>>;
|
||||||
|
readonly authenticate: (
|
||||||
|
keysetFile: string,
|
||||||
|
assertion: string,
|
||||||
|
) => Promise<Readonly<SecurityPrincipal>>;
|
||||||
|
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<string, unknown> {
|
||||||
|
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<SecuritySubject>;
|
||||||
|
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<ClusterAdministrationCommand> {
|
||||||
|
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<SecurityAuditQuery>;
|
||||||
|
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<ClusterAdministrationCommand> {
|
||||||
|
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<ReturnType<ClusterAdministrationService['issueCredential']>>,
|
||||||
|
): 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<Record<string, string | undefined>>,
|
||||||
|
): Promise<Readonly<ClusterAdministrationCommandResult>>;
|
||||||
|
}> {
|
||||||
|
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<ClusterAdministrationCommand>;
|
||||||
|
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<ClusterAdministrationCommandAuthority>
|
||||||
|
| 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<IdentityCommand>;
|
||||||
|
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<CredentialCommand>;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
+467
@@ -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<Record<string, string | undefined>>,
|
||||||
|
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<Record<string, string | undefined>>,
|
||||||
|
): () => Promise<QingLongPostgresDatabaseResource> {
|
||||||
|
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<Record<string, string | undefined>>,
|
||||||
|
pepper: string,
|
||||||
|
): Promise<Readonly<ClusterAdministrationCommandAuthority>> {
|
||||||
|
assertApiCredentialPepper(pepper);
|
||||||
|
const database = await defaultDatabaseOpener(environment)();
|
||||||
|
let closePromise: Promise<void> | undefined;
|
||||||
|
const close = (): Promise<void> => {
|
||||||
|
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<ClusterAdministrationCommandPaths> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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,
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ const {
|
|||||||
createClusterApprovalIdentityKeysetFile,
|
createClusterApprovalIdentityKeysetFile,
|
||||||
createClusterModelProviderCredentialIdentityKeysetFile,
|
createClusterModelProviderCredentialIdentityKeysetFile,
|
||||||
createClusterRunIdentityKeysetFile,
|
createClusterRunIdentityKeysetFile,
|
||||||
|
createClusterSecurityAdministrationIdentityKeysetFile,
|
||||||
} = require('@qinglong/cluster-admin/plugin-package-identity-keyset');
|
} = require('@qinglong/cluster-admin/plugin-package-identity-keyset');
|
||||||
|
|
||||||
const NOW_MS = 1_700_000_000_000;
|
const NOW_MS = 1_700_000_000_000;
|
||||||
@@ -249,6 +250,38 @@ function runAssertion(key, overrides = {}) {
|
|||||||
).toString('base64url')}`;
|
).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) {
|
async function atomicWrite(filePath, document) {
|
||||||
const nextPath = `${filePath}.next`;
|
const nextPath = `${filePath}.next`;
|
||||||
await writeFile(nextPath, `${JSON.stringify(document)}\n`, { mode: 0o644 });
|
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',
|
type: 'user',
|
||||||
id: 'automation-operator-1',
|
id: 'automation-operator-1',
|
||||||
});
|
});
|
||||||
await assert.rejects(
|
await assert.rejects(provider.bind(workerAssertion(key)).authenticate(), {
|
||||||
provider.bind(workerAssertion(key)).authenticate(),
|
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID',
|
||||||
{ code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID' },
|
});
|
||||||
);
|
|
||||||
await assert.rejects(provider.bind(assertion(key)).authenticate(), {
|
await assert.rejects(provider.bind(assertion(key)).authenticate(), {
|
||||||
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID',
|
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID',
|
||||||
});
|
});
|
||||||
@@ -358,14 +390,19 @@ test('loads an Approval keyset isolated by type, purpose and audience', async ()
|
|||||||
filePath,
|
filePath,
|
||||||
now: () => NOW_MS,
|
now: () => NOW_MS,
|
||||||
});
|
});
|
||||||
const principal = await provider.bind(approvalAssertion(key)).authenticate();
|
const principal = await provider
|
||||||
|
.bind(approvalAssertion(key))
|
||||||
|
.authenticate();
|
||||||
assert.deepEqual(principal.subject, {
|
assert.deepEqual(principal.subject, {
|
||||||
type: 'user',
|
type: 'user',
|
||||||
id: 'approval-owner-1',
|
id: 'approval-owner-1',
|
||||||
});
|
});
|
||||||
await assert.rejects(provider.bind(automationAssertion(key)).authenticate(), {
|
await assert.rejects(
|
||||||
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID',
|
provider.bind(automationAssertion(key)).authenticate(),
|
||||||
});
|
{
|
||||||
|
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID',
|
||||||
|
},
|
||||||
|
);
|
||||||
await assert.rejects(provider.bind(assertion(key)).authenticate(), {
|
await assert.rejects(provider.bind(assertion(key)).authenticate(), {
|
||||||
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID',
|
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 () => {
|
test('supports overlap rotation then immediately revokes the previous key', async () => {
|
||||||
await fixture(async ({ filePath }) => {
|
await fixture(async ({ filePath }) => {
|
||||||
const first = reviewedKey('issuer-key-1');
|
const first = reviewedKey('issuer-key-1');
|
||||||
|
|||||||
@@ -336,7 +336,7 @@ function validContextFixture(t) {
|
|||||||
|
|
||||||
test('catalog exposes only reviewed product entrypoints from the same package', () => {
|
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(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(
|
assert.equal(
|
||||||
new Set(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.map(({ name }) => name)).size,
|
new Set(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.map(({ name }) => name)).size,
|
||||||
QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length,
|
QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length,
|
||||||
@@ -354,6 +354,7 @@ test('catalog exposes only reviewed product entrypoints from the same package',
|
|||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
command.binary.includes('-client') ||
|
command.binary.includes('-client') ||
|
||||||
|
command.binary === 'ql3-security-admin' ||
|
||||||
command.binary === 'ql3-copilot-mcp' ||
|
command.binary === 'ql3-copilot-mcp' ||
|
||||||
command.binary === 'ql3-copilot-console' ||
|
command.binary === 'ql3-copilot-console' ||
|
||||||
command.binary === 'ql3-copilot-evidence-verify',
|
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/,
|
/\n evidence-verify\s+verify one redacted Console evidence/,
|
||||||
);
|
);
|
||||||
assert.match(help, /Server, migration, recovery, executor and key-custody/);
|
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(help.includes('plugin-package-manage'), false);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
loadQingLong3ClusterProductVersion(moduleDirectory),
|
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.match(delegatedHelp.stdout, /^Usage: ql3-run-client /);
|
||||||
assert.equal(delegatedHelp.stderr, '');
|
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']);
|
const rejected = runCli(['../../tmp/not-a-command']);
|
||||||
assert.equal(rejected.status, 64);
|
assert.equal(rejected.status, 64);
|
||||||
assert.equal(rejected.stdout, '');
|
assert.equal(rejected.stdout, '');
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ const COMMANDS = Object.freeze([
|
|||||||
name: 'worker-credential',
|
name: 'worker-credential',
|
||||||
usage: 'Usage: ql3-worker-credential-client ',
|
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: 'approval', usage: 'Usage: ql3-approval-client ' }),
|
||||||
Object.freeze({ name: 'run', usage: 'Usage: ql3-run-client ' }),
|
Object.freeze({ name: 'run', usage: 'Usage: ql3-run-client ' }),
|
||||||
Object.freeze({ name: 'automation', usage: 'Usage: ql3-automation-client ' }),
|
Object.freeze({ name: 'automation', usage: 'Usage: ql3-automation-client ' }),
|
||||||
|
|||||||
@@ -340,10 +340,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
|||||||
rootSourceFileRoles: clusterAdmin.rootSourceFileRoles,
|
rootSourceFileRoles: clusterAdmin.rootSourceFileRoles,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sourceFiles: 129,
|
sourceFiles: 132,
|
||||||
rootSourceFiles: 1,
|
rootSourceFiles: 1,
|
||||||
rootSourceLines: 61,
|
rootSourceLines: 61,
|
||||||
nestedSourceFiles: 128,
|
nestedSourceFiles: 131,
|
||||||
rootSourceFileRoles: {
|
rootSourceFileRoles: {
|
||||||
'modelInvocationMigrationCli.ts': 'binary_entry',
|
'modelInvocationMigrationCli.ts': 'binary_entry',
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user