feat(ql3): add short-lived security administration command

This commit is contained in:
whyour
2026-08-25 02:09:06 +08:00
parent 9c736f0943
commit 7b3d49acda
17 changed files with 1756 additions and 17 deletions
+28 -3
View File
@@ -43,7 +43,8 @@ client and stdio transport.
Its default entrypoint is the bounded `ql3-cluster-admin` product facade. The
facade exposes eight operator-facing remote clients: `copilot`, `package`,
`package-kubernetes`, `worker-credential`, `approval`, `run`, `automation` and
`model-credential`, plus the bounded `copilot-mcp` stdio process. It delegates
`model-credential`, the short-lived local `security` administration command,
plus the bounded `copilot-mcp` stdio process. It delegates
with the current Node executable, an exact same-image target and `shell=false`;
arguments and MCP stdio remain opaque. Server
`*-manage` processes, migration, recovery, executors, Prompt output key custody
@@ -51,6 +52,30 @@ and garbage collection remain separate explicit binaries and Kubernetes
operations. Existing Jobs continue to name their exact binary and do not
inherit facade authority.
Run Identity, API credential and bounded Security Audit administration only
from a short-lived operator workstation or one-shot Job. The command opens at
most one admin PostgreSQL connection and never starts a listener:
```sh
ql3-cluster-admin security \
--command=/secure/qinglong3/security-command.json \
--assertion=/secure/qinglong3/security-assertion.jwt \
--keyset=/secure/qinglong3/security-keyset.json \
--pepper=/secure/qinglong3/api-credential-pepper \
--delivery=/secure/qinglong3/delivery/new-api-credential.json
```
Use `--delivery` only for `credential.issue` and `credential.rotate`. Its
parent directory must already be private and the target must not exist; the
token is published as a `0600`, no-replace file while stdout contains only its
basename and SHA-256. The four input files must be explicit, bounded and
private. Inject `QL3_POSTGRES_ADMIN_*` separately from runtime and migration
credentials; production defaults to TLS `verify-full`. Exact keyset/JWT rules,
command schemas and operator procedure are documented in
[`ql3-cluster-security-administration`](../../../docs/operations/ql3-cluster-security-administration.md);
response-loss behavior and remaining gates are frozen in
[`ADR-0500`](../../../docs/adr/ADR-0500-short-lived-cluster-security-administration-command.md).
Workstation operators may pass an explicit owner-private context to reuse only
stable client paths:
@@ -88,8 +113,8 @@ ql3-cluster-admin context validate \
This command reuses the production client parsers to validate every selected
endpoint shape, CA, matching client certificate/private key and the bounded
Kubernetes config. It also verifies that all nine facade targets exist in the
same installation. It does not read a command or assertion, open a network
Kubernetes config. It also verifies the context-aware facade targets exist in
the same installation. It does not read a command or assertion, open a network
connection, query Kubernetes or mutate the cluster. Its JSON summary contains
only command names and reviewed transport/authentication classes; paths,
endpoints, namespaces, context names and credentials are never emitted.
+5 -3
View File
@@ -6,10 +6,12 @@
- 目标版本:QingLong 3.x
- 作者:QingLong Maintainers
- 创建日期:2026-07-17
- 最后更新:2026-08-24
- 最后更新:2026-08-25
- 讨论范围:架构与演进路线,不包含最终 UI 视觉方案
最新增量证据(2026-08-24):
最新增量证据(2026-08-25):
- D-405/ADR-0500(已验收):Cluster Identity/API Credential/Security Audit 现在有首个受审产品入口。`ql3-security-admin` 由既有 `ql3-cluster-admin security` facade 到达同一安装内的固定 target,不新建单文件 package,也不进入常驻 `cluster-control`;每次只执行一个 exact-shape 的 identity register/enable/disable、credential issue/rotate/revoke 或最多 200 条的 keyset audit query,随后关闭单连接 admin PostgreSQL authority。命令使用独立 `ql3-security-administration+jwt` type、`security-administration` purpose、audience 与 generation/revocation ledger,拒绝其他管理面的 assertioncommand、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/418-package clean build/test 退出 0backend 为 `1575 total / 1573 pass / 2 conditional skip / 0 fail`。真实 arm64 Admin 镜像在 non-root、read-only、无网络/能力、128 MiB/0.25 CPU 下通过 12-command live contractPostgreSQL 18.6 arm64 physical HA timeline 1→2 的 147 gates 全部通过,报告 SHA-256 为 `8fbb606773080dae15de5e31db5726abb8700862b51e4616b5f3f50e0b8374f3`。package、Cluster dependency、122-module Edge import、部署、镜像与 service bridge 审计全部 compatibleEdge/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 退出 0package、Cluster dependency、122-module Edge import、部署和 14 档 Local artifact 审计全部 compatible,基础 Edge/Standalone 仍为 `2,669,390 / 2,669,468 bytes`、325 files、58 modules。共享 CI 新增原生 x64/arm64 live matrix。该 fixture 关闭 QingLong 直接 custody adapter/data-boundary 门,不冒充生产 Vault HA、KMS/HSM seal、审计设备或灾备证明;ADR-0491 现在只剩固定低性能物理 Edge 的真实空间、RSS/I/O、写放大、ENOSPC 与断电恢复门。
@@ -10074,7 +10076,7 @@ PR-8 的本机最新增量由 ADR-0075/0076/0077/0078/0079/0080/0081/0082/0083/0
ADR-0087 Owner package 更新同样适用于上段 PR-8 累计描述:现行产品 CLI 只能经 console facade 到达其内部 bootstrap/credential-recovery,三个历史 ceremony package 名都只表示旧切片;`ql3-owner-gc` 由 maintenance 直接提供,不再拥有独立 importer。
PR-8 的 cluster ADR-0049 未完成项由 ADR-0050/0051 部分收敛:Identity register/enable/disable、credential issue/rotate/revoke、mutation ledger、强 actor、同事务 audit、有界 audit query,以及常驻 `/api/v3` 的认证前 overload shield 已孵化完成,但仍默认没有远程管理入口。当前 Gate 仍要求受审 CLI/API/UI、部署 ceremony、管理入口独立 rate limit、双人复核或 break-glass、pepper rotation、audit retention/export/alert;不得把 application service 或 process-local HTTP shield 的存在解释为 cluster-control 已获得管理 authority 或全局 quota。
PR-8 的 cluster ADR-0049 未完成项由 ADR-0050/0051/0500 继续收敛:Identity register/enable/disable、credential issue/rotate/revoke、mutation ledger、强 actor、同事务 audit、有界 audit query,以及常驻 `/api/v3` 的认证前 overload shield 已孵化完成D-405 新增默认无 listener、一次只执行一个命令并关闭单连接 admin authority 的 `ql3-security-admin` 产品 CLIcredential 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 ChildProcesssystem 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 退出 0backend 为 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 gatescontent-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 独立证明。
+1
View File
@@ -503,6 +503,7 @@
| [ADR-0497](./ADR-0497-atomic-cluster-legacy-env-migration-application.md) | Cluster Legacy Env 的原子 Task/Trigger 迁移与只追加回执 | Accepted |
| [ADR-0498](./ADR-0498-cluster-legacy-env-application-ha-replay.md) | Cluster Legacy Env Application 的 HA Promotion 后精确重放 | Accepted |
| [ADR-0499](./ADR-0499-direct-vault-kv-worker-secret-custody.md) | 直接 Vault KV Worker Secret 外部托管 | Accepted |
| [ADR-0500](./ADR-0500-short-lived-cluster-security-administration-command.md) | 短生命周期 Cluster Security Administration 产品命令 | Accepted |
## 规则
@@ -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 范围为 1200。可选 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)。
+6
View File
@@ -75,6 +75,11 @@
"require": "./dist/security-administration/clusterAdministration.js",
"default": "./dist/security-administration/clusterAdministration.js"
},
"./administration-command": {
"types": "./dist/security-administration/clusterAdministrationCommand.d.ts",
"require": "./dist/security-administration/clusterAdministrationCommand.js",
"default": "./dist/security-administration/clusterAdministrationCommand.js"
},
"./automation-management": {
"types": "./dist/automation-management/automationManagement.d.ts",
"require": "./dist/automation-management/automationManagement.js",
@@ -414,6 +419,7 @@
},
"bin": {
"ql3-cluster-admin": "dist/product-cli/cli.js",
"ql3-security-admin": "dist/security-administration/clusterAdministrationCli.js",
"ql3-copilot-client": "dist/copilot-client/cli.js",
"ql3-copilot-mcp": "dist/copilot-mcp/cli.js",
"ql3-copilot-console": "dist/copilot-console/cli.js",
@@ -85,6 +85,12 @@ export const CLUSTER_RUN_MANAGEMENT_IDENTITY_ASSERTION_PROFILE = Object.freeze({
purpose: 'run-management',
});
export const CLUSTER_SECURITY_ADMINISTRATION_IDENTITY_ASSERTION_PROFILE =
Object.freeze({
type: 'ql3-security-administration+jwt',
purpose: 'security-administration',
});
export interface ClusterPluginPackageIdentityAssertionVerifierOptions {
readonly issuer: string;
readonly audience: string;
@@ -10,6 +10,7 @@ import {
CLUSTER_APPROVAL_MANAGEMENT_IDENTITY_ASSERTION_PROFILE,
CLUSTER_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_IDENTITY_ASSERTION_PROFILE,
CLUSTER_RUN_MANAGEMENT_IDENTITY_ASSERTION_PROFILE,
CLUSTER_SECURITY_ADMINISTRATION_IDENTITY_ASSERTION_PROFILE,
createClusterPluginPackageIdentityAssertionVerifier,
type ClusterManagementIdentityAssertionProfile,
type ClusterPluginPackageIdentityAssertionAuthentication,
@@ -517,3 +518,13 @@ export function createClusterRunIdentityKeysetFile(
assertionProfile: CLUSTER_RUN_MANAGEMENT_IDENTITY_ASSERTION_PROFILE,
});
}
export function createClusterSecurityAdministrationIdentityKeysetFile(
options: ClusterWorkerCredentialIdentityKeysetFileOptions,
): Readonly<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',
description: 'manage Worker credentials through the authenticated API',
}),
Object.freeze({
name: 'security',
binary: 'ql3-security-admin',
target: 'security-administration/clusterAdministrationCli.js',
description: 'administer identities, API credentials and audit records',
}),
Object.freeze({
name: 'approval',
binary: 'ql3-approval-client',
@@ -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));
}
@@ -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();
}
},
});
}
@@ -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,
createClusterModelProviderCredentialIdentityKeysetFile,
createClusterRunIdentityKeysetFile,
createClusterSecurityAdministrationIdentityKeysetFile,
} = require('@qinglong/cluster-admin/plugin-package-identity-keyset');
const NOW_MS = 1_700_000_000_000;
@@ -249,6 +250,38 @@ function runAssertion(key, overrides = {}) {
).toString('base64url')}`;
}
function securityAdministrationAssertion(key, overrides = {}) {
const header = Buffer.from(
JSON.stringify({
alg: 'EdDSA',
kid: key.kid,
typ: 'ql3-security-administration+jwt',
}),
).toString('base64url');
const now = Math.floor(NOW_MS / 1000);
const payload = Buffer.from(
JSON.stringify({
acr: 'urn:ql3:mfa',
amr: ['pwd', 'otp'],
aud: 'qinglong3-security-administration',
auth_time: now - 10,
exp: now + 120,
iat: now,
iss: ISSUER,
jti: `security-administration-assertion-${key.kid}`,
ql3_purpose: 'security-administration',
sub: 'security-owner-1',
...overrides,
}),
).toString('base64url');
const signed = `${header}.${payload}`;
return `${signed}.${sign(
null,
Buffer.from(signed, 'ascii'),
key.privateKey,
).toString('base64url')}`;
}
async function atomicWrite(filePath, document) {
const nextPath = `${filePath}.next`;
await writeFile(nextPath, `${JSON.stringify(document)}\n`, { mode: 0o644 });
@@ -337,10 +370,9 @@ test('loads an automation keyset with a purpose isolated from other management p
type: 'user',
id: 'automation-operator-1',
});
await assert.rejects(
provider.bind(workerAssertion(key)).authenticate(),
{ code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID' },
);
await assert.rejects(provider.bind(workerAssertion(key)).authenticate(), {
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID',
});
await assert.rejects(provider.bind(assertion(key)).authenticate(), {
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID',
});
@@ -358,14 +390,19 @@ test('loads an Approval keyset isolated by type, purpose and audience', async ()
filePath,
now: () => NOW_MS,
});
const principal = await provider.bind(approvalAssertion(key)).authenticate();
const principal = await provider
.bind(approvalAssertion(key))
.authenticate();
assert.deepEqual(principal.subject, {
type: 'user',
id: 'approval-owner-1',
});
await assert.rejects(provider.bind(automationAssertion(key)).authenticate(), {
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID',
});
await assert.rejects(
provider.bind(automationAssertion(key)).authenticate(),
{
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID',
},
);
await assert.rejects(provider.bind(assertion(key)).authenticate(), {
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID',
});
@@ -435,6 +472,40 @@ test('loads a Run keyset isolated from every other management purpose', async ()
});
});
test('loads a Security Administration keyset isolated from other management purposes', async () => {
await fixture(async ({ filePath }) => {
const key = reviewedKey('security-administration-key-1');
await atomicWrite(filePath, {
...keyset(1, [key]),
audience: 'qinglong3-security-administration',
});
const provider = createClusterSecurityAdministrationIdentityKeysetFile({
filePath,
now: () => NOW_MS,
});
const principal = await provider
.bind(securityAdministrationAssertion(key))
.authenticate();
assert.deepEqual(principal.subject, {
type: 'user',
id: 'security-owner-1',
});
await assert.rejects(provider.bind(runAssertion(key)).authenticate(), {
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID',
});
await assert.rejects(
provider
.bind(
securityAdministrationAssertion(key, {
ql3_purpose: 'run-management',
}),
)
.authenticate(),
{ code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID' },
);
});
});
test('supports overlap rotation then immediately revokes the previous key', async () => {
await fixture(async ({ filePath }) => {
const first = reviewedKey('issuer-key-1');
@@ -336,7 +336,7 @@ function validContextFixture(t) {
test('catalog exposes only reviewed product entrypoints from the same package', () => {
assert.equal(manifest.bin['ql3-cluster-admin'], 'dist/product-cli/cli.js');
assert.equal(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length, 12);
assert.equal(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length, 13);
assert.equal(
new Set(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.map(({ name }) => name)).size,
QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length,
@@ -354,6 +354,7 @@ test('catalog exposes only reviewed product entrypoints from the same package',
);
assert.equal(
command.binary.includes('-client') ||
command.binary === 'ql3-security-admin' ||
command.binary === 'ql3-copilot-mcp' ||
command.binary === 'ql3-copilot-console' ||
command.binary === 'ql3-copilot-evidence-verify',
@@ -391,6 +392,7 @@ test('help and version are bounded installation-derived product facts', () => {
/\n evidence-verify\s+verify one redacted Console evidence/,
);
assert.match(help, /Server, migration, recovery, executor and key-custody/);
assert.match(help, /\n security\s+administer identities, API credentials/);
assert.equal(help.includes('plugin-package-manage'), false);
assert.equal(
loadQingLong3ClusterProductVersion(moduleDirectory),
@@ -923,6 +925,11 @@ test('binary exposes help/version and delegates without a shell', () => {
assert.match(delegatedHelp.stdout, /^Usage: ql3-run-client /);
assert.equal(delegatedHelp.stderr, '');
const securityHelp = runCli(['security', '--help']);
assert.equal(securityHelp.status, 0);
assert.match(securityHelp.stdout, /^Usage: ql3-security-admin /);
assert.equal(securityHelp.stderr, '');
const rejected = runCli(['../../tmp/not-a-command']);
assert.equal(rejected.status, 64);
assert.equal(rejected.stdout, '');
@@ -40,6 +40,10 @@ const COMMANDS = Object.freeze([
name: 'worker-credential',
usage: 'Usage: ql3-worker-credential-client ',
}),
Object.freeze({
name: 'security',
usage: 'Usage: ql3-security-admin ',
}),
Object.freeze({ name: 'approval', usage: 'Usage: ql3-approval-client ' }),
Object.freeze({ name: 'run', usage: 'Usage: ql3-run-client ' }),
Object.freeze({ name: 'automation', usage: 'Usage: ql3-automation-client ' }),
+2 -2
View File
@@ -340,10 +340,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
rootSourceFileRoles: clusterAdmin.rootSourceFileRoles,
},
{
sourceFiles: 129,
sourceFiles: 132,
rootSourceFiles: 1,
rootSourceLines: 61,
nestedSourceFiles: 128,
nestedSourceFiles: 131,
rootSourceFileRoles: {
'modelInvocationMigrationCli.ts': 'binary_entry',
},